Compare commits

..

No commits in common. "4ff9b78db87f85cbd68edaf8743ceefb6bad790b" and "a5cb6ef7d4850b6505102368e941b8c3cb1639f2" have entirely different histories.

45 changed files with 772 additions and 1956 deletions

View file

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

View file

@ -37,7 +37,5 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="Avalonia.Headless" Version="11.3.4" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="11.3.4" />
</ItemGroup>
</Project>

View file

@ -20,7 +20,6 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="AvaloniaEdit.TextMate" />
</ItemGroup>
<ItemGroup>

View file

@ -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()
{

View file

@ -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);
}
}

View file

@ -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&param=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"]);
}
}

View file

@ -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);
}
}

View file

@ -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<IDictionary<string, string>> output = parser.Parse();
ResultAssert.Success(output, v =>
{
Assert.Single(v);
Assert.True(v.TryGetValue(kw, out string? result));
Assert.Equal(val, result);
});
IDictionary<string, string> output = parser.Parse();
Assert.Single(output);
Assert.True(output.TryGetValue(kw, out string? result));
Assert.Equal(val, result);
}
[Fact]
public void StaticParse()
{
Result<IDictionary<string, string>> 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

View file

@ -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;
/// <summary>
/// Shared helper utilities for codecs to reduce duplication (SSL mode mapping, host:port parsing/formatting,
/// URL query parsing, and .NET/libpq quoting helpers).
/// </summary>
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<string, string> ParseQuery(string query)
{
var dict = new Dictionary<string, string>(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;
}
}

View file

@ -8,6 +8,8 @@ namespace pgLabII.PgUtils.ConnectionStrings;
/// </summary>
public sealed class ConnectionDescriptor
{
public string? Name { get; init; }
// Primary hosts (support multi-host). If empty, implies localhost default.
public IReadOnlyList<HostEndpoint> Hosts { get; init; } = new List<HostEndpoint>();
@ -24,4 +26,4 @@ public sealed class ConnectionDescriptor
// Additional parameters preserved across conversions
public IReadOnlyDictionary<string, string> Properties { get; init; } =
new Dictionary<string, string>();
}
}

View file

@ -1,36 +0,0 @@
using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
public sealed class ConnectionDescriptorBuilder
{
private List<HostEndpoint> 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<string, string> 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
};
}
}

View file

@ -21,22 +21,18 @@ public sealed class ConnectionStringService : IConnectionStringService
}
/// <summary>
/// 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).
/// </summary>
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<ConnStringFormat> DetectFormat(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result.Fail<ConnStringFormat>("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))
{

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class JdbcCodec : IConnectionStringCodec
{
public ConnStringFormat Format => ConnStringFormat.Jdbc;
public string FormatName => "JDBC";
public Result<ConnectionDescriptor> TryParse(string input)
{
try
{
if (string.IsNullOrWhiteSpace(input))
return Result.Fail<ConnectionDescriptor>("Empty JDBC url");
var trimmed = input.Trim();
if (!trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
return Result.Fail<ConnectionDescriptor>("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<string>(["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<ConnectionDescriptor>(ex.Message);
}
}
public Result<string> 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<string>(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<string>(ex.Message);
}
}
private static string FormatHost(HostEndpoint h) => CodecCommon.FormatHost(h);
private static bool TryFirst(
Dictionary<string, string> 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;
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<string>(rawHosts.Count);
var portsPerHost = new List<ushort?>(rawHosts.Count);
// First, extract inline ports from each host entry (e.g., host:5432 or [::1]:5432)
foreach (var raw in rawHosts)
{
ParseHostPort(raw, out var hostOnly, out var inlinePort);
hosts.Add(hostOnly);
portsPerHost.Add(inlinePort);
}
// Then, merge values from Port key: single port applies to all hosts missing a port;
// list of ports applies 1:1 for hosts that still miss a port. Inline ports take precedence.
var hosts = SplitList(hostVal).ToList();
List<ushort?> 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<string>();
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<string>(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<string, string> 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<string, string> 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<string, string>(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<HostEndpoint> 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<string, string> 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
};
}
}
}

View file

@ -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.

View file

@ -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<IDictionary<string, string>> 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<string, string>(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<string>();
// 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<HostEndpoint> 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<string,string> 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
};
}
}
}

View file

@ -48,7 +48,7 @@ public ref struct PqConnectionStringParser
//service
//target_session_attrs
public static Result<IDictionary<string, string>> Parse(string input)
public static IDictionary<string, string> Parse(string input)
{
return new PqConnectionStringParser(
new PqConnectionStringTokenizer(input)
@ -63,16 +63,12 @@ public ref struct PqConnectionStringParser
this._tokenizer = tokenizer;
}
public Result<IDictionary<string, string>> Parse()
public IDictionary<string, string> Parse()
{
_result.Clear();
while (!_tokenizer.IsEof)
{
var result = ParsePair();
if (result.IsFailed)
return result;
}
ParsePair();
return _result;
}

View file

@ -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);
}

View file

@ -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<string>(descriptor.Hosts.Count);
foreach (var h in descriptor.Hosts)
@ -165,7 +170,7 @@ public sealed class UrlCodec : IConnectionStringCodec
// query
var queryPairs = new List<string>();
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<string> 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<string>();
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<HostEndpoint> 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<string, string> 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<string, string> ParseQuery(string query)
{
var dict = new Dictionary<string, string>(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;
}
}

View file

@ -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<string,string>{{"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);
}
}

View file

@ -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);
}
}

View file

@ -15,8 +15,6 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup>
<ItemGroup>

View file

@ -1,4 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=hostaddr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=libpq/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=sslmode/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -49,6 +49,6 @@ public partial class App : Application
using var scope = services.CreateScope();
var db = services.GetRequiredService<LocalDb>();
db.Database.Migrate();
//db.Database.Migrate();
}
}

View file

@ -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<ServerConfigurationEntity> ServerConfigurations => Set<ServerConfigurationEntity>();
public DbSet<ServerConfiguration> ServerConfigurations => Set<ServerConfiguration>();
public DbSet<Document> Documents => Set<Document>();
public DbSet<EditHistoryEntry> EditHistory => Set<EditHistoryEntry>();
@ -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<ServerConfigurationEntity>());
new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity<ServerConfiguration>());
new ServerUserEntityConfiguration().Configure(modelBuilder.Entity<ServerUser>());
new DocumentEntityConfiguration().Configure(modelBuilder.Entity<Document>());
new EditHistoryEntityConfiguration().Configure(modelBuilder.Entity<EditHistoryEntry>());
}
@ -41,16 +38,23 @@ public class LocalDb : DbContext
{
base.ConfigureConventions(configurationBuilder);
// Keep Color converter for any other entities still using Avalonia Color
configurationBuilder
.Properties<Color>()
.HaveConversion<ColorConverter>();
}
}
public class ServerConfigurationEntityConfiguration : IEntityTypeConfiguration<ServerConfigurationEntity>
public class ServerConfigurationEntityConfiguration : IEntityTypeConfiguration<ServerConfiguration>
{
public void Configure(EntityTypeBuilder<ServerConfigurationEntity> b)
public void Configure(EntityTypeBuilder<ServerConfiguration> b)
{
b.HasKey(e => e.Id);
}
}
public class ServerUserEntityConfiguration : IEntityTypeConfiguration<ServerUser>
{
public void Configure(EntityTypeBuilder<ServerUser> b)
{
b.HasKey(e => e.Id);
}

View file

@ -1,139 +0,0 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BaseCopyFilename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Documents");
});
modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("DocumentId")
.HasColumnType("INTEGER");
b.Property<string>("InsertedText")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Offset")
.HasColumnType("INTEGER");
b.Property<string>("RemovedText")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DocumentId", "Timestamp");
b.ToTable("EditHistory");
});
modelBuilder.Entity("pgLabII.Model.ServerConfigurationEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ColorArgb")
.HasColumnType("INTEGER");
b.Property<bool>("ColorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Host")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("InitialDatabase")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("TEXT");
b.Property<ushort>("Port")
.HasColumnType("INTEGER");
b.Property<int>("SslMode")
.HasColumnType("INTEGER");
b.Property<string>("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
}
}
}

View file

@ -1,92 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace pgLabII.Migrations
{
/// <inheritdoc />
public partial class First : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Documents",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
OriginalFilename = table.Column<string>(type: "TEXT", nullable: false),
BaseCopyFilename = table.Column<string>(type: "TEXT", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Documents", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ServerConfigurations",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Host = table.Column<string>(type: "TEXT", nullable: false),
Port = table.Column<ushort>(type: "INTEGER", nullable: false),
InitialDatabase = table.Column<string>(type: "TEXT", nullable: false),
SslMode = table.Column<int>(type: "INTEGER", nullable: false),
ColorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
ColorArgb = table.Column<int>(type: "INTEGER", nullable: false),
UserName = table.Column<string>(type: "TEXT", nullable: false),
Password = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerConfigurations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "EditHistory",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DocumentId = table.Column<int>(type: "INTEGER", nullable: false),
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false),
Offset = table.Column<int>(type: "INTEGER", nullable: false),
InsertedText = table.Column<string>(type: "TEXT", nullable: false),
RemovedText = table.Column<string>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EditHistory");
migrationBuilder.DropTable(
name: "ServerConfigurations");
migrationBuilder.DropTable(
name: "Documents");
}
}
}

View file

@ -1,136 +0,0 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BaseCopyFilename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Documents");
});
modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("DocumentId")
.HasColumnType("INTEGER");
b.Property<string>("InsertedText")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Offset")
.HasColumnType("INTEGER");
b.Property<string>("RemovedText")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("DocumentId", "Timestamp");
b.ToTable("EditHistory");
});
modelBuilder.Entity("pgLabII.Model.ServerConfigurationEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ColorArgb")
.HasColumnType("INTEGER");
b.Property<bool>("ColorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Host")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("InitialDatabase")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("TEXT");
b.Property<ushort>("Port")
.HasColumnType("INTEGER");
b.Property<int>("SslMode")
.HasColumnType("INTEGER");
b.Property<string>("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
}
}
}

View file

@ -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();
/// <summary>
/// For the user to help him identify the item
/// </summary>
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<Unit, Unit> EditCommand { get; }
public ReactiveCommand<Unit, Unit> 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;
}
}

View file

@ -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; } = "";
}

View file

@ -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; } = "";
}

View file

@ -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 querys 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 5002000).
- 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 1020k) 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.

View file

@ -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.
/// </summary>
public static ConnectionDescriptor ToDescriptor(ServerConfigurationEntity cfg,
public static ConnectionDescriptor ToDescriptor(ServerConfiguration cfg,
string? applicationName = null,
int? timeoutSeconds = null,
IReadOnlyDictionary<string, string>? 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).
/// </summary>
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<string, string>? 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;
}
}

View file

@ -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<Unit, Unit> ParseConnectionStringCommand { get; }
public ReactiveCommand<Unit, Unit> GenerateConnectionStringCommand { get; }
public ReactiveCommand<Unit, Unit> CopyOutputConnectionStringCommand { get; }
public ServerConfiguration Configuration { get; set; }
public ReactiveCommand<Unit, Unit> SaveAndCloseCommand { get; }
public ReactiveCommand<Unit, Unit> 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;
}
}

View file

@ -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<Unit, Unit> 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<object> Rows { get; } = new();
[Reactive] private IReadOnlyList<ColumnInfo> _columns = new List<ColumnInfo>();
// Commands
public ReactiveCommand<Unit, Unit> RunQuery { get; }
public ReactiveCommand<Unit, Unit> LoadMore { get; }
public ReactiveCommand<Unit, Unit> ExportResults { get; }
public ReactiveCommand<Unit, Unit> OpenSqlFile { get; }
public ReactiveCommand<Unit, Unit> SaveSqlFile { get; }
public QueryToolViewModel(ServerConfigurationEntity? serverConfig)
public QueryToolViewModel()
{
_serverConfig = serverConfig;
// Create command that executes actual SQL queries
RunQuery = ReactiveCommand.CreateFromTask(async () =>
EditCommand = ReactiveCommand.Create(() =>
{
await ExecuteQuery();
Query += " test";
});
LoadMore = ReactiveCommand.Create(() =>
{
// Add more demo rows to see paging UX
for (int i = 1; i <= 3; i++)
{
Rows.Add(new RowData(10));
}
this.RaisePropertyChanged(nameof(Rows)); // Force DataGrid refresh
ResultSummary = $"Showing {Rows.Count} rows";
Status = "Loaded more (stub)";
// Stop after a few loads visually
CanLoadMore = Rows.Count < 12;
});
ExportResults = ReactiveCommand.Create(() =>
{
Status = "Export invoked (stub)";
});
OpenSqlFile = ReactiveCommand.Create(() =>
{
Status = "Open SQL file (stub)";
});
SaveSqlFile = ReactiveCommand.Create(() =>
{
Status = "Save SQL file (stub)";
});
}
private async Task ExecuteQuery()
{
if (_serverConfig == null)
{
Status = "Error: No server configuration selected";
ResultSummary = "Showing 0 rows";
return;
}
if (string.IsNullOrWhiteSpace(UserSql))
{
Status = "Error: SQL query is empty";
ResultSummary = "Showing 0 rows";
return;
}
try
{
Status = "Executing query...";
Rows.Clear();
var connStringBuilder = new NpgsqlConnectionStringBuilder
{
Host = _serverConfig.Host,
Port = _serverConfig.Port,
Database = _serverConfig.InitialDatabase,
Username = _serverConfig.UserName,
Password = _serverConfig.Password,
SslMode = _serverConfig.SslMode,
};
using var connection = new NpgsqlConnection(connStringBuilder.ConnectionString);
await connection.OpenAsync();
using var command = new NpgsqlCommand(UserSql, connection);
using var reader = await command.ExecuteReaderAsync();
// Get column information - build in a temporary list to avoid multiple CollectionChanged events
var schema = reader.GetColumnSchema();
var columnList = new List<ColumnInfo>();
foreach (var column in schema)
{
columnList.Add(new ColumnInfo
{
Name = column.ColumnName ?? "Unknown",
DisplayName = column.ColumnName,
DataType = column.DataType ?? typeof(string),
IsSortable = true,
IsFilterable = true
});
}
// Read rows - also build in a temporary list first
var rowList = new List<object>();
int rowCount = 0;
while (await reader.ReadAsync())
{
var values = new object[reader.FieldCount];
reader.GetValues(values);
// Convert to a dynamic object for the DataGrid
var row = new RowData(reader.FieldCount);
for (int i = 0; i < reader.FieldCount; i++)
{
row.Values[i] = values[i];
}
rowList.Add(row);
rowCount++;
}
// Swap the entire Columns list at once (single property change notification)
Columns = columnList;
// Add all rows at once
Rows.Clear();
foreach (var row in rowList)
{
Rows.Add(row);
}
ResultSummary = $"Showing {rowCount} rows";
Status = "Query executed successfully";
CanLoadMore = false; // TODO: Implement pagination if needed
}
catch (Exception ex)
{
Status = $"Error: {ex.Message}";
ResultSummary = "Showing 0 rows";
Rows.Clear();
Columns = new List<ColumnInfo>();
}
}
}
/// <summary>
/// Dynamic row container for displaying results in the DataGrid
/// </summary>
public class RowData
{
public RowData(int columnCount)
{
Values = new object[columnCount];
}
//public Dictionary<string, object?> Values { get; } = new();
public object[] Values { get; }
public object? this[int idx]
{
get => Values[idx]; //.TryGetValue(key, out var value) ? value : null;
set => Values[idx] = value;
}
}

View file

@ -1,122 +0,0 @@
using System;
using System.Reactive;
using Avalonia.Media;
using Npgsql;
using pgLabII.Model;
using ReactiveUI;
namespace pgLabII.ViewModels;
// UI ViewModel that wraps the persistence entity
public class ServerConfigurationViewModel : ReactiveObject
{
private readonly ServerConfigurationEntity _entity;
public ServerConfigurationViewModel(ServerConfigurationEntity entity)
{
_entity = entity ?? throw new ArgumentNullException(nameof(entity));
EditCommand = ReactiveCommand.Create(() =>
{
var window = new Views.EditServerConfigurationWindow(new(this)) { New = false };
window.Show();
});
ExploreCommand = ReactiveCommand.Create(() =>
{
var window = new Views.SingleDatabaseWindow(entity);
window.Show();
});
}
public ServerConfigurationEntity Entity => _entity;
public Guid Id
{
get => _entity.Id;
set { if (_entity.Id != value) { _entity.Id = value; this.RaisePropertyChanged(); } }
}
public string Name
{
get => _entity.Name;
set { if (_entity.Name != value) { _entity.Name = value; this.RaisePropertyChanged(); } }
}
public string Host
{
get => _entity.Host;
set { if (_entity.Host != value) { _entity.Host = value; this.RaisePropertyChanged(); } }
}
public ushort Port
{
get => _entity.Port;
set { if (_entity.Port != value) { _entity.Port = value; this.RaisePropertyChanged(); } }
}
public string InitialDatabase
{
get => _entity.InitialDatabase;
set { if (_entity.InitialDatabase != value) { _entity.InitialDatabase = value; this.RaisePropertyChanged(); } }
}
public SslMode DefaultSslMode
{
get => _entity.SslMode;
set { if (_entity.SslMode != value) { _entity.SslMode = value; this.RaisePropertyChanged(); } }
}
public bool ColorEnabled
{
get => _entity.ColorEnabled;
set { if (_entity.ColorEnabled != value) { _entity.ColorEnabled = value; this.RaisePropertyChanged(); this.RaisePropertyChanged(nameof(BackgroundBrush)); } }
}
public Color Color
{
get => Color.FromUInt32((uint)_entity.ColorArgb);
set
{
var argb = unchecked((int)value.ToUInt32());
if (_entity.ColorArgb != argb)
{
_entity.ColorArgb = argb;
this.RaisePropertyChanged();
this.RaisePropertyChanged(nameof(BackgroundBrush));
}
}
}
public IBrush? BackgroundBrush => ColorEnabled ? new SolidColorBrush(Color) : null;
public string UserName
{
get => _entity.UserName;
set
{
if (_entity.UserName != value)
{
_entity.UserName = value;
this.RaisePropertyChanged();
}
}
}
public string Password
{
get => _entity.Password;
set
{
if (_entity.Password != value)
{
_entity.Password = value;
this.RaisePropertyChanged();
}
}
}
public ReactiveCommand<Unit, Unit> EditCommand { get; }
public ReactiveCommand<Unit, Unit> ExploreCommand { get; }
}

View file

@ -9,36 +9,36 @@ namespace pgLabII.ViewModels;
public class ServerListViewModel : ViewModelBase
{
public ObservableCollection<ServerConfigurationViewModel> ServerConfigurations { get; } =
public ObservableCollection<ServerConfiguration> ServerConfigurations { get; } =
[
new (new()
new ServerConfiguration()
{
Name = "pg18",
Name = "Local pg15",
Color = Colors.Aquamarine,
ColorEnabled = true,
Host = "localhost",
Port = 5418,
InitialDatabase = "postgres",
UserName = "postgres",
Password = "admin",
})
{
Color = Colors.Aquamarine,
Port = 5434,
User = new ()
{
Name = "postgres",
Password = "admin",
},
},
new (new ()
new ServerConfiguration()
{
Name = "Bar",
ColorEnabled = false,
Host = "db.host.nl"
}),
}
];
public ReactiveCommand<ServerConfigurationViewModel, Unit> RemoveServerCommand { get; }
public ReactiveCommand<ServerConfiguration, Unit> RemoveServerCommand { get; }
public ReactiveCommand<Unit, Unit> AddServerCommand { get; }
public ServerListViewModel()
{
RemoveServerCommand = ReactiveCommand.Create<ServerConfigurationViewModel, Unit>((sc) =>
RemoveServerCommand = ReactiveCommand.Create<ServerConfiguration, Unit>((sc) =>
{
ServerConfigurations.Remove(sc);
return Unit.Default;
@ -46,8 +46,8 @@ public class ServerListViewModel : ViewModelBase
AddServerCommand = ReactiveCommand.Create(() =>
{
EditServerConfigurationViewModel vm = new();
EditServerConfigurationWindow window = new() { DataContext = vm, New = true };
ServerConfiguration sc = new();
EditServerConfigurationWindow window = new() { DataContext = sc, New = true };
window.Show();
});
}

View file

@ -1,5 +1,4 @@
using System.Collections.ObjectModel;
using pgLabII.Model;
namespace pgLabII.ViewModels;
@ -8,17 +7,9 @@ namespace pgLabII.ViewModels;
/// </summary>
public class ViewListViewModel : ViewModelBase
{
private readonly ServerConfigurationEntity serverConfig;
public ViewListViewModel(ServerConfigurationEntity serverConfig)
{
this.serverConfig = serverConfig;
Views = [
new QueryToolViewModel(serverConfig) { Caption = "Abc" },
new QueryToolViewModel(serverConfig) { Caption = "Test" },
];
}
public ObservableCollection<IViewItem> Views { get; private set; }
public ObservableCollection<IViewItem> Views { get; } = [
new QueryToolViewModel() { Caption = "Abc" },
new QueryToolViewModel() { Caption = "Test" } ,
];
}

View file

@ -1,12 +1,9 @@
using System.IO;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using AvaloniaEdit.Document;
using AvaloniaEdit.TextMate;
using TextMateSharp.Grammars;
using System;
using System.Reactive.Linq;
namespace pgLabII.Views.Controls;
@ -20,30 +17,12 @@ public partial class CodeEditorView : UserControl
OriginalFilename = "",
BaseCopyFilename = "",
});
public static readonly AvaloniaProperty<string> TextProperty = AvaloniaProperty.Register<CodeEditorView, string>(
nameof(Text), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public string Text
{
get => Editor?.Text ?? string.Empty;
set
{
if (Editor != null && Editor.Text != value)
Editor.Text = value ?? string.Empty;
}
}
public CodeEditorView()
{
InitializeComponent();
this.GetObservable(TextProperty).Subscribe(text =>
{
if (Editor != null && Editor.Text != text)
Editor.Text = text ?? string.Empty;
});
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
@ -51,15 +30,10 @@ public partial class CodeEditorView : UserControl
var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
_textMate = Editor.InstallTextMate(registryOptions);
_textMate.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions.GetLanguageByExtension(".sql").Id));
Editor.Document.Changed += DocumentChanged;
Editor.TextChanged += Editor_TextChanged;
}
private void Editor_TextChanged(object? sender, EventArgs e)
{
SetValue(TextProperty, Editor.Text);
}
private void OnSaveClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
@ -67,6 +41,7 @@ public partial class CodeEditorView : UserControl
File.WriteAllText("final.sql", Editor.Text);
}
private void DocumentChanged(object? sender, DocumentChangeEventArgs e)
{
_editHistoryManager.AddEdit(e.Offset, e.InsertedText.Text, e.RemovedText.Text);

View file

@ -2,97 +2,37 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="650"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
xmlns:vm="clr-namespace:pgLabII.ViewModels"
x:DataType="vm:EditServerConfigurationViewModel"
x:Class="pgLabII.Views.EditServerConfigurationWindow"
Title="Edit Server Configuration"
Title="EditServerConfiguration"
SizeToContent="WidthAndHeight">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:EditServerConfigurationViewModel />
</Design.DataContext>
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*">
<!-- Basic Details -->
<StackPanel Grid.Row="0" Spacing="6">
<TextBlock FontWeight="Bold" Text="Details" Margin="0,0,0,4"/>
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Name"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Configuration.Name}"/>
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Color"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Spacing="6">
<CheckBox IsChecked="{Binding Configuration.ColorEnabled}"/>
<ColorPicker Color="{Binding Configuration.Color}"/>
</StackPanel>
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Database"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Configuration.InitialDatabase}"/>
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Username"/>
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Configuration.UserName}"/>
<TextBlock Grid.Row="0" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Host"/>
<TextBox Grid.Row="0" Grid.Column="3" Text="{Binding Configuration.Host}"/>
<TextBlock Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Port"/>
<NumericUpDown Grid.Row="1" Grid.Column="3" Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
<TextBlock Grid.Row="2" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="SSL Mode"/>
<ComboBox Grid.Row="2" Grid.Column="3" SelectedIndex="{Binding Configuration.DefaultSslMode}">
<!-- Order must match Npgsql.SslMode enum: Disable(0), Allow(1), Prefer(2), Require(3), VerifyCA(4), VerifyFull(5) -->
<ComboBoxItem>Disable</ComboBoxItem>
<ComboBoxItem>Allow</ComboBoxItem>
<ComboBoxItem>Prefer</ComboBoxItem>
<ComboBoxItem>Require</ComboBoxItem>
<ComboBoxItem>VerifyCA</ComboBoxItem>
<ComboBoxItem>VerifyFull</ComboBoxItem>
</ComboBox>
<TextBlock Grid.Row="3" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Password"/>
<TextBox Grid.Row="3" Grid.Column="3" PasswordChar="*" Text="{Binding Configuration.Password}"/>
</Grid>
<StackPanel >
<TextBlock>Name:</TextBlock>
<TextBox Text="{Binding Configuration.Name}"/>
<TextBlock>Color:</TextBlock>
<StackPanel Orientation="Horizontal">
<CheckBox IsChecked="{Binding Configuration.ColorEnabled}"/>
<ColorPicker Color="{Binding Configuration.Color}"
/>
</StackPanel>
<TextBlock>Host:</TextBlock>
<TextBox Text="{Binding Configuration.Host}"/>
<!-- Input Connection String -->
<StackPanel Grid.Row="1" Margin="0,12,0,0" Spacing="6">
<TextBlock FontWeight="Bold" Text="Connection string input"/>
<TextBlock Text="Paste an existing connection string (libpq, Npgsql, URL, or JDBC)."/>
<TextBox TextWrapping="Wrap" AcceptsReturn="True" MinHeight="60" Text="{Binding InputConnectionString}"/>
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Interpret as"/>
<!-- Bind SelectedIndex to enum (Auto=0, Libpq=1, Npgsql=2, Url=3, Jdbc=4) -->
<ComboBox Grid.Column="1" SelectedIndex="{Binding ForcedFormat}">
<ComboBoxItem>Auto</ComboBoxItem>
<ComboBoxItem>Libpq</ComboBoxItem>
<ComboBoxItem>Npgsql</ComboBoxItem>
<ComboBoxItem>URL</ComboBoxItem>
<ComboBoxItem>JDBC</ComboBoxItem>
</ComboBox>
<TextBlock Grid.Column="2" Margin="12,0,8,0" VerticalAlignment="Center" Text="Detected:"/>
<TextBlock Grid.Column="3" VerticalAlignment="Center" Text="{Binding DetectedFormat}"/>
<Button Grid.Column="4" Margin="12,0,0,0" Content="Parse into fields" Command="{Binding ParseConnectionStringCommand}"/>
</Grid>
</StackPanel>
<TextBlock>Port:</TextBlock>
<NumericUpDown Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
<!-- Output Connection String -->
<StackPanel Grid.Row="2" Margin="0,12,0,0" Spacing="6">
<TextBlock FontWeight="Bold" Text="Connection string output"/>
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Format"/>
<!-- ConnStringFormat: Libpq=0, Npgsql=1, Url=2, Jdbc=3 -->
<ComboBox Grid.Column="1" SelectedIndex="{Binding OutputFormat}">
<ComboBoxItem>Libpq</ComboBoxItem>
<ComboBoxItem>Npgsql</ComboBoxItem>
<ComboBoxItem>URL</ComboBoxItem>
<ComboBoxItem>JDBC</ComboBoxItem>
</ComboBox>
<Button Grid.Column="2" Margin="12,0,0,0" Content="Generate" Command="{Binding GenerateConnectionStringCommand}"/>
<Button Grid.Column="3" Margin="6,0,0,0" Content="Copy" Command="{Binding CopyOutputConnectionStringCommand}"/>
</Grid>
<TextBox TextWrapping="Wrap" AcceptsReturn="True" MinHeight="60" IsReadOnly="True" Text="{Binding OutputConnectionString}"/>
</StackPanel>
<TextBlock>Database:</TextBlock>
<TextBox Text="{Binding Configuration.InitialDatabase}"/>
<!-- Spacer / Future buttons row could go here -->
</Grid>
</StackPanel>
</Window>

View file

@ -18,7 +18,7 @@ public partial class EditServerConfigurationWindow : Window
InitializeComponent();
DataContext = viewModel ?? new EditServerConfigurationViewModel(
new(new()));
new());
}
public bool New { get; set; }

View file

@ -1,40 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:pgLabII.Views.Controls"
xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="pgLabII.Views.QueryToolView"
x:DataType="viewModels:QueryToolViewModel">
<Grid RowDefinitions="Auto,Auto,Auto,*">
<!-- Step 2: Open/Save toolbar -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4">
<Button Content="Open" Command="{Binding OpenSqlFile}" ToolTip.Tip="Open .sql file (Ctrl+O)" />
<Button Content="Save" Command="{Binding SaveSqlFile}" ToolTip.Tip="Save .sql file (Ctrl+S)" Margin="4,0,0,0"/>
</StackPanel>
<!-- Step 2: SQL Editor -->
<controls:CodeEditorView Grid.Row="1"
Text="{Binding UserSql, Mode=TwoWay}" />
<!-- Step 3: Results toolbar -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="4">
<Button Content="Run" Command="{Binding RunQuery}" />
<Button Content="Load more" Command="{Binding LoadMore}" IsEnabled="{Binding CanLoadMore}" Margin="4,0,0,0"/>
<Button Content="Export..." Command="{Binding ExportResults}" Margin="4,0,0,0"/>
<CheckBox Content="Auto-load on scroll" IsChecked="{Binding AutoLoadMore}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding ResultSummary}" Margin="16,0,0,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Status}" Margin="8,0,0,0" VerticalAlignment="Center" Foreground="Gray"/>
</StackPanel>
<!-- Step 4: Results grid -->
<DataGrid Grid.Row="3"
x:Name="ResultsDataGrid"
ItemsSource="{Binding Rows}"
IsReadOnly="True"
SelectionMode="Extended"
CanUserSortColumns="True"
AutoGenerateColumns="False"
Margin="4">
<DataGrid.Columns />
</DataGrid>
</Grid>
</UserControl>

View file

@ -1,71 +0,0 @@
using System.ComponentModel;
using Avalonia.Controls;
using pgLabII.ViewModels;
namespace pgLabII.Views;
public partial class QueryToolView : UserControl
{
private QueryToolViewModel? _currentVm;
public QueryToolView()
{
InitializeComponent();
this.DataContextChanged += (_, _) => WireColumns();
WireColumns();
}
private void WireColumns()
{
// Unsubscribe from previous ViewModel if it exists
if (_currentVm != null)
{
_currentVm.PropertyChanged -= OnViewModelPropertyChanged;
}
if (DataContext is QueryToolViewModel vm)
{
_currentVm = vm;
vm.PropertyChanged += OnViewModelPropertyChanged;
RegenerateColumns(this.ResultsDataGrid, vm);
}
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(QueryToolViewModel.Columns) && DataContext is QueryToolViewModel vm)
{
var grid = this.ResultsDataGrid;
RegenerateColumns(grid, vm);
}
}
private void RegenerateColumns(DataGrid grid, QueryToolViewModel vm)
{
grid.Columns.Clear();
//foreach (var col in vm.Columns)
for (int i = 0; i < vm.Columns.Count; i++)
{
var col = vm.Columns[i];
DataGridColumn gridCol;
if (col.DataType == typeof(bool))
{
gridCol = new DataGridCheckBoxColumn
{
Header = col.DisplayName ?? col.Name,
Binding = new Avalonia.Data.Binding($"Values[{i}]")
};
}
else
{
gridCol = new DataGridTextColumn
{
Header = col.DisplayName ?? col.Name,
Binding = new Avalonia.Data.Binding($"Values[{i}]")
};
}
grid.Columns.Add(gridCol);
}
}
}

View file

@ -3,7 +3,6 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
xmlns:views="clr-namespace:pgLabII.Views"
xmlns:controls="clr-namespace:pgLabII.Views.Controls"
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
@ -48,7 +47,12 @@
<TabControl.ContentTemplate>
<DataTemplate DataType="viewModels:QueryToolViewModel">
<views:QueryToolView />
<DockPanel LastChildFill="True">
<TextBox Text="{Binding Query, Mode=TwoWay}" DockPanel.Dock="Top"/>
<Button Command="{Binding EditCommand}" DockPanel.Dock="Top">TEST</Button>
<controls:CodeEditorView />
</DockPanel>
</DataTemplate>
</TabControl.ContentTemplate>

View file

@ -1,17 +1,15 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using pgLabII.Model;
using pgLabII.ViewModels;
namespace pgLabII.Views;
public partial class SingleDatabaseWindow : Window
{
public SingleDatabaseWindow(ServerConfigurationEntity serverConfig)
public SingleDatabaseWindow()
{
InitializeComponent();
DataContext = new ViewListViewModel(serverConfig);
}
}