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