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