Compare commits

..

10 commits

Author SHA1 Message Date
eelke
4ff9b78db8 Fix local db
- correct path to contain application specific folder
- create migration
- apply the migrations at startup
- reactivate commented out code

The code was failing before because the tables were never created.
2025-10-26 06:27:43 +01:00
eelke
bee0e0915f WIP query tool, executes query for real and shows result 2025-10-25 15:48:19 +02:00
eelke
fd4cb8692d WIP querytool 2025-09-06 13:32:51 +02:00
eelke
d78de23ebc Change NpgsqlCodec to rely on the DbConnectionStringBuilder class for basic parsing and formatting. 2025-09-06 07:36:04 +02:00
eelke
4c7a6c2666 Improve NpgsqlCodec whitespace and quotation rules 2025-09-02 18:50:23 +02:00
eelke
18e737e865 Added user and password input they are now directly fields on the connection configuration and no longer a seperate entity.
Because as long as I have no clear plan for how to manage multiple users per server it is better to keep it simple.

Also some other tweaks to make edits appear in the list.
2025-08-31 19:34:27 +02:00
eelke
747358297b Seperate database entity from ui object (Reactive) 2025-08-31 14:25:27 +02:00
eelke
739d6bd65a Fix libpq parsing and refactors/code cleanup 2025-08-31 13:26:22 +02:00
eelke
0090f39910 JDBC string support 2025-08-31 10:22:08 +02:00
eelke
1d53ca2fc2 connection string/url parsing and generation in the server configuration dialog 2025-08-31 10:12:22 +02:00
45 changed files with 1955 additions and 771 deletions

View file

@ -1,7 +1,8 @@
# pgLabII AI Assistant Guidelines
## Project Context
This is a .NET 8/C# 13 Avalonia cross-platform application for document management.
This is a .NET 9/C# 14 Avalonia cross-platform application for querying and inspecting
postgresql databases. It should also be a good editor for SQL files.
### Architecture Overview
- **Main Project**: pgLabII (Avalonia UI)
@ -12,16 +13,18 @@ This is a .NET 8/C# 13 Avalonia cross-platform application for document manageme
## Coding Standards
### C# Guidelines
- Use C# 13 features and modern .NET patterns
- Use C# 14 features and modern .NET patterns
- Prefer primary constructors for dependency injection
- Use `var` for obvious types, explicit types for clarity
- Implement proper async/await patterns for I/O operations
- Use nullable reference types consistently
- Prefer "target-typed new"
- Prefer "list initializer" over new
- Package versions are managed centrally in Directory.Packages.props
### Avalonia-Specific
- Follow MVVM pattern strictly
- x:Name does work for making components accessible in code-behind
- ViewModels should inherit from appropriate base classes
- Use ReactiveUI patterns where applicable
- Make use of annotations to generate bindable properties

View file

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

View file

@ -14,6 +14,14 @@ public class ConnectionStringServiceTests
Assert.Equal(ConnStringFormat.Url, r.Value);
}
[Fact]
public void DetectFormat_Jdbc()
{
var r = svc.DetectFormat("jdbc:postgresql://localhost/db");
Assert.True(r.IsSuccess);
Assert.Equal(ConnStringFormat.Jdbc, r.Value);
}
[Fact]
public void DetectFormat_Npgsql()
{

View file

@ -0,0 +1,29 @@
using System.Data.Common;
using Npgsql;
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
public class DbConnectionStringBuilderTests
{
[Theory]
[InlineData("abc", "abc")]
[InlineData(" abc ", "abc")]
[InlineData("\"abc \"", "abc ")]
public void TestDecode(string input, string expected)
{
DbConnectionStringBuilder sb = new() { ConnectionString = $"key={input}" };
string result = (string)sb["key"];
Assert.Equal(expected, result);
}
[Theory]
[InlineData("abc", "key=abc")]
[InlineData("abc ", "key=\"abc \"")]
[InlineData("a\"c", "key='a\"c'")]
public void TestEncode(string input, string expected)
{
DbConnectionStringBuilder sb = new();
sb["key"] = input;
Assert.Equal(expected, sb.ConnectionString);
}
}

View file

@ -0,0 +1,55 @@
using pgLabII.PgUtils.ConnectionStrings;
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
public class JdbcCodecTests
{
[Fact]
public void Parse_Basic()
{
var codec = new JdbcCodec();
var r = codec.TryParse("jdbc:postgresql://localhost:5433/mydb?sslmode=require&applicationName=app&connectTimeout=12");
Assert.True(r.IsSuccess);
var d = r.Value;
Assert.Single(d.Hosts);
Assert.Equal("localhost", d.Hosts[0].Host);
Assert.Equal((ushort)5433, d.Hosts[0].Port);
Assert.Equal("mydb", d.Database);
Assert.Equal(Npgsql.SslMode.Require, d.SslMode);
Assert.Equal("app", d.ApplicationName);
Assert.Equal(12, d.TimeoutSeconds);
}
[Fact]
public void Parse_MultiHost_MixedPorts()
{
var codec = new JdbcCodec();
var r = codec.TryParse("jdbc:postgresql://host1:5432,[::1]:5544,host3/db");
Assert.True(r.IsSuccess);
var d = r.Value;
Assert.Equal(3, d.Hosts.Count);
Assert.Equal("host1", d.Hosts[0].Host);
Assert.Equal((ushort)5432, d.Hosts[0].Port);
Assert.Equal("::1", d.Hosts[1].Host);
Assert.Equal((ushort)5544, d.Hosts[1].Port);
Assert.Equal("host3", d.Hosts[2].Host);
Assert.Null(d.Hosts[2].Port);
Assert.Equal("db", d.Database);
}
[Fact]
public void Format_RoundTrip()
{
var codec = new JdbcCodec();
var parsed = codec.TryParse("jdbc:postgresql://hostA,hostB:5555/test_db?applicationName=cli&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,11 +58,12 @@ public class NpgsqlCodecTests
var s = res.Value;
Assert.Contains("Host=db.example.com", s);
Assert.Contains("Port=5432", s);
Assert.Contains("Database=\"prod db\"", s);
Assert.Contains("Username=bob", s);
Assert.Contains("Password=\"p;ss\"\"word\"", s);
Assert.Contains("Database=prod db", s);
Assert.Contains("Username='bob '", s);
// Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior
Assert.Contains("Password='p;ss" + '"' + "word'", s);
Assert.Contains("SSL Mode=VerifyFull", s);
Assert.Contains("Application Name=\"cli app\"", s);
Assert.Contains("Application Name=cli app", s);
Assert.Contains("Timeout=9", s);
Assert.Contains("Search Path=public", s);
}
@ -77,11 +78,12 @@ public class NpgsqlCodecTests
var formatted = codec.TryFormat(parsed.Value);
Assert.True(formatted.IsSuccess);
var s = formatted.Value;
Assert.Contains("Host=\"my host\"", s);
Assert.Contains("Host=my host", s);
Assert.Contains("Database=postgres", s);
Assert.Contains("Username=me", s);
Assert.Contains("Password=\"with;quote\"\"\"", s);
Assert.Contains("Application Name=\"my app\"", s);
// Contains double-quote, no single-quote -> prefer single-quoted per DbConnectionStringBuilder-like behavior; parsed value contains one double-quote
Assert.Contains("Password='with;quote" + '"' + "'", s);
Assert.Contains("Application Name=my app", s);
Assert.Contains("SSL Mode=Prefer", s);
}
}

View file

@ -1,4 +1,6 @@
using pgLabII.PgUtils.ConnectionStrings;
using FluentResults;
using pgLabII.PgUtils.ConnectionStrings;
using pgLabII.PgUtils.Tests.ConnectionStrings.Util;
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
@ -22,20 +24,25 @@ public class PqConnectionStringParserTests
public void Success()
{
var parser = new PqConnectionStringParser(tokenizer);
IDictionary<string, string> output = parser.Parse();
Assert.Single(output);
Assert.True(output.TryGetValue(kw, out string? result));
Assert.Equal(val, result);
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);
});
}
[Fact]
public void StaticParse()
{
var output = PqConnectionStringParser.Parse("foo=bar");
Assert.Single(output);
Assert.True(output.TryGetValue("foo", out string? result));
Assert.Equal("bar", result);
Result<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);
});
}
// There are few tests here as this is a predictive parser and all error handling is done
// in the tokenizer

View file

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
/// <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,8 +8,6 @@ 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>();
@ -26,4 +24,4 @@ public sealed class ConnectionDescriptor
// Additional parameters preserved across conversions
public IReadOnlyDictionary<string, string> Properties { get; init; } =
new Dictionary<string, string>();
}
}

View file

@ -0,0 +1,36 @@
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,18 +21,22 @@ public sealed class ConnectionStringService : IConnectionStringService
}
/// <summary>
/// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url).
/// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url, Jdbc).
/// </summary>
public static ConnectionStringService CreateDefault()
=> new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec() });
=> new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec(), new JdbcCodec() });
public Result<ConnStringFormat> DetectFormat(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result.Fail<ConnStringFormat>("Empty input");
// URL: postgresql:// or postgres://
// URL: postgresql:// or postgres:// or JDBC jdbc:postgresql://
var trimmed = input.TrimStart();
if (trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
{
return Result.Ok(ConnStringFormat.Jdbc);
}
if (trimmed.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
{

View file

@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Text;
using FluentResults;
using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
/// <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,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections;
using System.Data.Common;
using System.Globalization;
using System.Linq;
using System.Text;
using FluentResults;
using Npgsql;
@ -9,19 +8,7 @@ using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
/// <summary>
/// Parser/formatter for Npgsql-style .NET connection strings. We intentionally do not
/// rely on NpgsqlConnectionStringBuilder here because:
/// - We need a lossless, format-agnostic round-trip to our ConnectionDescriptor, including
/// unknown/extension keys and per-host port lists. NpgsqlConnectionStringBuilder normalizes
/// names, may drop unknown keys or coerce values, which breaks lossless conversions.
/// - We support multi-host with per-host ports and want to preserve the original textual
/// representation across conversions. The builder flattens/rewrites these details.
/// - We aim to keep pgLabII.PgUtils independent from Npgsql's evolving parsing rules and
/// version-specific behaviors to ensure stable UX and deterministic tests.
/// - We need symmetric formatting matching our other codecs (libpq/URL/JDBC) and consistent
/// quoting rules across formats.
/// If required, we still reference Npgsql for enums and interop types, but parsing/formatting
/// is done by this small, well-tested custom codec for full control and stability.
/// Parser/formatter for Npgsql-style .NET connection strings.
/// </summary>
public sealed class NpgsqlCodec : IConnectionStringCodec
{
@ -38,30 +25,42 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
// Hosts and Ports
if (dict.TryGetValue("Host", out var hostVal) || dict.TryGetValue("Server", out hostVal) || dict.TryGetValue("Servers", out hostVal))
{
var hosts = SplitList(hostVal).ToList();
List<ushort?> portsPerHost = new();
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.
if (dict.TryGetValue("Port", out var portVal))
{
var ports = SplitList(portVal).ToList();
if (ports.Count == 1 && ushort.TryParse(ports[0], out var singlePort))
if (ports.Count == 1 && ushort.TryParse(ports[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var singlePort))
{
foreach (var _ in hosts) portsPerHost.Add(singlePort);
for (int i = 0; i < portsPerHost.Count; i++)
if (!portsPerHost[i].HasValue)
portsPerHost[i] = singlePort;
}
else if (ports.Count == hosts.Count)
{
foreach (var p in ports)
for (int i = 0; i < ports.Count; i++)
{
if (ushort.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
portsPerHost.Add(up);
else
portsPerHost.Add(null);
if (!portsPerHost[i].HasValue && ushort.TryParse(ports[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
portsPerHost[i] = up;
}
}
}
for (int i = 0; i < hosts.Count; i++)
{
ushort? port = i < portsPerHost.Count ? portsPerHost[i] : null;
descriptor.AddHost(hosts[i], port);
descriptor.AddHost(hosts[i], i < portsPerHost.Count ? portsPerHost[i] : null);
}
}
@ -107,16 +106,16 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
{
try
{
var parts = new List<string>();
var parts = new DbConnectionStringBuilder();
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
{
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
parts.Add(FormatPair("Host", hostList));
parts["Host"] = hostList;
var ports = descriptor.Hosts.Select(h => h.Port).Where(p => p.HasValue).Select(p => p!.Value).Distinct().ToList();
if (ports.Count == 1)
{
parts.Add(FormatPair("Port", ports[0].ToString(CultureInfo.InvariantCulture)));
parts["Port"] = ports[0].ToString(CultureInfo.InvariantCulture);
}
else if (ports.Count == 0)
{
@ -127,31 +126,24 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
// Per-host ports if provided 1:1
var perHost = descriptor.Hosts.Select(h => h.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).ToList();
if (perHost.All(s => !string.IsNullOrEmpty(s)))
parts.Add(FormatPair("Port", string.Join(',', perHost)));
parts["Port"] = string.Join(',', perHost);
}
}
if (!string.IsNullOrEmpty(descriptor.Database))
parts.Add(FormatPair("Database", descriptor.Database));
parts["Database"] = descriptor.Database;
if (!string.IsNullOrEmpty(descriptor.Username))
parts.Add(FormatPair("Username", descriptor.Username));
parts["Username"] = descriptor.Username;
if (!string.IsNullOrEmpty(descriptor.Password))
parts.Add(FormatPair("Password", descriptor.Password));
parts["Password"] = descriptor.Password;
if (descriptor.SslMode.HasValue)
parts.Add(FormatPair("SSL Mode", FormatSslMode(descriptor.SslMode.Value)));
parts["SSL Mode"] = FormatSslMode(descriptor.SslMode.Value);
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
parts.Add(FormatPair("Application Name", descriptor.ApplicationName));
parts["Application Name"] = descriptor.ApplicationName;
if (descriptor.TimeoutSeconds.HasValue)
parts.Add(FormatPair("Timeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)));
parts["Timeout"] = descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture);
var emittedKeys = new HashSet<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));
return Result.Ok(parts.ConnectionString);
}
catch (Exception ex)
{
@ -164,6 +156,42 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static void ParseHostPort(string hostPart, out string host, out ushort? port)
{
host = hostPart;
port = null;
if (string.IsNullOrWhiteSpace(hostPart)) return;
// IPv6 in brackets: [::1]:5432
if (hostPart[0] == '[')
{
int end = hostPart.IndexOf(']');
if (end > 0)
{
host = hostPart.Substring(1, end - 1);
if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
{
var ps = hostPart.Substring(end + 2);
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
port = p;
}
}
return;
}
// Non-IPv6: split on last ':' and ensure right side is numeric
int colon = hostPart.LastIndexOf(':');
if (colon > 0 && colon < hostPart.Length - 1)
{
var ps = hostPart.Substring(colon + 1);
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
{
host = hostPart.Substring(0, colon);
port = p;
}
}
}
private static bool TryGetFirst(Dictionary<string, string> dict, out string value, params string[] keys)
{
foreach (var k in keys)
@ -174,158 +202,25 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
return false;
}
private static SslMode ParseSslMode(string s)
{
switch (s.Trim().ToLowerInvariant())
{
case "disable": return SslMode.Disable;
case "allow": return SslMode.Allow;
case "prefer": return SslMode.Prefer;
case "require": return SslMode.Require;
case "verify-ca":
case "verifyca": return SslMode.VerifyCA;
case "verify-full":
case "verifyfull": return SslMode.VerifyFull;
default: throw new ArgumentException($"Not a valid SSL Mode: {s}");
}
}
private static SslMode ParseSslMode(string s) => CodecCommon.ParseSslModeLoose(s);
private static string FormatSslMode(SslMode mode)
private static string FormatSslMode(SslMode mode) => mode switch
{
return mode switch
{
SslMode.Disable => "Disable",
SslMode.Allow => "Allow",
SslMode.Prefer => "Prefer",
SslMode.Require => "Require",
SslMode.VerifyCA => "VerifyCA",
SslMode.VerifyFull => "VerifyFull",
_ => "Prefer"
};
}
// Npgsql/.NET connection string grammar: semicolon-separated key=value; values with special chars are wrapped in quotes, internal quotes doubled
private static string FormatPair(string key, string? value)
{
value ??= string.Empty;
var needsQuotes = NeedsQuoting(value);
if (!needsQuotes) return key + "=" + value;
return key + "=\"" + EscapeQuoted(value) + "\"";
}
private static bool NeedsQuoting(string value)
{
if (value.Length == 0) return true;
foreach (var c in value)
{
if (char.IsWhiteSpace(c) || c == ';' || c == '=' || c == '"') return true;
}
return false;
}
private static string EscapeQuoted(string value)
{
// Double the quotes per standard DbConnectionString rules
return value.Replace("\"", "\"\"");
}
SslMode.Disable => "Disable",
SslMode.Allow => "Allow",
SslMode.Prefer => "Prefer",
SslMode.Require => "Require",
SslMode.VerifyCA => "VerifyCA",
SslMode.VerifyFull => "VerifyFull",
_ => "Prefer"
};
private static Dictionary<string, string> Tokenize(string input)
{
// Simple tokenizer for .NET connection strings: key=value pairs separated by semicolons; values may be quoted with double quotes
DbConnectionStringBuilder db = new() { ConnectionString = input };
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
int i = 0;
void SkipWs() { while (i < input.Length && char.IsWhiteSpace(input[i])) i++; }
while (true)
{
SkipWs();
if (i >= input.Length) break;
// read key
int keyStart = i;
while (i < input.Length && input[i] != '=') i++;
if (i >= input.Length) { break; }
var key = input.Substring(keyStart, i - keyStart).Trim();
i++; // skip '='
SkipWs();
// read value
string value;
if (i < input.Length && input[i] == '"')
{
i++; // skip opening quote
var sb = new StringBuilder();
while (i < input.Length)
{
char c = input[i++];
if (c == '"')
{
if (i < input.Length && input[i] == '"')
{
// doubled quote -> literal quote
sb.Append('"');
i++;
continue;
}
else
{
break; // end quoted value
}
}
else
{
sb.Append(c);
}
}
value = sb.ToString();
}
else
{
int valStart = i;
while (i < input.Length && input[i] != ';') i++;
value = input.Substring(valStart, i - valStart).Trim();
}
dict[key] = value;
// skip to next, if ; present, consume one
while (i < input.Length && input[i] != ';') i++;
if (i < input.Length && input[i] == ';') i++;
}
foreach (string k in db.Keys)
dict.Add(k, (string)db[k]);
return dict;
}
private sealed class ConnectionDescriptorBuilder
{
public List<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

@ -1,51 +0,0 @@
# Connection Strings Plan
This document tracks the plan for supporting multiple PostgreSQL connection string formats, converting between them, and mapping to/from a canonical model.
## Current Status (2025-08-30)
Implemented:
- Abstractions: `ConnStringFormat`, `HostEndpoint`, `ConnectionDescriptor`, `IConnectionStringCodec`, `IConnectionStringService`.
- Codecs:
- `LibpqCodec` (libpq): parse/format; multi-host; `sslmode`, `application_name`, `connect_timeout`; quoting/escaping; preserves extras.
- `NpgsqlCodec` (.NET/Npgsql): parse/format; alias recognition; multi-host with single or per-host ports; `SSL Mode`, `Application Name`, `Timeout`; double-quote rules; preserves extras.
- Tests for both codecs: parse, format, round-trip, edge quoting.
Not yet implemented:
- URL (postgresql://) codec ✓
- JDBC (jdbc:postgresql://) codec
- Composite `ConnectionStringService` (detect + convert) ✓
- Mapping helpers to/from `ServerConfiguration`
## Updated Plan
1. Define canonical model and interfaces for connection strings. ✓
2. Establish normalization strategy for parameter aliases and extra `Properties` handling. ✓
3. Implement format-specific codecs:
- libpq codec (parse/format; multi-host, quoting, sslmode, timeout, extras). ✓
- Npgsql codec (parse/format; aliases, multi-host/ports, quoting, ssl mode, timeout, extras). ✓
- URL (postgresql://) codec (parse/format; userinfo, host[:port], db, query params, percent-encoding). ✓
- JDBC (jdbc:postgresql://) codec (parse/format; hosts, ports, db, properties; URL-like semantics).
4. Composite conversion service:
- Implement `ConnectionStringService` composing codecs, detecting formats, converting via `ConnectionDescriptor`, and resolving alias priorities.
5. Mapping with application model:
- Add mapping utilities between `ConnectionDescriptor` and `ServerConfiguration` (primary host/port, db, SSL mode), with sensible defaults.
6. Validation and UX:
- Validation for malformed inputs & edge cases (mismatched host/port counts, invalid SSL mode, missing db/host, IPv6 bracket handling).
- Ensure sensitive fields (password) are masked in logs/preview.
7. Tests:
- Unit tests for URL and JDBC codecs; composite service detect/convert; mapping functions; cross-format round-trips; edge cases (spaces, quotes, unicode, IPv6, percent-encoding).
8. Documentation:
- Keep this plan updated and enrich XML docs on codecs/service including alias mappings and quoting/escaping rules per format.
## Next Small Step
Implement the URL (postgresql://) codec with unit tests. Scope:
- Parse: `postgresql://[user[:password]@]host1[:port1][,hostN[:portN]]/[database]?param=value&...`
- Support percent-decoding for user, password, database, and query values.
- Handle IPv6 literals in `[::1]` form; allow multiple hosts with optional per-host ports.
- Map common params: `sslmode`, `application_name`, `connect_timeout` and preserve other query params in `Properties`.
- Format: Build a URL using percent-encoding where required; emit multi-hosts and parameters from `Properties` not already emitted.
- Tests: basic parse/format, quoting/percent-encoding, multi-host with mixed ports, round-trips.
After that, implement the composite `ConnectionStringService` to detect/convert across libpq, Npgsql, and URL formats.

View file

@ -1,9 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text;
using FluentResults;
using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
@ -16,11 +12,13 @@ public sealed class LibpqCodec : IConnectionStringCodec
{
try
{
var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
Result<IDictionary<string, string>> kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
if (kv.IsFailed)
return kv.ToResult();
// libpq keywords are case-insensitive; normalize to lower for lookup
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in kv)
foreach (var pair in kv.Value)
dict[pair.Key] = pair.Value;
var descriptor = new ConnectionDescriptorBuilder();
@ -28,7 +26,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
if (dict.TryGetValue("host", out var host))
{
// libpq supports host lists separated by commas
var hosts = host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
string[] hosts = CodecCommon.SplitHosts(host);
ushort? portForAll = null;
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
portForAll = p;
@ -37,10 +35,10 @@ public sealed class LibpqCodec : IConnectionStringCodec
descriptor.AddHost(h, portForAll);
}
}
if (dict.TryGetValue("hostaddr", out var hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
if (dict.TryGetValue("hostaddr", out string? hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
{
// If hostaddr is provided without host, include as host entries as well
var hosts = hostaddr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// If hostaddr is provided without a host, include as host entries as well
string[] hosts = CodecCommon.SplitHosts(hostaddr);
ushort? portForAll = null;
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
portForAll = p;
@ -58,7 +56,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
descriptor.Password = pass;
if (dict.TryGetValue("sslmode", out var sslStr))
descriptor.SslMode = ParseSslMode(sslStr);
descriptor.SslMode = CodecCommon.ParseSslModeLoose(sslStr);
if (dict.TryGetValue("application_name", out var app))
descriptor.ApplicationName = app;
if (dict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, out var seconds))
@ -90,7 +88,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
var parts = new List<string>();
// Hosts and port
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
if (descriptor.Hosts.Count > 0)
{
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
parts.Add(FormatPair("host", hostList));
@ -107,7 +105,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
if (!string.IsNullOrEmpty(descriptor.Password))
parts.Add(FormatPair("password", descriptor.Password));
if (descriptor.SslMode.HasValue)
parts.Add(FormatPair("sslmode", FormatSslMode(descriptor.SslMode.Value)));
parts.Add(FormatPair("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
parts.Add(FormatPair("application_name", descriptor.ApplicationName));
if (descriptor.TimeoutSeconds.HasValue)
@ -129,34 +127,6 @@ public sealed class LibpqCodec : IConnectionStringCodec
}
}
private static SslMode ParseSslMode(string s)
{
return s.Trim().ToLowerInvariant() switch
{
"disable" => SslMode.Disable,
"allow" => SslMode.Allow,
"prefer" => SslMode.Prefer,
"require" => SslMode.Require,
"verify-ca" => SslMode.VerifyCA,
"verify-full" => SslMode.VerifyFull,
_ => throw new ArgumentException($"Not a valid SSL mode: {s}")
};
}
private static string FormatSslMode(SslMode mode)
{
return mode switch
{
SslMode.Disable => "disable",
SslMode.Allow => "allow",
SslMode.Prefer => "prefer",
SslMode.Require => "require",
SslMode.VerifyCA => "verify-ca",
SslMode.VerifyFull => "verify-full",
_ => "prefer"
};
}
private static string FormatPair(string key, string? value)
{
value ??= string.Empty;
@ -167,56 +137,17 @@ public sealed class LibpqCodec : IConnectionStringCodec
private static bool NeedsQuoting(string value)
{
if (value.Length == 0) return true;
foreach (var c in value)
{
if (char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\')
return true;
}
return false;
return value.Any(c => char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\');
}
private static string EscapeValue(string value)
{
var sb = new StringBuilder();
foreach (var c in value)
foreach (char c in value)
{
if (c == '\'' || c == '\\') sb.Append('\\');
sb.Append(c);
}
return sb.ToString();
}
private sealed class ConnectionDescriptorBuilder
{
public List<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 IDictionary<string, string> Parse(string input)
public static Result<IDictionary<string, string>> Parse(string input)
{
return new PqConnectionStringParser(
new PqConnectionStringTokenizer(input)
@ -63,12 +63,16 @@ public ref struct PqConnectionStringParser
this._tokenizer = tokenizer;
}
public IDictionary<string, string> Parse()
public Result<IDictionary<string, string>> Parse()
{
_result.Clear();
while (!_tokenizer.IsEof)
ParsePair();
{
var result = ParsePair();
if (result.IsFailed)
return result;
}
return _result;
}

View file

@ -72,8 +72,18 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
private string UnquotedString(bool forKeyword)
{
int start = position;
while (++position < input.Length && !char.IsWhiteSpace(input[position]) && (!forKeyword || input[position] != '='))
{ }
while (++position < input.Length)
{
char c = input[position];
// Libpq syntax does not use semicolons as pair separators; treat ';' as invalid here
if (c == ';')
{
// Force tokenizer to stop and later cause a parse error by making GetValue/keyword incomplete
break;
}
if (char.IsWhiteSpace(c)) break;
if (forKeyword && c == '=') break;
}
return input.Substring(start, position - start);
}

View file

@ -1,11 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Globalization;
using System.Text;
using FluentResults;
using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
@ -73,13 +68,12 @@ public sealed class UrlCodec : IConnectionStringCodec
builder.Password = Uri.UnescapeDataString(up[1]);
}
// Parse hosts (may be comma-separated)
foreach (var hostPart in SplitHosts(authority))
// Parse hosts (maybe comma-separated)
foreach (string hostPart in CodecCommon.SplitHosts(authority))
{
if (string.IsNullOrWhiteSpace(hostPart)) continue;
ParseHostPort(hostPart, out var host, out ushort? port);
CodecCommon.ParseHostPort(hostPart, out string host, out ushort? port);
if (!string.IsNullOrEmpty(host))
builder.AddHost(host!, port);
builder.AddHost(host, port);
}
// Parse path (database) and query
@ -88,24 +82,25 @@ public sealed class UrlCodec : IConnectionStringCodec
if (!string.IsNullOrEmpty(pathAndQuery))
{
// pathAndQuery like /db?x=y
var qIdx = pathAndQuery.IndexOf('?');
string path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery;
query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty;
int qIdx = pathAndQuery.IndexOf('?');
string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
if (path.Length > 0)
{
// strip leading '/'
if (path[0] == '/') path = path.Substring(1);
if (path[0] == '/')
path = path[1..];
if (path.Length > 0)
database = Uri.UnescapeDataString(path);
}
}
if (!string.IsNullOrEmpty(database)) builder.Database = database;
var queryDict = ParseQuery(query);
var queryDict = CodecCommon.ParseQuery(query);
// Map known params
if (queryDict.TryGetValue("sslmode", out var sslVal))
builder.SslMode = ParseSslMode(sslVal);
builder.SslMode = CodecCommon.ParseSslModeLoose(sslVal);
if (queryDict.TryGetValue("application_name", out var app))
builder.ApplicationName = app;
if (queryDict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ts))
@ -146,7 +141,7 @@ public sealed class UrlCodec : IConnectionStringCodec
}
// hosts
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
if (descriptor.Hosts.Count > 0)
{
var hostParts = new List<string>(descriptor.Hosts.Count);
foreach (var h in descriptor.Hosts)
@ -170,7 +165,7 @@ public sealed class UrlCodec : IConnectionStringCodec
// query
var queryPairs = new List<string>();
if (descriptor.SslMode.HasValue)
queryPairs.Add("sslmode=" + Uri.EscapeDataString(FormatSslMode(descriptor.SslMode.Value)));
queryPairs.Add("sslmode=" + Uri.EscapeDataString(CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
queryPairs.Add("application_name=" + Uri.EscapeDataString(descriptor.ApplicationName));
if (descriptor.TimeoutSeconds.HasValue)
@ -202,153 +197,4 @@ public sealed class UrlCodec : IConnectionStringCodec
=> key.Equals("sslmode", StringComparison.OrdinalIgnoreCase)
|| key.Equals("application_name", StringComparison.OrdinalIgnoreCase)
|| key.Equals("connect_timeout", StringComparison.OrdinalIgnoreCase);
private static IEnumerable<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()
{
var cfg = new ServerConfiguration
ServerConfigurationEntity cfg = new()
{
Name = "Prod",
Host = "db.example.com",
Port = 5433,
InitialDatabase = "appdb",
DefaultSslMode = SslMode.Require,
User = new ServerUser { Name = "alice", Password = "secret" }
SslMode = SslMode.Require,
UserName = "alice",
Password = "secret"
};
var extra = new Dictionary<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,12 +42,13 @@ public class ServerConfigurationMappingTests
[Fact]
public void ToDescriptor_OmitsEmptyFields()
{
var cfg = new ServerConfiguration
ServerConfigurationEntity cfg = new ()
{
Name = "Empty",
Host = "",
InitialDatabase = "",
User = new ServerUser { Name = "", Password = "" }
UserName = "",
Password = "",
};
var d = ServerConfigurationMapping.ToDescriptor(cfg);
@ -63,7 +64,6 @@ public class ServerConfigurationMappingTests
{
var desc = new ConnectionDescriptor
{
Name = "Staging",
Hosts = new []
{
new HostEndpoint{ Host = "host1", Port = 5432 },
@ -75,28 +75,28 @@ public class ServerConfigurationMappingTests
SslMode = SslMode.VerifyFull
};
var cfg = ServerConfigurationMapping.FromDescriptor(desc);
ServerConfigurationEntity cfg = ServerConfigurationMapping.FromDescriptor(desc);
Assert.Equal("Staging", cfg.Name);
Assert.Equal("host1", cfg.Host);
Assert.Equal((ushort)5432, cfg.Port);
Assert.Equal("stagedb", cfg.InitialDatabase);
Assert.Equal(SslMode.VerifyFull, cfg.DefaultSslMode);
Assert.Equal("bob", cfg.User.Name);
Assert.Equal("pwd", cfg.User.Password);
Assert.Equal(SslMode.VerifyFull, cfg.SslMode);
Assert.Equal("bob", cfg.UserName);
Assert.Equal("pwd", cfg.Password);
}
[Fact]
public void FromDescriptor_UpdatesExisting_PreservesMissing()
{
var existing = new ServerConfiguration
ServerConfigurationEntity existing = new()
{
Name = "Existing",
Host = "keep-host",
Port = 5432,
InitialDatabase = "keepdb",
DefaultSslMode = SslMode.Prefer,
User = new ServerUser { Name = "keepuser", Password = "keeppwd" }
SslMode = SslMode.Prefer,
UserName = "keepuser",
Password = "keeppwd",
};
// Descriptor missing db and user/pass and sslmode
@ -110,33 +110,33 @@ public class ServerConfigurationMappingTests
Assert.Equal("new-host", cfg.Host);
Assert.Equal((ushort)5432, cfg.Port); // unchanged
Assert.Equal("keepdb", cfg.InitialDatabase); // preserved
Assert.Equal(SslMode.Prefer, cfg.DefaultSslMode); // preserved
Assert.Equal("keepuser", cfg.User.Name); // preserved
Assert.Equal("keeppwd", cfg.User.Password); // preserved
Assert.Equal(SslMode.Prefer, cfg.SslMode); // preserved
Assert.Equal("keepuser", cfg.UserName); // preserved
Assert.Equal("keeppwd", cfg.Password); // preserved
}
[Fact]
public void Roundtrip_Basic()
{
var cfg = new ServerConfiguration
ServerConfigurationEntity cfg = new()
{
Name = "Round",
Host = "localhost",
Port = 5432,
InitialDatabase = "postgres",
DefaultSslMode = SslMode.Allow,
User = new ServerUser { Name = "me", Password = "pw" }
SslMode = SslMode.Allow,
UserName = "me",
Password = "pw",
};
var d = ServerConfigurationMapping.ToDescriptor(cfg);
var cfg2 = ServerConfigurationMapping.FromDescriptor(d);
Assert.Equal(cfg.Name, cfg2.Name);
Assert.Equal(cfg.Host, cfg2.Host);
Assert.Equal(cfg.Port, cfg2.Port);
Assert.Equal(cfg.InitialDatabase, cfg2.InitialDatabase);
Assert.Equal(cfg.DefaultSslMode, cfg2.DefaultSslMode);
Assert.Equal(cfg.User.Name, cfg2.User.Name);
Assert.Equal(cfg.User.Password, cfg2.User.Password);
Assert.Equal(cfg.SslMode, cfg2.SslMode);
Assert.Equal(cfg.UserName, cfg2.UserName);
Assert.Equal(cfg.Password, cfg2.Password);
}
}

View file

@ -0,0 +1,83 @@
using System;
using Avalonia.Headless.XUnit;
using pgLabII.Model;
using pgLabII.ViewModels;
using pgLabII.Views;
using Xunit;
namespace pgLabII.Tests.Views;
public class EditServerConfigurationWindowTests
{
[AvaloniaFact]
public void Parse_and_Generate_roundtrip_via_UI_bindings()
{
// Arrange: initialize Avalonia headless app once for the test
var vm = new EditServerConfigurationViewModel(new(new ServerConfigurationEntity()));
var window = new EditServerConfigurationWindow(vm);
// Act: set an URL input, auto mode, then parse
vm.InputConnectionString = "postgresql://user:pass@localhost:5433/mydb?sslmode=require";
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
vm.ParseConnectionStringCommand.Execute().Subscribe();
// Assert fields updated
Assert.Equal("localhost", vm.Configuration.Host);
Assert.Equal((ushort)5433, vm.Configuration.Port);
Assert.Equal("mydb", vm.Configuration.InitialDatabase);
Assert.Equal("user", vm.Configuration.UserName);
Assert.Equal("pass", vm.Configuration.Password);
Assert.Equal(Npgsql.SslMode.Require, vm.Configuration.DefaultSslMode);
// Generate back as libpq and validate
vm.OutputFormat = pgLabII.PgUtils.ConnectionStrings.ConnStringFormat.Libpq;
vm.GenerateConnectionStringCommand.Execute().Subscribe();
var outStr = vm.OutputConnectionString;
Assert.Contains("host=localhost", outStr);
Assert.Contains("port=5433", outStr);
Assert.Contains("dbname=mydb", outStr);
Assert.Contains("user=user", outStr);
Assert.Contains("password=pass", outStr);
Assert.Contains("sslmode=require", outStr);
window.Close();
}
[AvaloniaFact]
public void Forced_format_overrides_auto_detection()
{
var vm = new EditServerConfigurationViewModel(new(new ServerConfigurationEntity()));
// Use a string with quoted values that libpq would struggle with due to incorrect quoting
vm.InputConnectionString = "Host=\"server with spaces\";Username=\"bob\";Password=\"secret\";Database=\"db1\"";
// Force interpret as libpq should fail to parse (libpq expects single quotes, not double quotes for quoting)
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Libpq;
vm.ParseConnectionStringCommand.Execute().Subscribe();
// Since forced libpq parse would fail, configuration should remain default (Host empty)
Assert.True(string.IsNullOrEmpty(vm.Configuration.Host));
// Now set to Auto and parse again -> should detect Npgsql and parse
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
vm.ParseConnectionStringCommand.Execute().Subscribe();
Assert.Equal("server with spaces", vm.Configuration.Host);
Assert.Equal("db1", vm.Configuration.InitialDatabase);
Assert.Equal("bob", vm.Configuration.UserName);
}
[AvaloniaFact]
public void Parse_Npgsql_with_inline_host_port_updates_all_fields()
{
var vm = new EditServerConfigurationViewModel(new(new ServerConfigurationEntity()));
vm.InputConnectionString = "Host=host.docker.internal:5432;Database=kms_quartz;Username=postgres;Password=admin;Trust Server Certificate=true";
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
vm.ParseConnectionStringCommand.Execute().Subscribe();
Assert.Equal("host.docker.internal", vm.Configuration.Host);
Assert.Equal((ushort)5432, vm.Configuration.Port);
Assert.Equal("kms_quartz", vm.Configuration.InitialDatabase);
Assert.Equal("postgres", vm.Configuration.UserName);
Assert.Equal("admin", vm.Configuration.Password);
}
}

View file

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

4
pgLabII.sln.DotSettings Normal file
View file

@ -0,0 +1,4 @@
<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,4 +1,5 @@
using System;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -8,7 +9,7 @@ namespace pgLabII.Infra;
public class LocalDb : DbContext
{
public DbSet<ServerConfiguration> ServerConfigurations => Set<ServerConfiguration>();
public DbSet<ServerConfigurationEntity> ServerConfigurations => Set<ServerConfigurationEntity>();
public DbSet<Document> Documents => Set<Document>();
public DbSet<EditHistoryEntry> EditHistory => Set<EditHistoryEntry>();
@ -16,20 +17,22 @@ public class LocalDb : DbContext
public LocalDb()
{
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
var path = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
path = System.IO.Path.Join(path, "pgLabII");
System.IO.Directory.CreateDirectory(path);
DbPath = System.IO.Path.Join(path, "local.db");
}
// The following configures EF to create a Sqlite database file in the
// special "local" folder for your platform.
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite($"Data Source={DbPath}");
{
options.UseSqlite($"Data Source={DbPath}");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity<ServerConfiguration>());
new ServerUserEntityConfiguration().Configure(modelBuilder.Entity<ServerUser>());
new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity<ServerConfigurationEntity>());
new DocumentEntityConfiguration().Configure(modelBuilder.Entity<Document>());
new EditHistoryEntityConfiguration().Configure(modelBuilder.Entity<EditHistoryEntry>());
}
@ -38,23 +41,16 @@ 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<ServerConfiguration>
public class ServerConfigurationEntityConfiguration : IEntityTypeConfiguration<ServerConfigurationEntity>
{
public void Configure(EntityTypeBuilder<ServerConfiguration> b)
{
b.HasKey(e => e.Id);
}
}
public class ServerUserEntityConfiguration : IEntityTypeConfiguration<ServerUser>
{
public void Configure(EntityTypeBuilder<ServerUser> b)
public void Configure(EntityTypeBuilder<ServerConfigurationEntity> b)
{
b.HasKey(e => e.Id);
}

View file

@ -0,0 +1,139 @@
// <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

@ -0,0 +1,92 @@
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

@ -0,0 +1,136 @@
// <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

@ -1,105 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Reactive;
using Avalonia.Media;
using Npgsql;
using pgLabII.ViewModels;
using pgLabII.Views;
using ReactiveUI;
namespace pgLabII.Model;
public class ServerConfiguration : ReactiveObject
{
private Color _color;
private bool _colorEnabled = true;
private string initialDatabase = "";
public Guid Id { get; set; } = Guid.NewGuid();
/// <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

@ -0,0 +1,20 @@
using System;
using Npgsql;
namespace pgLabII.Model;
// Pure persistence entity for EF Core: no UI dependencies, no ReactiveObject
public class ServerConfigurationEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public ushort Port { get; set; } = 5432;
public string InitialDatabase { get; set; } = string.Empty;
public SslMode SslMode { get; set; } = SslMode.Prefer;
public bool ColorEnabled { get; set; } = true;
public int ColorArgb { get; set; } = unchecked((int)0xFF_33_33_33); // default dark gray
public string UserName { get; set; } = "";
public string Password { get; set; } = "";
}

View file

@ -1,11 +0,0 @@
using System;
namespace pgLabII.Model;
public class ServerUser : ViewModelBase
{
public Guid Id { get; set; }
public Guid ServerConfigurationId { get; set; }
public string Name { get; set; } = "";
public string Password { get; set; } = "";
}

108
pgLabII/QueryToolPlan.md Normal file
View file

@ -0,0 +1,108 @@
# Features
- Is a part of the SingleDatabaseWindow
- It's view should go in QueryToolView.axaml
- Uses mvvm
- AvaloniaEdit should be used as a query editor
## Editor
- Use CodeEditorView
- We want to be able to open and save .sql files with the editor
## Result grid
- Use an Avalonia.Controls.DataGrid
- The columns will change on runtime so it should be able to get the column count captions and data types from the viewmodel
- We want to be able to sort the columns
- We want to be able to filter the rows by defining conditions for the columns
- We want to be able to copy the rows to the clipboard
- We want to be able to customize cell rendering to use different colors for types and also do special things like rendering booleans as green checks and red crosses
- Be aware results may contain many rows, we should make a decision on how to handle this
- We want to be able to save the results to a file
---
# Step-by-step plan to create the Query Tool
1. Add QueryToolView to the UI shell.
- Place the view in pgLabII\Views\QueryToolView.axaml and include it within SingleDatabaseWindow as a child region/panel. Ensure DataContext is set to QueryToolViewModel.
- Confirm MVVM wiring: commands and properties will be bound from the ViewModel.
2. Integrate the SQL editor.
- Embed AvaloniaEdit editor in the top area of QueryToolView.
- Bind editor text to a ViewModel property (e.g., UserSql).
- Provide commands for OpenSqlFile and SaveSqlFile; wire to toolbar/buttons and standard shortcuts (Ctrl+O/Ctrl+S).
- Ensure file filters default to .sql and that encoding/line-endings preserve content when saving.
3. Add a results toolbar for query operations.
- Buttons/controls: Run, Cancel (optional), "Load more", Auto-load on scroll toggle, Export..., and a compact status/summary text (e.g., "Showing X of Y rows").
- Bind to RunQuery, LoadMore, ExportResults, AutoLoadMore, ResultSummary, and Status properties.
4. Add the result grid using Avalonia.Controls.DataGrid.
- Enable row and column virtualization. Keep cell templates lightweight to preserve performance.
- Start with AutoGenerateColumns=true; later switch to explicit columns if custom cell templates per type are needed.
- Bind Items to a read-only observable collection of row objects (e.g., Rows).
- Enable extended selection and clipboard copy.
5. Support dynamic columns and types from the ViewModel.
- Expose a Columns metadata collection (names, data types, display hints) from the ViewModel.
- On first page load, update metadata so the grid can reflect the current 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(ServerConfiguration cfg,
public static ConnectionDescriptor ToDescriptor(ServerConfigurationEntity cfg,
string? applicationName = null,
int? timeoutSeconds = null,
IReadOnlyDictionary<string, string>? extraProperties = null)
@ -43,12 +43,11 @@ public static class ServerConfigurationMapping
return new ConnectionDescriptor
{
Name = cfg.Name,
Hosts = hosts,
Database = string.IsNullOrWhiteSpace(cfg.InitialDatabase) ? null : cfg.InitialDatabase,
Username = string.IsNullOrWhiteSpace(cfg.User?.Name) ? null : cfg.User!.Name,
Password = string.IsNullOrEmpty(cfg.User?.Password) ? null : cfg.User!.Password,
SslMode = cfg.DefaultSslMode,
Username = string.IsNullOrWhiteSpace(cfg.UserName) ? null : cfg.UserName,
Password = string.IsNullOrEmpty(cfg.Password) ? null : cfg.Password,
SslMode = cfg.SslMode,
ApplicationName = applicationName,
TimeoutSeconds = timeoutSeconds,
Properties = props
@ -61,17 +60,13 @@ public static class ServerConfigurationMapping
/// - If descriptor has multiple hosts, the first is mapped to Host/Port.
/// - If descriptor omits sslmode/database/username/password, existing values are preserved (if any).
/// </summary>
public static ServerConfiguration FromDescriptor(ConnectionDescriptor descriptor, ServerConfiguration? existing = null)
public static ServerConfigurationEntity FromDescriptor(ConnectionDescriptor descriptor, ServerConfigurationEntity? existing = null)
{
if (descriptor == null) throw new ArgumentNullException(nameof(descriptor));
var cfg = existing ?? new ServerConfiguration();
// Name
if (!string.IsNullOrWhiteSpace(descriptor.Name))
cfg.Name = descriptor.Name!;
var cfg = existing ?? new ServerConfigurationEntity();
// Host/Port: take first
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
if (descriptor.Hosts.Count > 0)
{
var h = descriptor.Hosts[0];
if (!string.IsNullOrWhiteSpace(h.Host))
@ -86,17 +81,35 @@ public static class ServerConfigurationMapping
// SSL Mode
if (descriptor.SslMode.HasValue)
cfg.DefaultSslMode = descriptor.SslMode.Value;
cfg.SslMode = descriptor.SslMode.Value;
// User
if (cfg.User == null)
cfg.User = new ServerUser();
if (!string.IsNullOrWhiteSpace(descriptor.Username))
cfg.User.Name = descriptor.Username!;
cfg.UserName = descriptor.Username!;
if (!string.IsNullOrEmpty(descriptor.Password))
cfg.User.Password = descriptor.Password!;
cfg.Password = descriptor.Password!;
// Nothing to do for ApplicationName/TimeoutSeconds here; not represented in ServerConfiguration.
return cfg;
}
// Overloads for new UI ViewModel wrapper
public static ConnectionDescriptor ToDescriptor(pgLabII.ViewModels.ServerConfigurationViewModel cfgVm,
string? applicationName = null,
int? timeoutSeconds = null,
IReadOnlyDictionary<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,33 +1,176 @@
using System.Reactive;
using System;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using Npgsql;
using pgLabII.Model;
using pgLabII.PgUtils.ConnectionStrings;
using pgLabII.Services;
using ReactiveUI;
namespace pgLabII.ViewModels;
public class EditServerConfigurationViewModel : ViewModelBase
{
public ServerConfiguration Configuration { get; set; }
// Prefer new UI VM; keep old model for compatibility by wrapping when needed
public ServerConfigurationViewModel Configuration { get; set; }
// Connection string IO
private string _inputConnectionString = string.Empty;
public string InputConnectionString
{
get => _inputConnectionString;
set
{
this.RaiseAndSetIfChanged(ref _inputConnectionString, value);
// auto-detect when input changes and we are in Auto mode
if (ForcedFormat == ForcedFormatOption.Auto)
{
DetectFormat();
}
}
}
public enum ForcedFormatOption
{
Auto,
Libpq,
Npgsql,
Url,
Jdbc
}
private ForcedFormatOption _forcedFormat = ForcedFormatOption.Auto;
public ForcedFormatOption ForcedFormat
{
get => _forcedFormat;
set
{
this.RaiseAndSetIfChanged(ref _forcedFormat, value);
// When forcing off Auto, clear detected label; when switching to Auto, re-detect
if (value == ForcedFormatOption.Auto)
DetectFormat();
else
DetectedFormat = null;
}
}
private ConnStringFormat? _detectedFormat;
public ConnStringFormat? DetectedFormat
{
get => _detectedFormat;
private set => this.RaiseAndSetIfChanged(ref _detectedFormat, value);
}
private ConnStringFormat _outputFormat = ConnStringFormat.Url;
public ConnStringFormat OutputFormat
{
get => _outputFormat;
set => this.RaiseAndSetIfChanged(ref _outputFormat, value);
}
private string _outputConnectionString = string.Empty;
public string OutputConnectionString
{
get => _outputConnectionString;
set => this.RaiseAndSetIfChanged(ref _outputConnectionString, value);
}
public ReactiveCommand<Unit, Unit> ParseConnectionStringCommand { get; }
public ReactiveCommand<Unit, Unit> GenerateConnectionStringCommand { get; }
public ReactiveCommand<Unit, Unit> CopyOutputConnectionStringCommand { get; }
public ReactiveCommand<Unit, Unit> SaveAndCloseCommand { get; }
public ReactiveCommand<Unit, Unit> CloseCommand { get; }
private readonly IConnectionStringService _service;
public EditServerConfigurationViewModel()
{
Configuration = new();
Configuration = new(new ServerConfigurationEntity());
_service = ConnectionStringService.CreateDefault();
SaveAndCloseCommand = ReactiveCommand.Create(() =>
{
});
CloseCommand = ReactiveCommand.Create(() =>
{
});
ParseConnectionStringCommand = ReactiveCommand.Create(ParseConnectionString);
GenerateConnectionStringCommand = ReactiveCommand.Create(GenerateConnectionString);
CopyOutputConnectionStringCommand = ReactiveCommand.Create(() => { /* no-op placeholder */ });
SaveAndCloseCommand = ReactiveCommand.Create(() => { });
CloseCommand = ReactiveCommand.Create(() => { });
}
public EditServerConfigurationViewModel(ServerConfiguration configuration)
public EditServerConfigurationViewModel(ServerConfigurationViewModel configuration)
: this()
{
Configuration = configuration;
}
private void DetectFormat()
{
if (string.IsNullOrWhiteSpace(InputConnectionString))
{
DetectedFormat = null;
return;
}
var res = _service.DetectFormat(InputConnectionString);
DetectedFormat = res.IsSuccess ? res.Value : null;
}
private void ParseConnectionString()
{
if (string.IsNullOrWhiteSpace(InputConnectionString)) return;
var forced = ForcedFormat;
ConnectionDescriptor? descriptor = null;
if (forced != ForcedFormatOption.Auto)
{
IConnectionStringCodec codec = forced switch
{
ForcedFormatOption.Libpq => new LibpqCodec(),
ForcedFormatOption.Npgsql => new NpgsqlCodec(),
ForcedFormatOption.Url => new UrlCodec(),
ForcedFormatOption.Jdbc => new JdbcCodec(),
_ => new UrlCodec()
};
var r = codec.TryParse(InputConnectionString);
if (r.IsSuccess)
descriptor = r.Value;
}
else
{
var r = _service.ParseToDescriptor(InputConnectionString);
if (r.IsSuccess)
descriptor = r.Value;
}
if (descriptor != null)
{
// Map into our configuration (update existing)
ServerConfigurationMapping.FromDescriptorInto(Configuration, descriptor);
// Also set a sensible default OutputFormat to the detected/forced one
if (forced == ForcedFormatOption.Auto)
{
if (DetectedFormat.HasValue) OutputFormat = DetectedFormat.Value;
}
else
{
OutputFormat = forced switch
{
ForcedFormatOption.Libpq => ConnStringFormat.Libpq,
ForcedFormatOption.Npgsql => ConnStringFormat.Npgsql,
ForcedFormatOption.Url => ConnStringFormat.Url,
ForcedFormatOption.Jdbc => ConnStringFormat.Jdbc,
_ => ConnStringFormat.Url
};
}
}
}
private void GenerateConnectionString()
{
// Build descriptor from current configuration
var descriptor = ServerConfigurationMapping.ToDescriptor(Configuration);
var r = _service.FormatFromDescriptor(descriptor, OutputFormat);
if (r.IsSuccess)
OutputConnectionString = r.Value;
}
}

View file

@ -1,22 +1,205 @@
using System.Reactive;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reactive;
using System.Threading.Tasks;
using Npgsql;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using pgLabII.Model;
namespace pgLabII.ViewModels;
public class ColumnInfo
{
public string Name { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public Type DataType { get; set; } = typeof(string);
public bool IsSortable { get; set; } = true;
public bool IsFilterable { get; set; } = true;
// Add more metadata as needed (format, cell template, etc.)
}
public partial class QueryToolViewModel : ViewModelBase, IViewItem
{
[Reactive] private string _caption = "Cap";
private readonly ServerConfigurationEntity? _serverConfig;
[Reactive] private string _query = "";
// Tab caption
[Reactive] private string _caption = "Query";
public ReactiveCommand<Unit, Unit> EditCommand { get; }
// SQL text bound to the editor
[Reactive] private string _userSql = "SELECT n, 1, 'hello', '2025-4-5'::date FROM generate_series(1, 2000) AS d(n)";
public QueryToolViewModel()
// Summary and status labels
[Reactive] private string _resultSummary = "Showing 0 rows";
[Reactive] private string _status = "Ready";
// Paging flags
[Reactive] private bool _canLoadMore;
[Reactive] private bool _autoLoadMore;
// Rows shown in the DataGrid. For now, simple object rows for AutoGenerateColumns.
public ObservableCollection<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)
{
EditCommand = ReactiveCommand.Create(() =>
_serverConfig = serverConfig;
// Create command that executes actual SQL queries
RunQuery = ReactiveCommand.CreateFromTask(async () =>
{
Query += " test";
await ExecuteQuery();
});
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

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

View file

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

View file

@ -1,9 +1,12 @@
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;
@ -17,12 +20,30 @@ 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);
@ -30,10 +51,15 @@ 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)
{
@ -41,7 +67,6 @@ 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,37 +2,97 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="650"
xmlns:vm="clr-namespace:pgLabII.ViewModels"
x:DataType="vm:EditServerConfigurationViewModel"
x:Class="pgLabII.Views.EditServerConfigurationWindow"
Title="EditServerConfiguration"
Title="Edit Server Configuration"
SizeToContent="WidthAndHeight">
<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>
<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}"
/>
<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>Host:</TextBlock>
<TextBox Text="{Binding Configuration.Host}"/>
<TextBlock>Port:</TextBlock>
<NumericUpDown Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
<!-- 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>Database:</TextBlock>
<TextBox Text="{Binding Configuration.InitialDatabase}"/>
<!-- 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>
</StackPanel>
<!-- Spacer / Future buttons row could go here -->
</Grid>
</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

@ -0,0 +1,40 @@
<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

@ -0,0 +1,71 @@
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,6 +3,7 @@
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"
@ -47,12 +48,7 @@
<TabControl.ContentTemplate>
<DataTemplate DataType="viewModels:QueryToolViewModel">
<DockPanel LastChildFill="True">
<TextBox Text="{Binding Query, Mode=TwoWay}" DockPanel.Dock="Top"/>
<Button Command="{Binding EditCommand}" DockPanel.Dock="Top">TEST</Button>
<controls:CodeEditorView />
</DockPanel>
<views:QueryToolView />
</DataTemplate>
</TabControl.ContentTemplate>

View file

@ -1,15 +1,17 @@
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()
public SingleDatabaseWindow(ServerConfigurationEntity serverConfig)
{
InitializeComponent();
DataContext = new ViewListViewModel(serverConfig);
}
}