Compare commits
No commits in common. "4ff9b78db87f85cbd68edaf8743ceefb6bad790b" and "a5cb6ef7d4850b6505102368e941b8c3cb1639f2" have entirely different histories.
4ff9b78db8
...
a5cb6ef7d4
45 changed files with 772 additions and 1956 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,7 +37,5 @@
|
|||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
||||
<PackageVersion Include="Avalonia.Headless" Version="11.3.4" />
|
||||
<PackageVersion Include="Avalonia.Headless.XUnit" Version="11.3.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AvaloniaEdit.TextMate" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
using FluentResults;
|
||||
using pgLabII.PgUtils.ConnectionStrings;
|
||||
using pgLabII.PgUtils.Tests.ConnectionStrings.Util;
|
||||
using pgLabII.PgUtils.ConnectionStrings;
|
||||
|
||||
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
|
||||
|
||||
|
|
@ -24,25 +22,20 @@ public class PqConnectionStringParserTests
|
|||
public void Success()
|
||||
{
|
||||
var parser = new PqConnectionStringParser(tokenizer);
|
||||
Result<IDictionary<string, string>> output = parser.Parse();
|
||||
ResultAssert.Success(output, v =>
|
||||
{
|
||||
Assert.Single(v);
|
||||
Assert.True(v.TryGetValue(kw, out string? result));
|
||||
Assert.Equal(val, result);
|
||||
});
|
||||
IDictionary<string, string> output = parser.Parse();
|
||||
|
||||
Assert.Single(output);
|
||||
Assert.True(output.TryGetValue(kw, out string? result));
|
||||
Assert.Equal(val, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticParse()
|
||||
{
|
||||
Result<IDictionary<string, string>> output = PqConnectionStringParser.Parse("foo=bar");
|
||||
ResultAssert.Success(output, v =>
|
||||
{
|
||||
Assert.Single(v);
|
||||
Assert.True(v.TryGetValue("foo", out string? result));
|
||||
Assert.Equal("bar", result);
|
||||
});
|
||||
var output = PqConnectionStringParser.Parse("foo=bar");
|
||||
Assert.Single(output);
|
||||
Assert.True(output.TryGetValue("foo", out string? result));
|
||||
Assert.Equal("bar", result);
|
||||
}
|
||||
// There are few tests here as this is a predictive parser and all error handling is done
|
||||
// in the tokenizer
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Npgsql;
|
||||
|
||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helper utilities for codecs to reduce duplication (SSL mode mapping, host:port parsing/formatting,
|
||||
/// URL query parsing, and .NET/libpq quoting helpers).
|
||||
/// </summary>
|
||||
internal static class CodecCommon
|
||||
{
|
||||
// SSL mapping
|
||||
public static SslMode ParseSslModeLoose(string s)
|
||||
=> s.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"disable" => SslMode.Disable,
|
||||
"allow" => SslMode.Allow,
|
||||
"prefer" => SslMode.Prefer,
|
||||
"require" => SslMode.Require,
|
||||
"verify-ca" or "verifyca" => SslMode.VerifyCA,
|
||||
"verify-full" or "verifyfull" => SslMode.VerifyFull,
|
||||
_ => throw new ArgumentException($"Not a valid SSL Mode: {s}")
|
||||
};
|
||||
|
||||
public static string FormatSslModeUrlLike(SslMode mode) => mode switch
|
||||
{
|
||||
SslMode.Disable => "disable",
|
||||
SslMode.Allow => "allow",
|
||||
SslMode.Prefer => "prefer",
|
||||
SslMode.Require => "require",
|
||||
SslMode.VerifyCA => "verify-ca",
|
||||
SslMode.VerifyFull => "verify-full",
|
||||
_ => "prefer"
|
||||
};
|
||||
|
||||
|
||||
// host:port parsing for plain or [IPv6]:port
|
||||
public static void ParseHostPort(string hostPart, out string host, out ushort? port)
|
||||
{
|
||||
host = hostPart;
|
||||
port = null;
|
||||
if (string.IsNullOrWhiteSpace(hostPart))
|
||||
return;
|
||||
|
||||
if (hostPart[0] == '[')
|
||||
{
|
||||
int end = hostPart.IndexOf(']');
|
||||
if (end > 0)
|
||||
{
|
||||
host = hostPart[1..end];
|
||||
if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
|
||||
{
|
||||
string ps = hostPart[(end + 2)..];
|
||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
||||
port = p;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
int colon = hostPart.LastIndexOf(':');
|
||||
if (colon > 0 && colon < hostPart.Length - 1)
|
||||
{
|
||||
var ps = hostPart.Substring(colon + 1);
|
||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
||||
{
|
||||
host = hostPart.Substring(0, colon);
|
||||
port = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string FormatHost(HostEndpoint h)
|
||||
{
|
||||
var host = h.Host;
|
||||
if (host.Contains(':') && !host.StartsWith("["))
|
||||
host = "[" + host + "]"; // IPv6
|
||||
|
||||
return h.Port.HasValue ? host + ":" + h.Port.Value.ToString(CultureInfo.InvariantCulture) : host;
|
||||
}
|
||||
|
||||
public static string[] SplitHosts(string hostList)
|
||||
=> hostList.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
public static Dictionary<string, string> ParseQuery(string query)
|
||||
{
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(query)) return dict;
|
||||
foreach (var kv in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var parts = kv.Split('=', 2);
|
||||
var key = Uri.UnescapeDataString(parts[0]);
|
||||
var val = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||
dict[key] = val;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ namespace pgLabII.PgUtils.ConnectionStrings;
|
|||
/// </summary>
|
||||
public sealed class ConnectionDescriptor
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
|
||||
// Primary hosts (support multi-host). If empty, implies localhost default.
|
||||
public IReadOnlyList<HostEndpoint> Hosts { get; init; } = new List<HostEndpoint>();
|
||||
|
||||
|
|
@ -24,4 +26,4 @@ public sealed class ConnectionDescriptor
|
|||
// Additional parameters preserved across conversions
|
||||
public IReadOnlyDictionary<string, string> Properties { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
using Npgsql;
|
||||
|
||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||
|
||||
public sealed class ConnectionDescriptorBuilder
|
||||
{
|
||||
private List<HostEndpoint> Hosts { get; } = [];
|
||||
public string? Database { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public SslMode? SslMode { get; set; }
|
||||
public string? ApplicationName { get; set; }
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
public Dictionary<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddHost(string host, ushort? port)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host)) return;
|
||||
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
||||
}
|
||||
|
||||
public ConnectionDescriptor Build()
|
||||
{
|
||||
return new ConnectionDescriptor
|
||||
{
|
||||
Hosts = Hosts,
|
||||
Database = Database,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
SslMode = SslMode,
|
||||
ApplicationName = ApplicationName,
|
||||
TimeoutSeconds = TimeoutSeconds,
|
||||
Properties = Properties
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -21,22 +21,18 @@ public sealed class ConnectionStringService : IConnectionStringService
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url, Jdbc).
|
||||
/// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url).
|
||||
/// </summary>
|
||||
public static ConnectionStringService CreateDefault()
|
||||
=> new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec(), new JdbcCodec() });
|
||||
=> new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec() });
|
||||
|
||||
public Result<ConnStringFormat> DetectFormat(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return Result.Fail<ConnStringFormat>("Empty input");
|
||||
|
||||
// URL: postgresql:// or postgres:// or JDBC jdbc:postgresql://
|
||||
// URL: postgresql:// or postgres://
|
||||
var trimmed = input.TrimStart();
|
||||
if (trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Result.Ok(ConnStringFormat.Jdbc);
|
||||
}
|
||||
if (trimmed.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,175 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentResults;
|
||||
using Npgsql;
|
||||
|
||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||
|
||||
/// <summary>
|
||||
/// Codec for JDBC PostgreSQL URLs: jdbc:postgresql://host1[:port1][,hostN[:portN]]/[database]?param=value&...
|
||||
/// - Supports multiple hosts with optional per-host ports, IPv6 bracketed literals.
|
||||
/// - Percent-decodes database and query param values on parse; encodes on format.
|
||||
/// - Recognizes sslmode/ssl, applicationName, loginTimeout/connectTimeout and maps to descriptor.
|
||||
/// - Preserves unrecognized parameters in Properties.
|
||||
/// </summary>
|
||||
public sealed class JdbcCodec : IConnectionStringCodec
|
||||
{
|
||||
public ConnStringFormat Format => ConnStringFormat.Jdbc;
|
||||
public string FormatName => "JDBC";
|
||||
|
||||
public Result<ConnectionDescriptor> TryParse(string input)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return Result.Fail<ConnectionDescriptor>("Empty JDBC url");
|
||||
var trimmed = input.Trim();
|
||||
if (!trimmed.StartsWith("jdbc:postgresql://", StringComparison.OrdinalIgnoreCase))
|
||||
return Result.Fail<ConnectionDescriptor>("JDBC url must start with jdbc:postgresql://");
|
||||
|
||||
// Strip scheme
|
||||
var rest = trimmed.Substring("jdbc:postgresql://".Length);
|
||||
|
||||
// Split authority and path+query
|
||||
string authority = rest;
|
||||
string pathAndQuery = string.Empty;
|
||||
var slashIdx = rest.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
authority = rest.Substring(0, slashIdx);
|
||||
pathAndQuery = rest.Substring(slashIdx); // includes '/'
|
||||
}
|
||||
|
||||
var builder = new ConnectionDescriptorBuilder();
|
||||
|
||||
// Parse hosts (comma separated)
|
||||
foreach (string part in CodecCommon.SplitHosts(authority))
|
||||
{
|
||||
CodecCommon.ParseHostPort(part, out var host, out ushort? port);
|
||||
if (!string.IsNullOrEmpty(host))
|
||||
builder.AddHost(host!, port);
|
||||
}
|
||||
|
||||
// Parse database and query
|
||||
string? database = null;
|
||||
string query = string.Empty;
|
||||
if (!string.IsNullOrEmpty(pathAndQuery))
|
||||
{
|
||||
int qIdx = pathAndQuery.IndexOf('?');
|
||||
string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
|
||||
query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
|
||||
if (path.Length > 0)
|
||||
{
|
||||
if (path[0] == '/') path = path.Substring(1);
|
||||
if (path.Length > 0)
|
||||
database = Uri.UnescapeDataString(path);
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrEmpty(database)) builder.Database = database;
|
||||
|
||||
var queryDict = CodecCommon.ParseQuery(query);
|
||||
|
||||
// Map known properties
|
||||
if (TryFirst(queryDict, out string? ssl, "sslmode", "ssl"))
|
||||
builder.SslMode = CodecCommon.ParseSslModeLoose(ssl);
|
||||
if (TryFirst(queryDict, out string? app, "applicationName", "application_name"))
|
||||
builder.ApplicationName = app;
|
||||
if (TryFirst(queryDict, out string? tout, "loginTimeout", "connectTimeout", "connect_timeout"))
|
||||
{
|
||||
if (int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out int t))
|
||||
builder.TimeoutSeconds = t;
|
||||
}
|
||||
|
||||
// Preserve extras
|
||||
var mapped = new HashSet<string>(["sslmode", "ssl", "applicationName", "application_name", "loginTimeout", "connectTimeout", "connect_timeout"
|
||||
], StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in queryDict)
|
||||
{
|
||||
if (!mapped.Contains(kv.Key))
|
||||
builder.Properties[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
return Result.Ok(builder.Build());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail<ConnectionDescriptor>(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Result<string> TryFormat(ConnectionDescriptor descriptor)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("jdbc:postgresql://");
|
||||
|
||||
if (descriptor.Hosts.Count > 0)
|
||||
{
|
||||
sb.Append(string.Join(',', descriptor.Hosts.Select(FormatHost)));
|
||||
}
|
||||
|
||||
// Path with database
|
||||
if (!string.IsNullOrEmpty(descriptor.Database))
|
||||
{
|
||||
sb.Append('/');
|
||||
sb.Append(Uri.EscapeDataString(descriptor.Database));
|
||||
}
|
||||
|
||||
// Query parameters
|
||||
var qp = new List<(string k, string v)>();
|
||||
if (descriptor.SslMode.HasValue)
|
||||
{
|
||||
qp.Add(("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
|
||||
}
|
||||
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
||||
{
|
||||
qp.Add(("applicationName", descriptor.ApplicationName));
|
||||
}
|
||||
if (descriptor.TimeoutSeconds.HasValue)
|
||||
{
|
||||
qp.Add(("connectTimeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
// Add extras not already present
|
||||
var emitted = new HashSet<string>(qp.Select(x => x.k), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in descriptor.Properties)
|
||||
{
|
||||
if (!emitted.Contains(kv.Key))
|
||||
qp.Add((kv.Key, kv.Value));
|
||||
}
|
||||
|
||||
if (qp.Count > 0)
|
||||
{
|
||||
sb.Append('?');
|
||||
sb.Append(string.Join('&', qp.Select(p => Uri.EscapeDataString(p.k) + "=" + Uri.EscapeDataString(p.v ?? string.Empty))));
|
||||
}
|
||||
|
||||
return Result.Ok(sb.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail<string>(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatHost(HostEndpoint h) => CodecCommon.FormatHost(h);
|
||||
|
||||
private static bool TryFirst(
|
||||
Dictionary<string, string> dict,
|
||||
[MaybeNullWhen(false)] out string value,
|
||||
params string[] keys)
|
||||
{
|
||||
foreach (string k in keys)
|
||||
{
|
||||
if (dict.TryGetValue(k, out value))
|
||||
return true;
|
||||
}
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections;
|
||||
using System.Data.Common;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentResults;
|
||||
using Npgsql;
|
||||
|
|
@ -8,7 +9,19 @@ using Npgsql;
|
|||
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||
|
||||
/// <summary>
|
||||
/// Parser/formatter for Npgsql-style .NET connection strings.
|
||||
/// Parser/formatter for Npgsql-style .NET connection strings. We intentionally do not
|
||||
/// rely on NpgsqlConnectionStringBuilder here because:
|
||||
/// - We need a lossless, format-agnostic round-trip to our ConnectionDescriptor, including
|
||||
/// unknown/extension keys and per-host port lists. NpgsqlConnectionStringBuilder normalizes
|
||||
/// names, may drop unknown keys or coerce values, which breaks lossless conversions.
|
||||
/// - We support multi-host with per-host ports and want to preserve the original textual
|
||||
/// representation across conversions. The builder flattens/rewrites these details.
|
||||
/// - We aim to keep pgLabII.PgUtils independent from Npgsql's evolving parsing rules and
|
||||
/// version-specific behaviors to ensure stable UX and deterministic tests.
|
||||
/// - We need symmetric formatting matching our other codecs (libpq/URL/JDBC) and consistent
|
||||
/// quoting rules across formats.
|
||||
/// If required, we still reference Npgsql for enums and interop types, but parsing/formatting
|
||||
/// is done by this small, well-tested custom codec for full control and stability.
|
||||
/// </summary>
|
||||
public sealed class NpgsqlCodec : IConnectionStringCodec
|
||||
{
|
||||
|
|
@ -25,42 +38,30 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
|||
// Hosts and Ports
|
||||
if (dict.TryGetValue("Host", out var hostVal) || dict.TryGetValue("Server", out hostVal) || dict.TryGetValue("Servers", out hostVal))
|
||||
{
|
||||
var rawHosts = SplitList(hostVal).ToList();
|
||||
var hosts = new List<string>(rawHosts.Count);
|
||||
var portsPerHost = new List<ushort?>(rawHosts.Count);
|
||||
|
||||
// First, extract inline ports from each host entry (e.g., host:5432 or [::1]:5432)
|
||||
foreach (var raw in rawHosts)
|
||||
{
|
||||
ParseHostPort(raw, out var hostOnly, out var inlinePort);
|
||||
hosts.Add(hostOnly);
|
||||
portsPerHost.Add(inlinePort);
|
||||
}
|
||||
|
||||
// Then, merge values from Port key: single port applies to all hosts missing a port;
|
||||
// list of ports applies 1:1 for hosts that still miss a port. Inline ports take precedence.
|
||||
var hosts = SplitList(hostVal).ToList();
|
||||
List<ushort?> portsPerHost = new();
|
||||
if (dict.TryGetValue("Port", out var portVal))
|
||||
{
|
||||
var ports = SplitList(portVal).ToList();
|
||||
if (ports.Count == 1 && ushort.TryParse(ports[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var singlePort))
|
||||
if (ports.Count == 1 && ushort.TryParse(ports[0], out var singlePort))
|
||||
{
|
||||
for (int i = 0; i < portsPerHost.Count; i++)
|
||||
if (!portsPerHost[i].HasValue)
|
||||
portsPerHost[i] = singlePort;
|
||||
foreach (var _ in hosts) portsPerHost.Add(singlePort);
|
||||
}
|
||||
else if (ports.Count == hosts.Count)
|
||||
{
|
||||
for (int i = 0; i < ports.Count; i++)
|
||||
foreach (var p in ports)
|
||||
{
|
||||
if (!portsPerHost[i].HasValue && ushort.TryParse(ports[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
|
||||
portsPerHost[i] = up;
|
||||
if (ushort.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
|
||||
portsPerHost.Add(up);
|
||||
else
|
||||
portsPerHost.Add(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < hosts.Count; i++)
|
||||
{
|
||||
descriptor.AddHost(hosts[i], i < portsPerHost.Count ? portsPerHost[i] : null);
|
||||
ushort? port = i < portsPerHost.Count ? portsPerHost[i] : null;
|
||||
descriptor.AddHost(hosts[i], port);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,16 +107,16 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
|||
{
|
||||
try
|
||||
{
|
||||
var parts = new DbConnectionStringBuilder();
|
||||
var parts = new List<string>();
|
||||
|
||||
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
||||
{
|
||||
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
|
||||
parts["Host"] = hostList;
|
||||
parts.Add(FormatPair("Host", hostList));
|
||||
var ports = descriptor.Hosts.Select(h => h.Port).Where(p => p.HasValue).Select(p => p!.Value).Distinct().ToList();
|
||||
if (ports.Count == 1)
|
||||
{
|
||||
parts["Port"] = ports[0].ToString(CultureInfo.InvariantCulture);
|
||||
parts.Add(FormatPair("Port", ports[0].ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
else if (ports.Count == 0)
|
||||
{
|
||||
|
|
@ -126,24 +127,31 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
|||
// Per-host ports if provided 1:1
|
||||
var perHost = descriptor.Hosts.Select(h => h.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).ToList();
|
||||
if (perHost.All(s => !string.IsNullOrEmpty(s)))
|
||||
parts["Port"] = string.Join(',', perHost);
|
||||
parts.Add(FormatPair("Port", string.Join(',', perHost)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(descriptor.Database))
|
||||
parts["Database"] = descriptor.Database;
|
||||
parts.Add(FormatPair("Database", descriptor.Database));
|
||||
if (!string.IsNullOrEmpty(descriptor.Username))
|
||||
parts["Username"] = descriptor.Username;
|
||||
parts.Add(FormatPair("Username", descriptor.Username));
|
||||
if (!string.IsNullOrEmpty(descriptor.Password))
|
||||
parts["Password"] = descriptor.Password;
|
||||
parts.Add(FormatPair("Password", descriptor.Password));
|
||||
if (descriptor.SslMode.HasValue)
|
||||
parts["SSL Mode"] = FormatSslMode(descriptor.SslMode.Value);
|
||||
parts.Add(FormatPair("SSL Mode", FormatSslMode(descriptor.SslMode.Value)));
|
||||
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
||||
parts["Application Name"] = descriptor.ApplicationName;
|
||||
parts.Add(FormatPair("Application Name", descriptor.ApplicationName));
|
||||
if (descriptor.TimeoutSeconds.HasValue)
|
||||
parts["Timeout"] = descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture);
|
||||
parts.Add(FormatPair("Timeout", descriptor.TimeoutSeconds.Value.ToString(CultureInfo.InvariantCulture)));
|
||||
|
||||
return Result.Ok(parts.ConnectionString);
|
||||
var emittedKeys = new HashSet<string>(parts.Select(p => p.Split('=')[0].Trim()), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in descriptor.Properties)
|
||||
{
|
||||
if (!emittedKeys.Contains(kv.Key))
|
||||
parts.Add(FormatPair(kv.Key, kv.Value));
|
||||
}
|
||||
|
||||
return Result.Ok(string.Join(";", parts));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -156,42 +164,6 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
|||
return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static void ParseHostPort(string hostPart, out string host, out ushort? port)
|
||||
{
|
||||
host = hostPart;
|
||||
port = null;
|
||||
if (string.IsNullOrWhiteSpace(hostPart)) return;
|
||||
|
||||
// IPv6 in brackets: [::1]:5432
|
||||
if (hostPart[0] == '[')
|
||||
{
|
||||
int end = hostPart.IndexOf(']');
|
||||
if (end > 0)
|
||||
{
|
||||
host = hostPart.Substring(1, end - 1);
|
||||
if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
|
||||
{
|
||||
var ps = hostPart.Substring(end + 2);
|
||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
||||
port = p;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-IPv6: split on last ':' and ensure right side is numeric
|
||||
int colon = hostPart.LastIndexOf(':');
|
||||
if (colon > 0 && colon < hostPart.Length - 1)
|
||||
{
|
||||
var ps = hostPart.Substring(colon + 1);
|
||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
||||
{
|
||||
host = hostPart.Substring(0, colon);
|
||||
port = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetFirst(Dictionary<string, string> dict, out string value, params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
|
|
@ -202,25 +174,158 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
|
|||
return false;
|
||||
}
|
||||
|
||||
private static SslMode ParseSslMode(string s) => CodecCommon.ParseSslModeLoose(s);
|
||||
|
||||
private static string FormatSslMode(SslMode mode) => mode switch
|
||||
private static SslMode ParseSslMode(string s)
|
||||
{
|
||||
SslMode.Disable => "Disable",
|
||||
SslMode.Allow => "Allow",
|
||||
SslMode.Prefer => "Prefer",
|
||||
SslMode.Require => "Require",
|
||||
SslMode.VerifyCA => "VerifyCA",
|
||||
SslMode.VerifyFull => "VerifyFull",
|
||||
_ => "Prefer"
|
||||
};
|
||||
switch (s.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "disable": return SslMode.Disable;
|
||||
case "allow": return SslMode.Allow;
|
||||
case "prefer": return SslMode.Prefer;
|
||||
case "require": return SslMode.Require;
|
||||
case "verify-ca":
|
||||
case "verifyca": return SslMode.VerifyCA;
|
||||
case "verify-full":
|
||||
case "verifyfull": return SslMode.VerifyFull;
|
||||
default: throw new ArgumentException($"Not a valid SSL Mode: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSslMode(SslMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
SslMode.Disable => "Disable",
|
||||
SslMode.Allow => "Allow",
|
||||
SslMode.Prefer => "Prefer",
|
||||
SslMode.Require => "Require",
|
||||
SslMode.VerifyCA => "VerifyCA",
|
||||
SslMode.VerifyFull => "VerifyFull",
|
||||
_ => "Prefer"
|
||||
};
|
||||
}
|
||||
|
||||
// Npgsql/.NET connection string grammar: semicolon-separated key=value; values with special chars are wrapped in quotes, internal quotes doubled
|
||||
private static string FormatPair(string key, string? value)
|
||||
{
|
||||
value ??= string.Empty;
|
||||
var needsQuotes = NeedsQuoting(value);
|
||||
if (!needsQuotes) return key + "=" + value;
|
||||
return key + "=\"" + EscapeQuoted(value) + "\"";
|
||||
}
|
||||
|
||||
private static bool NeedsQuoting(string value)
|
||||
{
|
||||
if (value.Length == 0) return true;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (char.IsWhiteSpace(c) || c == ';' || c == '=' || c == '"') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string EscapeQuoted(string value)
|
||||
{
|
||||
// Double the quotes per standard DbConnectionString rules
|
||||
return value.Replace("\"", "\"\"");
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Tokenize(string input)
|
||||
{
|
||||
DbConnectionStringBuilder db = new() { ConnectionString = input };
|
||||
// Simple tokenizer for .NET connection strings: key=value pairs separated by semicolons; values may be quoted with double quotes
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string k in db.Keys)
|
||||
dict.Add(k, (string)db[k]);
|
||||
int i = 0;
|
||||
void SkipWs() { while (i < input.Length && char.IsWhiteSpace(input[i])) i++; }
|
||||
|
||||
while (true)
|
||||
{
|
||||
SkipWs();
|
||||
if (i >= input.Length) break;
|
||||
|
||||
// read key
|
||||
int keyStart = i;
|
||||
while (i < input.Length && input[i] != '=') i++;
|
||||
if (i >= input.Length) { break; }
|
||||
var key = input.Substring(keyStart, i - keyStart).Trim();
|
||||
i++; // skip '='
|
||||
SkipWs();
|
||||
|
||||
// read value
|
||||
string value;
|
||||
if (i < input.Length && input[i] == '"')
|
||||
{
|
||||
i++; // skip opening quote
|
||||
var sb = new StringBuilder();
|
||||
while (i < input.Length)
|
||||
{
|
||||
char c = input[i++];
|
||||
if (c == '"')
|
||||
{
|
||||
if (i < input.Length && input[i] == '"')
|
||||
{
|
||||
// doubled quote -> literal quote
|
||||
sb.Append('"');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
break; // end quoted value
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
value = sb.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
int valStart = i;
|
||||
while (i < input.Length && input[i] != ';') i++;
|
||||
value = input.Substring(valStart, i - valStart).Trim();
|
||||
}
|
||||
|
||||
dict[key] = value;
|
||||
|
||||
// skip to next, if ; present, consume one
|
||||
while (i < input.Length && input[i] != ';') i++;
|
||||
if (i < input.Length && input[i] == ';') i++;
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
private sealed class ConnectionDescriptorBuilder
|
||||
{
|
||||
public List<HostEndpoint> Hosts { get; } = new();
|
||||
public string? Database { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public SslMode? SslMode { get; set; }
|
||||
public string? ApplicationName { get; set; }
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
public Dictionary<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddHost(string host, ushort? port)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host)) return;
|
||||
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
||||
}
|
||||
|
||||
public ConnectionDescriptor Build()
|
||||
{
|
||||
return new ConnectionDescriptor
|
||||
{
|
||||
Hosts = Hosts,
|
||||
Database = Database,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
SslMode = SslMode,
|
||||
ApplicationName = ApplicationName,
|
||||
TimeoutSeconds = TimeoutSeconds,
|
||||
Properties = Properties
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
pgLabII.PgUtils/ConnectionStrings/PLAN.md
Normal file
51
pgLabII.PgUtils/ConnectionStrings/PLAN.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Connection Strings Plan
|
||||
|
||||
This document tracks the plan for supporting multiple PostgreSQL connection string formats, converting between them, and mapping to/from a canonical model.
|
||||
|
||||
## Current Status (2025-08-30)
|
||||
|
||||
Implemented:
|
||||
- Abstractions: `ConnStringFormat`, `HostEndpoint`, `ConnectionDescriptor`, `IConnectionStringCodec`, `IConnectionStringService`.
|
||||
- Codecs:
|
||||
- `LibpqCodec` (libpq): parse/format; multi-host; `sslmode`, `application_name`, `connect_timeout`; quoting/escaping; preserves extras.
|
||||
- `NpgsqlCodec` (.NET/Npgsql): parse/format; alias recognition; multi-host with single or per-host ports; `SSL Mode`, `Application Name`, `Timeout`; double-quote rules; preserves extras.
|
||||
- Tests for both codecs: parse, format, round-trip, edge quoting.
|
||||
|
||||
Not yet implemented:
|
||||
- URL (postgresql://) codec ✓
|
||||
- JDBC (jdbc:postgresql://) codec
|
||||
- Composite `ConnectionStringService` (detect + convert) ✓
|
||||
- Mapping helpers to/from `ServerConfiguration` ✓
|
||||
|
||||
## Updated Plan
|
||||
|
||||
1. Define canonical model and interfaces for connection strings. ✓
|
||||
2. Establish normalization strategy for parameter aliases and extra `Properties` handling. ✓
|
||||
3. Implement format-specific codecs:
|
||||
- libpq codec (parse/format; multi-host, quoting, sslmode, timeout, extras). ✓
|
||||
- Npgsql codec (parse/format; aliases, multi-host/ports, quoting, ssl mode, timeout, extras). ✓
|
||||
- URL (postgresql://) codec (parse/format; userinfo, host[:port], db, query params, percent-encoding). ✓
|
||||
- JDBC (jdbc:postgresql://) codec (parse/format; hosts, ports, db, properties; URL-like semantics).
|
||||
4. Composite conversion service:
|
||||
- Implement `ConnectionStringService` composing codecs, detecting formats, converting via `ConnectionDescriptor`, and resolving alias priorities.
|
||||
5. Mapping with application model:
|
||||
- Add mapping utilities between `ConnectionDescriptor` and `ServerConfiguration` (primary host/port, db, SSL mode), with sensible defaults.
|
||||
6. Validation and UX:
|
||||
- Validation for malformed inputs & edge cases (mismatched host/port counts, invalid SSL mode, missing db/host, IPv6 bracket handling).
|
||||
- Ensure sensitive fields (password) are masked in logs/preview.
|
||||
7. Tests:
|
||||
- Unit tests for URL and JDBC codecs; composite service detect/convert; mapping functions; cross-format round-trips; edge cases (spaces, quotes, unicode, IPv6, percent-encoding).
|
||||
8. Documentation:
|
||||
- Keep this plan updated and enrich XML docs on codecs/service including alias mappings and quoting/escaping rules per format.
|
||||
|
||||
## Next Small Step
|
||||
|
||||
Implement the URL (postgresql://) codec with unit tests. Scope:
|
||||
- Parse: `postgresql://[user[:password]@]host1[:port1][,hostN[:portN]]/[database]?param=value&...`
|
||||
- Support percent-decoding for user, password, database, and query values.
|
||||
- Handle IPv6 literals in `[::1]` form; allow multiple hosts with optional per-host ports.
|
||||
- Map common params: `sslmode`, `application_name`, `connect_timeout` and preserve other query params in `Properties`.
|
||||
- Format: Build a URL using percent-encoding where required; emit multi-hosts and parameters from `Properties` not already emitted.
|
||||
- Tests: basic parse/format, quoting/percent-encoding, multi-host with mixed ports, round-trips.
|
||||
|
||||
After that, implement the composite `ConnectionStringService` to detect/convert across libpq, Npgsql, and URL formats.
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
using System.Text;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using FluentResults;
|
||||
using Npgsql;
|
||||
|
||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||
|
||||
|
|
@ -12,13 +16,11 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
|||
{
|
||||
try
|
||||
{
|
||||
Result<IDictionary<string, string>> kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
|
||||
if (kv.IsFailed)
|
||||
return kv.ToResult();
|
||||
var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
|
||||
|
||||
// libpq keywords are case-insensitive; normalize to lower for lookup
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in kv.Value)
|
||||
foreach (var pair in kv)
|
||||
dict[pair.Key] = pair.Value;
|
||||
|
||||
var descriptor = new ConnectionDescriptorBuilder();
|
||||
|
|
@ -26,7 +28,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
|||
if (dict.TryGetValue("host", out var host))
|
||||
{
|
||||
// libpq supports host lists separated by commas
|
||||
string[] hosts = CodecCommon.SplitHosts(host);
|
||||
var hosts = host.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
ushort? portForAll = null;
|
||||
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
|
||||
portForAll = p;
|
||||
|
|
@ -35,10 +37,10 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
|||
descriptor.AddHost(h, portForAll);
|
||||
}
|
||||
}
|
||||
if (dict.TryGetValue("hostaddr", out string? hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
|
||||
if (dict.TryGetValue("hostaddr", out var hostaddr) && !string.IsNullOrWhiteSpace(hostaddr))
|
||||
{
|
||||
// If hostaddr is provided without a host, include as host entries as well
|
||||
string[] hosts = CodecCommon.SplitHosts(hostaddr);
|
||||
// If hostaddr is provided without host, include as host entries as well
|
||||
var hosts = hostaddr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
ushort? portForAll = null;
|
||||
if (dict.TryGetValue("port", out var portStr) && ushort.TryParse(portStr, out var p))
|
||||
portForAll = p;
|
||||
|
|
@ -56,7 +58,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
|||
descriptor.Password = pass;
|
||||
|
||||
if (dict.TryGetValue("sslmode", out var sslStr))
|
||||
descriptor.SslMode = CodecCommon.ParseSslModeLoose(sslStr);
|
||||
descriptor.SslMode = ParseSslMode(sslStr);
|
||||
if (dict.TryGetValue("application_name", out var app))
|
||||
descriptor.ApplicationName = app;
|
||||
if (dict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, out var seconds))
|
||||
|
|
@ -88,7 +90,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
|||
var parts = new List<string>();
|
||||
|
||||
// Hosts and port
|
||||
if (descriptor.Hosts.Count > 0)
|
||||
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
||||
{
|
||||
var hostList = string.Join(',', descriptor.Hosts.Select(h => h.Host));
|
||||
parts.Add(FormatPair("host", hostList));
|
||||
|
|
@ -105,7 +107,7 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
|||
if (!string.IsNullOrEmpty(descriptor.Password))
|
||||
parts.Add(FormatPair("password", descriptor.Password));
|
||||
if (descriptor.SslMode.HasValue)
|
||||
parts.Add(FormatPair("sslmode", CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
|
||||
parts.Add(FormatPair("sslmode", FormatSslMode(descriptor.SslMode.Value)));
|
||||
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
||||
parts.Add(FormatPair("application_name", descriptor.ApplicationName));
|
||||
if (descriptor.TimeoutSeconds.HasValue)
|
||||
|
|
@ -127,6 +129,34 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
|||
}
|
||||
}
|
||||
|
||||
private static SslMode ParseSslMode(string s)
|
||||
{
|
||||
return s.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"disable" => SslMode.Disable,
|
||||
"allow" => SslMode.Allow,
|
||||
"prefer" => SslMode.Prefer,
|
||||
"require" => SslMode.Require,
|
||||
"verify-ca" => SslMode.VerifyCA,
|
||||
"verify-full" => SslMode.VerifyFull,
|
||||
_ => throw new ArgumentException($"Not a valid SSL mode: {s}")
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatSslMode(SslMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
SslMode.Disable => "disable",
|
||||
SslMode.Allow => "allow",
|
||||
SslMode.Prefer => "prefer",
|
||||
SslMode.Require => "require",
|
||||
SslMode.VerifyCA => "verify-ca",
|
||||
SslMode.VerifyFull => "verify-full",
|
||||
_ => "prefer"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatPair(string key, string? value)
|
||||
{
|
||||
value ??= string.Empty;
|
||||
|
|
@ -137,17 +167,56 @@ public sealed class LibpqCodec : IConnectionStringCodec
|
|||
|
||||
private static bool NeedsQuoting(string value)
|
||||
{
|
||||
return value.Any(c => char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\');
|
||||
if (value.Length == 0) return true;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (char.IsWhiteSpace(c) || c == '=' || c == '\'' || c == '\\')
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string EscapeValue(string value)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (char c in value)
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c == '\'' || c == '\\') sb.Append('\\');
|
||||
sb.Append(c);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private sealed class ConnectionDescriptorBuilder
|
||||
{
|
||||
public List<HostEndpoint> Hosts { get; } = new();
|
||||
public string? Database { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public SslMode? SslMode { get; set; }
|
||||
public string? ApplicationName { get; set; }
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
public Dictionary<string,string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddHost(string host, ushort? port)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host)) return;
|
||||
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
||||
}
|
||||
|
||||
public ConnectionDescriptor Build()
|
||||
{
|
||||
return new ConnectionDescriptor
|
||||
{
|
||||
Hosts = Hosts,
|
||||
Database = Database,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
SslMode = SslMode,
|
||||
ApplicationName = ApplicationName,
|
||||
TimeoutSeconds = TimeoutSeconds,
|
||||
Properties = Properties
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ public ref struct PqConnectionStringParser
|
|||
//service
|
||||
//target_session_attrs
|
||||
|
||||
public static Result<IDictionary<string, string>> Parse(string input)
|
||||
public static IDictionary<string, string> Parse(string input)
|
||||
{
|
||||
return new PqConnectionStringParser(
|
||||
new PqConnectionStringTokenizer(input)
|
||||
|
|
@ -63,16 +63,12 @@ public ref struct PqConnectionStringParser
|
|||
this._tokenizer = tokenizer;
|
||||
}
|
||||
|
||||
public Result<IDictionary<string, string>> Parse()
|
||||
public IDictionary<string, string> Parse()
|
||||
{
|
||||
_result.Clear();
|
||||
|
||||
while (!_tokenizer.IsEof)
|
||||
{
|
||||
var result = ParsePair();
|
||||
if (result.IsFailed)
|
||||
return result;
|
||||
}
|
||||
ParsePair();
|
||||
|
||||
return _result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
using System.Globalization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using FluentResults;
|
||||
using Npgsql;
|
||||
|
||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||
|
||||
|
|
@ -68,12 +73,13 @@ public sealed class UrlCodec : IConnectionStringCodec
|
|||
builder.Password = Uri.UnescapeDataString(up[1]);
|
||||
}
|
||||
|
||||
// Parse hosts (maybe comma-separated)
|
||||
foreach (string hostPart in CodecCommon.SplitHosts(authority))
|
||||
// Parse hosts (may be comma-separated)
|
||||
foreach (var hostPart in SplitHosts(authority))
|
||||
{
|
||||
CodecCommon.ParseHostPort(hostPart, out string host, out ushort? port);
|
||||
if (string.IsNullOrWhiteSpace(hostPart)) continue;
|
||||
ParseHostPort(hostPart, out var host, out ushort? port);
|
||||
if (!string.IsNullOrEmpty(host))
|
||||
builder.AddHost(host, port);
|
||||
builder.AddHost(host!, port);
|
||||
}
|
||||
|
||||
// Parse path (database) and query
|
||||
|
|
@ -82,25 +88,24 @@ public sealed class UrlCodec : IConnectionStringCodec
|
|||
if (!string.IsNullOrEmpty(pathAndQuery))
|
||||
{
|
||||
// pathAndQuery like /db?x=y
|
||||
int qIdx = pathAndQuery.IndexOf('?');
|
||||
string path = qIdx >= 0 ? pathAndQuery[..qIdx] : pathAndQuery;
|
||||
query = qIdx >= 0 ? pathAndQuery[(qIdx + 1)..] : string.Empty;
|
||||
var qIdx = pathAndQuery.IndexOf('?');
|
||||
string path = qIdx >= 0 ? pathAndQuery.Substring(0, qIdx) : pathAndQuery;
|
||||
query = qIdx >= 0 ? pathAndQuery.Substring(qIdx + 1) : string.Empty;
|
||||
if (path.Length > 0)
|
||||
{
|
||||
// strip leading '/'
|
||||
if (path[0] == '/')
|
||||
path = path[1..];
|
||||
if (path[0] == '/') path = path.Substring(1);
|
||||
if (path.Length > 0)
|
||||
database = Uri.UnescapeDataString(path);
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrEmpty(database)) builder.Database = database;
|
||||
|
||||
var queryDict = CodecCommon.ParseQuery(query);
|
||||
var queryDict = ParseQuery(query);
|
||||
|
||||
// Map known params
|
||||
if (queryDict.TryGetValue("sslmode", out var sslVal))
|
||||
builder.SslMode = CodecCommon.ParseSslModeLoose(sslVal);
|
||||
builder.SslMode = ParseSslMode(sslVal);
|
||||
if (queryDict.TryGetValue("application_name", out var app))
|
||||
builder.ApplicationName = app;
|
||||
if (queryDict.TryGetValue("connect_timeout", out var tout) && int.TryParse(tout, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ts))
|
||||
|
|
@ -141,7 +146,7 @@ public sealed class UrlCodec : IConnectionStringCodec
|
|||
}
|
||||
|
||||
// hosts
|
||||
if (descriptor.Hosts.Count > 0)
|
||||
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
||||
{
|
||||
var hostParts = new List<string>(descriptor.Hosts.Count);
|
||||
foreach (var h in descriptor.Hosts)
|
||||
|
|
@ -165,7 +170,7 @@ public sealed class UrlCodec : IConnectionStringCodec
|
|||
// query
|
||||
var queryPairs = new List<string>();
|
||||
if (descriptor.SslMode.HasValue)
|
||||
queryPairs.Add("sslmode=" + Uri.EscapeDataString(CodecCommon.FormatSslModeUrlLike(descriptor.SslMode.Value)));
|
||||
queryPairs.Add("sslmode=" + Uri.EscapeDataString(FormatSslMode(descriptor.SslMode.Value)));
|
||||
if (!string.IsNullOrEmpty(descriptor.ApplicationName))
|
||||
queryPairs.Add("application_name=" + Uri.EscapeDataString(descriptor.ApplicationName));
|
||||
if (descriptor.TimeoutSeconds.HasValue)
|
||||
|
|
@ -197,4 +202,153 @@ public sealed class UrlCodec : IConnectionStringCodec
|
|||
=> key.Equals("sslmode", StringComparison.OrdinalIgnoreCase)
|
||||
|| key.Equals("application_name", StringComparison.OrdinalIgnoreCase)
|
||||
|| key.Equals("connect_timeout", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static IEnumerable<string> SplitHosts(string authority)
|
||||
{
|
||||
// authority may contain comma-separated hosts, each may be IPv6 [..] with optional :port
|
||||
// We split on commas that are not inside brackets
|
||||
var parts = new List<string>();
|
||||
int depth = 0;
|
||||
int start = 0;
|
||||
for (int i = 0; i < authority.Length; i++)
|
||||
{
|
||||
char c = authority[i];
|
||||
if (c == '[') depth++;
|
||||
else if (c == ']') depth = Math.Max(0, depth - 1);
|
||||
else if (c == ',' && depth == 0)
|
||||
{
|
||||
parts.Add(authority.Substring(start, i - start));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
// last
|
||||
if (start <= authority.Length)
|
||||
parts.Add(authority.Substring(start));
|
||||
return parts.Select(p => p.Trim()).Where(p => p.Length > 0);
|
||||
}
|
||||
|
||||
private static void ParseHostPort(string hostPart, out string host, out ushort? port)
|
||||
{
|
||||
host = string.Empty; port = null;
|
||||
if (string.IsNullOrWhiteSpace(hostPart)) return;
|
||||
|
||||
if (hostPart[0] == '[')
|
||||
{
|
||||
// IPv6 literal [....]:port?
|
||||
int end = hostPart.IndexOf(']');
|
||||
if (end < 0)
|
||||
{
|
||||
host = hostPart; // let it pass raw
|
||||
return;
|
||||
}
|
||||
var h = hostPart.Substring(1, end - 1);
|
||||
host = h;
|
||||
if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
|
||||
{
|
||||
var ps = hostPart.Substring(end + 2);
|
||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
|
||||
port = up;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// non-IPv6, split last ':' as port if numeric
|
||||
int colon = hostPart.LastIndexOf(':');
|
||||
if (colon > 0 && colon < hostPart.Length - 1)
|
||||
{
|
||||
var ps = hostPart.Substring(colon + 1);
|
||||
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
|
||||
{
|
||||
port = up;
|
||||
host = hostPart.Substring(0, colon);
|
||||
return;
|
||||
}
|
||||
}
|
||||
host = hostPart;
|
||||
}
|
||||
|
||||
private static SslMode ParseSslMode(string s)
|
||||
{
|
||||
switch (s.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "disable": return SslMode.Disable;
|
||||
case "allow": return SslMode.Allow;
|
||||
case "prefer": return SslMode.Prefer;
|
||||
case "require": return SslMode.Require;
|
||||
case "verify-ca":
|
||||
case "verifyca": return SslMode.VerifyCA;
|
||||
case "verify-full":
|
||||
case "verifyfull": return SslMode.VerifyFull;
|
||||
default: throw new ArgumentException($"Not a valid sslmode: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSslMode(SslMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
SslMode.Disable => "disable",
|
||||
SslMode.Allow => "allow",
|
||||
SslMode.Prefer => "prefer",
|
||||
SslMode.Require => "require",
|
||||
SslMode.VerifyCA => "verify-ca",
|
||||
SslMode.VerifyFull => "verify-full",
|
||||
_ => "prefer"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ConnectionDescriptorBuilder
|
||||
{
|
||||
public List<HostEndpoint> Hosts { get; } = new();
|
||||
public string? Database { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public SslMode? SslMode { get; set; }
|
||||
public string? ApplicationName { get; set; }
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
public Dictionary<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddHost(string host, ushort? port)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host)) return;
|
||||
Hosts.Add(new HostEndpoint { Host = host.Trim(), Port = port });
|
||||
}
|
||||
|
||||
public ConnectionDescriptor Build()
|
||||
{
|
||||
return new ConnectionDescriptor
|
||||
{
|
||||
Hosts = Hosts,
|
||||
Database = Database,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
SslMode = SslMode,
|
||||
ApplicationName = ApplicationName,
|
||||
TimeoutSeconds = TimeoutSeconds,
|
||||
Properties = Properties
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseQuery(string query)
|
||||
{
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(query)) return dict;
|
||||
var pairs = query.Split('&', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var idx = pair.IndexOf('=');
|
||||
if (idx < 0)
|
||||
{
|
||||
var k = Uri.UnescapeDataString(pair);
|
||||
dict[k] = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
var k = Uri.UnescapeDataString(pair.Substring(0, idx));
|
||||
var v = Uri.UnescapeDataString(pair.Substring(idx + 1));
|
||||
dict[k] = v;
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,20 @@ public class ServerConfigurationMappingTests
|
|||
[Fact]
|
||||
public void ToDescriptor_Basic_MapsExpectedFields()
|
||||
{
|
||||
ServerConfigurationEntity cfg = new()
|
||||
var cfg = new ServerConfiguration
|
||||
{
|
||||
Name = "Prod",
|
||||
Host = "db.example.com",
|
||||
Port = 5433,
|
||||
InitialDatabase = "appdb",
|
||||
SslMode = SslMode.Require,
|
||||
UserName = "alice",
|
||||
Password = "secret"
|
||||
DefaultSslMode = SslMode.Require,
|
||||
User = new ServerUser { Name = "alice", Password = "secret" }
|
||||
};
|
||||
|
||||
var extra = new Dictionary<string,string>{{"search_path","public"}};
|
||||
var d = ServerConfigurationMapping.ToDescriptor(cfg, applicationName: "pgLabII", timeoutSeconds: 15, extraProperties: extra);
|
||||
|
||||
Assert.Equal("Prod", d.Name);
|
||||
Assert.Single(d.Hosts);
|
||||
Assert.Equal("db.example.com", d.Hosts[0].Host);
|
||||
Assert.Equal((ushort)5433, d.Hosts[0].Port);
|
||||
|
|
@ -42,13 +42,12 @@ public class ServerConfigurationMappingTests
|
|||
[Fact]
|
||||
public void ToDescriptor_OmitsEmptyFields()
|
||||
{
|
||||
ServerConfigurationEntity cfg = new ()
|
||||
var cfg = new ServerConfiguration
|
||||
{
|
||||
Name = "Empty",
|
||||
Host = "",
|
||||
InitialDatabase = "",
|
||||
UserName = "",
|
||||
Password = "",
|
||||
User = new ServerUser { Name = "", Password = "" }
|
||||
};
|
||||
|
||||
var d = ServerConfigurationMapping.ToDescriptor(cfg);
|
||||
|
|
@ -64,6 +63,7 @@ public class ServerConfigurationMappingTests
|
|||
{
|
||||
var desc = new ConnectionDescriptor
|
||||
{
|
||||
Name = "Staging",
|
||||
Hosts = new []
|
||||
{
|
||||
new HostEndpoint{ Host = "host1", Port = 5432 },
|
||||
|
|
@ -75,28 +75,28 @@ public class ServerConfigurationMappingTests
|
|||
SslMode = SslMode.VerifyFull
|
||||
};
|
||||
|
||||
ServerConfigurationEntity cfg = ServerConfigurationMapping.FromDescriptor(desc);
|
||||
var cfg = ServerConfigurationMapping.FromDescriptor(desc);
|
||||
|
||||
Assert.Equal("Staging", cfg.Name);
|
||||
Assert.Equal("host1", cfg.Host);
|
||||
Assert.Equal((ushort)5432, cfg.Port);
|
||||
Assert.Equal("stagedb", cfg.InitialDatabase);
|
||||
Assert.Equal(SslMode.VerifyFull, cfg.SslMode);
|
||||
Assert.Equal("bob", cfg.UserName);
|
||||
Assert.Equal("pwd", cfg.Password);
|
||||
Assert.Equal(SslMode.VerifyFull, cfg.DefaultSslMode);
|
||||
Assert.Equal("bob", cfg.User.Name);
|
||||
Assert.Equal("pwd", cfg.User.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDescriptor_UpdatesExisting_PreservesMissing()
|
||||
{
|
||||
ServerConfigurationEntity existing = new()
|
||||
var existing = new ServerConfiguration
|
||||
{
|
||||
Name = "Existing",
|
||||
Host = "keep-host",
|
||||
Port = 5432,
|
||||
InitialDatabase = "keepdb",
|
||||
SslMode = SslMode.Prefer,
|
||||
UserName = "keepuser",
|
||||
Password = "keeppwd",
|
||||
DefaultSslMode = SslMode.Prefer,
|
||||
User = new ServerUser { Name = "keepuser", Password = "keeppwd" }
|
||||
};
|
||||
|
||||
// Descriptor missing db and user/pass and sslmode
|
||||
|
|
@ -110,33 +110,33 @@ public class ServerConfigurationMappingTests
|
|||
Assert.Equal("new-host", cfg.Host);
|
||||
Assert.Equal((ushort)5432, cfg.Port); // unchanged
|
||||
Assert.Equal("keepdb", cfg.InitialDatabase); // preserved
|
||||
Assert.Equal(SslMode.Prefer, cfg.SslMode); // preserved
|
||||
Assert.Equal("keepuser", cfg.UserName); // preserved
|
||||
Assert.Equal("keeppwd", cfg.Password); // preserved
|
||||
Assert.Equal(SslMode.Prefer, cfg.DefaultSslMode); // preserved
|
||||
Assert.Equal("keepuser", cfg.User.Name); // preserved
|
||||
Assert.Equal("keeppwd", cfg.User.Password); // preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_Basic()
|
||||
{
|
||||
ServerConfigurationEntity cfg = new()
|
||||
var cfg = new ServerConfiguration
|
||||
{
|
||||
Name = "Round",
|
||||
Host = "localhost",
|
||||
Port = 5432,
|
||||
InitialDatabase = "postgres",
|
||||
SslMode = SslMode.Allow,
|
||||
UserName = "me",
|
||||
Password = "pw",
|
||||
DefaultSslMode = SslMode.Allow,
|
||||
User = new ServerUser { Name = "me", Password = "pw" }
|
||||
};
|
||||
|
||||
var d = ServerConfigurationMapping.ToDescriptor(cfg);
|
||||
var cfg2 = ServerConfigurationMapping.FromDescriptor(d);
|
||||
|
||||
Assert.Equal(cfg.Name, cfg2.Name);
|
||||
Assert.Equal(cfg.Host, cfg2.Host);
|
||||
Assert.Equal(cfg.Port, cfg2.Port);
|
||||
Assert.Equal(cfg.InitialDatabase, cfg2.InitialDatabase);
|
||||
Assert.Equal(cfg.SslMode, cfg2.SslMode);
|
||||
Assert.Equal(cfg.UserName, cfg2.UserName);
|
||||
Assert.Equal(cfg.Password, cfg2.Password);
|
||||
Assert.Equal(cfg.DefaultSslMode, cfg2.DefaultSslMode);
|
||||
Assert.Equal(cfg.User.Name, cfg2.User.Name);
|
||||
Assert.Equal(cfg.User.Password, cfg2.User.Password);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,8 +15,6 @@
|
|||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Avalonia.Headless" />
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=hostaddr/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=libpq/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=sslmode/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
|
|
@ -49,6 +49,6 @@ public partial class App : Application
|
|||
using var scope = services.CreateScope();
|
||||
|
||||
var db = services.GetRequiredService<LocalDb>();
|
||||
db.Database.Migrate();
|
||||
//db.Database.Migrate();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
|
@ -9,7 +8,7 @@ namespace pgLabII.Infra;
|
|||
|
||||
public class LocalDb : DbContext
|
||||
{
|
||||
public DbSet<ServerConfigurationEntity> ServerConfigurations => Set<ServerConfigurationEntity>();
|
||||
public DbSet<ServerConfiguration> ServerConfigurations => Set<ServerConfiguration>();
|
||||
public DbSet<Document> Documents => Set<Document>();
|
||||
public DbSet<EditHistoryEntry> EditHistory => Set<EditHistoryEntry>();
|
||||
|
||||
|
|
@ -17,22 +16,20 @@ public class LocalDb : DbContext
|
|||
|
||||
public LocalDb()
|
||||
{
|
||||
var path = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
path = System.IO.Path.Join(path, "pgLabII");
|
||||
System.IO.Directory.CreateDirectory(path);
|
||||
var folder = Environment.SpecialFolder.LocalApplicationData;
|
||||
var path = Environment.GetFolderPath(folder);
|
||||
DbPath = System.IO.Path.Join(path, "local.db");
|
||||
}
|
||||
|
||||
// The following configures EF to create a Sqlite database file in the
|
||||
// special "local" folder for your platform.
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder options)
|
||||
{
|
||||
options.UseSqlite($"Data Source={DbPath}");
|
||||
}
|
||||
=> options.UseSqlite($"Data Source={DbPath}");
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity<ServerConfigurationEntity>());
|
||||
new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity<ServerConfiguration>());
|
||||
new ServerUserEntityConfiguration().Configure(modelBuilder.Entity<ServerUser>());
|
||||
new DocumentEntityConfiguration().Configure(modelBuilder.Entity<Document>());
|
||||
new EditHistoryEntityConfiguration().Configure(modelBuilder.Entity<EditHistoryEntry>());
|
||||
}
|
||||
|
|
@ -41,16 +38,23 @@ public class LocalDb : DbContext
|
|||
{
|
||||
base.ConfigureConventions(configurationBuilder);
|
||||
|
||||
// Keep Color converter for any other entities still using Avalonia Color
|
||||
configurationBuilder
|
||||
.Properties<Color>()
|
||||
.HaveConversion<ColorConverter>();
|
||||
}
|
||||
}
|
||||
|
||||
public class ServerConfigurationEntityConfiguration : IEntityTypeConfiguration<ServerConfigurationEntity>
|
||||
public class ServerConfigurationEntityConfiguration : IEntityTypeConfiguration<ServerConfiguration>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ServerConfigurationEntity> b)
|
||||
public void Configure(EntityTypeBuilder<ServerConfiguration> b)
|
||||
{
|
||||
b.HasKey(e => e.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public class ServerUserEntityConfiguration : IEntityTypeConfiguration<ServerUser>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ServerUser> b)
|
||||
{
|
||||
b.HasKey(e => e.Id);
|
||||
}
|
||||
|
|
|
|||
139
pgLabII/Migrations/20251025162617_First.Designer.cs
generated
139
pgLabII/Migrations/20251025162617_First.Designer.cs
generated
|
|
@ -1,139 +0,0 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using pgLabII.Infra;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace pgLabII.Migrations
|
||||
{
|
||||
[DbContext(typeof(LocalDb))]
|
||||
[Migration("20251025162617_First")]
|
||||
partial class First
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.Document", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseCopyFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DocumentId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("InsertedText")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Offset")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RemovedText")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentId", "Timestamp");
|
||||
|
||||
b.ToTable("EditHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.ServerConfigurationEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ColorArgb")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ColorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InitialDatabase")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<ushort>("Port")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SslMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerConfigurations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
|
||||
{
|
||||
b.HasOne("pgLabII.Model.Document", "Document")
|
||||
.WithMany("EditHistory")
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Document");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.Document", b =>
|
||||
{
|
||||
b.Navigation("EditHistory");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace pgLabII.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class First : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Documents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
OriginalFilename = table.Column<string>(type: "TEXT", nullable: false),
|
||||
BaseCopyFilename = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Documents", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServerConfigurations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Host = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Port = table.Column<ushort>(type: "INTEGER", nullable: false),
|
||||
InitialDatabase = table.Column<string>(type: "TEXT", nullable: false),
|
||||
SslMode = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ColorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
ColorArgb = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
UserName = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Password = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServerConfigurations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EditHistory",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
DocumentId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Offset = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
InsertedText = table.Column<string>(type: "TEXT", nullable: false),
|
||||
RemovedText = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EditHistory", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EditHistory_Documents_DocumentId",
|
||||
column: x => x.DocumentId,
|
||||
principalTable: "Documents",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EditHistory_DocumentId_Timestamp",
|
||||
table: "EditHistory",
|
||||
columns: new[] { "DocumentId", "Timestamp" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EditHistory");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServerConfigurations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Documents");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using pgLabII.Infra;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace pgLabII.Migrations
|
||||
{
|
||||
[DbContext(typeof(LocalDb))]
|
||||
partial class LocalDbModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.Document", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseCopyFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DocumentId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("InsertedText")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Offset")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RemovedText")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentId", "Timestamp");
|
||||
|
||||
b.ToTable("EditHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.ServerConfigurationEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ColorArgb")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ColorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InitialDatabase")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<ushort>("Port")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SslMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerConfigurations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.EditHistoryEntry", b =>
|
||||
{
|
||||
b.HasOne("pgLabII.Model.Document", "Document")
|
||||
.WithMany("EditHistory")
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Document");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("pgLabII.Model.Document", b =>
|
||||
{
|
||||
b.Navigation("EditHistory");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
105
pgLabII/Model/ServerConfiguration.cs
Normal file
105
pgLabII/Model/ServerConfiguration.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Reactive;
|
||||
using Avalonia.Media;
|
||||
using Npgsql;
|
||||
using pgLabII.ViewModels;
|
||||
using pgLabII.Views;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace pgLabII.Model;
|
||||
|
||||
public class ServerConfiguration : ReactiveObject
|
||||
{
|
||||
private Color _color;
|
||||
private bool _colorEnabled = true;
|
||||
private string initialDatabase = "";
|
||||
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
/// <summary>
|
||||
/// For the user to help him identify the item
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public Color Color
|
||||
{
|
||||
get => _color;
|
||||
set
|
||||
{
|
||||
if (_color != value)
|
||||
{
|
||||
_color = value;
|
||||
this.RaisePropertyChanged();
|
||||
this.RaisePropertyChanged(propertyName: nameof(BackgroundBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool ColorEnabled
|
||||
{
|
||||
get => _colorEnabled;
|
||||
set
|
||||
{
|
||||
if (_colorEnabled != value)
|
||||
{
|
||||
_colorEnabled = value;
|
||||
this.RaisePropertyChanged();
|
||||
this.RaisePropertyChanged(propertyName: nameof(BackgroundBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Host { get; set; } = "";
|
||||
public ushort Port { get; set; } = 5432;
|
||||
public string InitialDatabase
|
||||
{
|
||||
get => initialDatabase;
|
||||
set
|
||||
{
|
||||
if (initialDatabase != value)
|
||||
{
|
||||
initialDatabase = value;
|
||||
this.RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public SslMode DefaultSslMode { get; set; } = SslMode.Prefer;
|
||||
public IBrush? BackgroundBrush => ColorEnabled ? new SolidColorBrush(Color) : null;
|
||||
|
||||
public ServerUser User { get; set; } = new();
|
||||
|
||||
public ReactiveCommand<Unit, Unit> EditCommand { get; }
|
||||
|
||||
public ReactiveCommand<Unit, Unit> ExploreCommand { get; }
|
||||
|
||||
public ServerConfiguration()
|
||||
{
|
||||
EditCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
EditServerConfigurationWindow window = new(
|
||||
new ViewModels.EditServerConfigurationViewModel(this))
|
||||
{ New = false };
|
||||
window.Show();
|
||||
});
|
||||
ExploreCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
SingleDatabaseWindow window = new() { DataContext = new ViewListViewModel() };
|
||||
window.Show();
|
||||
});
|
||||
}
|
||||
|
||||
public ServerConfiguration(ServerConfiguration src)
|
||||
: this()
|
||||
{
|
||||
Color = src.Color;
|
||||
ColorEnabled = src.ColorEnabled;
|
||||
Id = src.Id;
|
||||
Name = src.Name;
|
||||
Port = src.Port;
|
||||
InitialDatabase = src.InitialDatabase;
|
||||
DefaultSslMode = src.DefaultSslMode;
|
||||
User = src.User;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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; } = "";
|
||||
}
|
||||
11
pgLabII/Model/ServerUser.cs
Normal file
11
pgLabII/Model/ServerUser.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using System;
|
||||
|
||||
namespace pgLabII.Model;
|
||||
|
||||
public class ServerUser : ViewModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ServerConfigurationId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -21,7 +21,7 @@ public static class ServerConfigurationMapping
|
|||
/// - ApplicationName and TimeoutSeconds don't exist on ServerConfiguration; we preserve any passed-in
|
||||
/// values via optional parameters or Properties if provided by caller.
|
||||
/// </summary>
|
||||
public static ConnectionDescriptor ToDescriptor(ServerConfigurationEntity cfg,
|
||||
public static ConnectionDescriptor ToDescriptor(ServerConfiguration cfg,
|
||||
string? applicationName = null,
|
||||
int? timeoutSeconds = null,
|
||||
IReadOnlyDictionary<string, string>? extraProperties = null)
|
||||
|
|
@ -43,11 +43,12 @@ public static class ServerConfigurationMapping
|
|||
|
||||
return new ConnectionDescriptor
|
||||
{
|
||||
Name = cfg.Name,
|
||||
Hosts = hosts,
|
||||
Database = string.IsNullOrWhiteSpace(cfg.InitialDatabase) ? null : cfg.InitialDatabase,
|
||||
Username = string.IsNullOrWhiteSpace(cfg.UserName) ? null : cfg.UserName,
|
||||
Password = string.IsNullOrEmpty(cfg.Password) ? null : cfg.Password,
|
||||
SslMode = cfg.SslMode,
|
||||
Username = string.IsNullOrWhiteSpace(cfg.User?.Name) ? null : cfg.User!.Name,
|
||||
Password = string.IsNullOrEmpty(cfg.User?.Password) ? null : cfg.User!.Password,
|
||||
SslMode = cfg.DefaultSslMode,
|
||||
ApplicationName = applicationName,
|
||||
TimeoutSeconds = timeoutSeconds,
|
||||
Properties = props
|
||||
|
|
@ -60,13 +61,17 @@ public static class ServerConfigurationMapping
|
|||
/// - If descriptor has multiple hosts, the first is mapped to Host/Port.
|
||||
/// - If descriptor omits sslmode/database/username/password, existing values are preserved (if any).
|
||||
/// </summary>
|
||||
public static ServerConfigurationEntity FromDescriptor(ConnectionDescriptor descriptor, ServerConfigurationEntity? existing = null)
|
||||
public static ServerConfiguration FromDescriptor(ConnectionDescriptor descriptor, ServerConfiguration? existing = null)
|
||||
{
|
||||
if (descriptor == null) throw new ArgumentNullException(nameof(descriptor));
|
||||
var cfg = existing ?? new ServerConfigurationEntity();
|
||||
var cfg = existing ?? new ServerConfiguration();
|
||||
|
||||
// Name
|
||||
if (!string.IsNullOrWhiteSpace(descriptor.Name))
|
||||
cfg.Name = descriptor.Name!;
|
||||
|
||||
// Host/Port: take first
|
||||
if (descriptor.Hosts.Count > 0)
|
||||
if (descriptor.Hosts != null && descriptor.Hosts.Count > 0)
|
||||
{
|
||||
var h = descriptor.Hosts[0];
|
||||
if (!string.IsNullOrWhiteSpace(h.Host))
|
||||
|
|
@ -81,35 +86,17 @@ public static class ServerConfigurationMapping
|
|||
|
||||
// SSL Mode
|
||||
if (descriptor.SslMode.HasValue)
|
||||
cfg.SslMode = descriptor.SslMode.Value;
|
||||
cfg.DefaultSslMode = descriptor.SslMode.Value;
|
||||
|
||||
// User
|
||||
if (cfg.User == null)
|
||||
cfg.User = new ServerUser();
|
||||
if (!string.IsNullOrWhiteSpace(descriptor.Username))
|
||||
cfg.UserName = descriptor.Username!;
|
||||
cfg.User.Name = descriptor.Username!;
|
||||
if (!string.IsNullOrEmpty(descriptor.Password))
|
||||
cfg.Password = descriptor.Password!;
|
||||
cfg.User.Password = descriptor.Password!;
|
||||
|
||||
// Nothing to do for ApplicationName/TimeoutSeconds here; not represented in ServerConfiguration.
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Overloads for new UI ViewModel wrapper
|
||||
public static ConnectionDescriptor ToDescriptor(pgLabII.ViewModels.ServerConfigurationViewModel cfgVm,
|
||||
string? applicationName = null,
|
||||
int? timeoutSeconds = null,
|
||||
IReadOnlyDictionary<string, string>? extraProperties = null)
|
||||
=> ToDescriptor(cfgVm.Entity, applicationName, timeoutSeconds, extraProperties);
|
||||
|
||||
public static void FromDescriptorInto(pgLabII.ViewModels.ServerConfigurationViewModel targetVm, ConnectionDescriptor descriptor)
|
||||
{
|
||||
//var updated = targetVm.Entity;
|
||||
var n = FromDescriptor(descriptor, null);
|
||||
// push back updated values into VM's entity to trigger the notifies
|
||||
targetVm.Host = n.Host;
|
||||
targetVm.Port = n.Port;
|
||||
targetVm.InitialDatabase = n.InitialDatabase;
|
||||
targetVm.DefaultSslMode = n.SslMode;
|
||||
targetVm.UserName = n.UserName;
|
||||
targetVm.Password = n.Password;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,176 +1,33 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Reactive;
|
||||
using System.Reactive.Linq;
|
||||
using Npgsql;
|
||||
using System.Reactive;
|
||||
using pgLabII.Model;
|
||||
using pgLabII.PgUtils.ConnectionStrings;
|
||||
using pgLabII.Services;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace pgLabII.ViewModels;
|
||||
|
||||
public class EditServerConfigurationViewModel : ViewModelBase
|
||||
{
|
||||
// Prefer new UI VM; keep old model for compatibility by wrapping when needed
|
||||
public ServerConfigurationViewModel Configuration { get; set; }
|
||||
|
||||
// Connection string IO
|
||||
private string _inputConnectionString = string.Empty;
|
||||
public string InputConnectionString
|
||||
{
|
||||
get => _inputConnectionString;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _inputConnectionString, value);
|
||||
// auto-detect when input changes and we are in Auto mode
|
||||
if (ForcedFormat == ForcedFormatOption.Auto)
|
||||
{
|
||||
DetectFormat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ForcedFormatOption
|
||||
{
|
||||
Auto,
|
||||
Libpq,
|
||||
Npgsql,
|
||||
Url,
|
||||
Jdbc
|
||||
}
|
||||
|
||||
private ForcedFormatOption _forcedFormat = ForcedFormatOption.Auto;
|
||||
public ForcedFormatOption ForcedFormat
|
||||
{
|
||||
get => _forcedFormat;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _forcedFormat, value);
|
||||
// When forcing off Auto, clear detected label; when switching to Auto, re-detect
|
||||
if (value == ForcedFormatOption.Auto)
|
||||
DetectFormat();
|
||||
else
|
||||
DetectedFormat = null;
|
||||
}
|
||||
}
|
||||
|
||||
private ConnStringFormat? _detectedFormat;
|
||||
public ConnStringFormat? DetectedFormat
|
||||
{
|
||||
get => _detectedFormat;
|
||||
private set => this.RaiseAndSetIfChanged(ref _detectedFormat, value);
|
||||
}
|
||||
|
||||
private ConnStringFormat _outputFormat = ConnStringFormat.Url;
|
||||
public ConnStringFormat OutputFormat
|
||||
{
|
||||
get => _outputFormat;
|
||||
set => this.RaiseAndSetIfChanged(ref _outputFormat, value);
|
||||
}
|
||||
|
||||
private string _outputConnectionString = string.Empty;
|
||||
public string OutputConnectionString
|
||||
{
|
||||
get => _outputConnectionString;
|
||||
set => this.RaiseAndSetIfChanged(ref _outputConnectionString, value);
|
||||
}
|
||||
|
||||
public ReactiveCommand<Unit, Unit> ParseConnectionStringCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> GenerateConnectionStringCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> CopyOutputConnectionStringCommand { get; }
|
||||
public ServerConfiguration Configuration { get; set; }
|
||||
|
||||
public ReactiveCommand<Unit, Unit> SaveAndCloseCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> CloseCommand { get; }
|
||||
|
||||
private readonly IConnectionStringService _service;
|
||||
|
||||
public EditServerConfigurationViewModel()
|
||||
{
|
||||
Configuration = new(new ServerConfigurationEntity());
|
||||
_service = ConnectionStringService.CreateDefault();
|
||||
Configuration = new();
|
||||
|
||||
ParseConnectionStringCommand = ReactiveCommand.Create(ParseConnectionString);
|
||||
GenerateConnectionStringCommand = ReactiveCommand.Create(GenerateConnectionString);
|
||||
CopyOutputConnectionStringCommand = ReactiveCommand.Create(() => { /* no-op placeholder */ });
|
||||
|
||||
SaveAndCloseCommand = ReactiveCommand.Create(() => { });
|
||||
CloseCommand = ReactiveCommand.Create(() => { });
|
||||
SaveAndCloseCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
});
|
||||
CloseCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
});
|
||||
}
|
||||
|
||||
public EditServerConfigurationViewModel(ServerConfigurationViewModel configuration)
|
||||
public EditServerConfigurationViewModel(ServerConfiguration configuration)
|
||||
: this()
|
||||
{
|
||||
Configuration = configuration;
|
||||
}
|
||||
|
||||
private void DetectFormat()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(InputConnectionString))
|
||||
{
|
||||
DetectedFormat = null;
|
||||
return;
|
||||
}
|
||||
var res = _service.DetectFormat(InputConnectionString);
|
||||
DetectedFormat = res.IsSuccess ? res.Value : null;
|
||||
}
|
||||
|
||||
private void ParseConnectionString()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(InputConnectionString)) return;
|
||||
|
||||
var forced = ForcedFormat;
|
||||
ConnectionDescriptor? descriptor = null;
|
||||
if (forced != ForcedFormatOption.Auto)
|
||||
{
|
||||
IConnectionStringCodec codec = forced switch
|
||||
{
|
||||
ForcedFormatOption.Libpq => new LibpqCodec(),
|
||||
ForcedFormatOption.Npgsql => new NpgsqlCodec(),
|
||||
ForcedFormatOption.Url => new UrlCodec(),
|
||||
ForcedFormatOption.Jdbc => new JdbcCodec(),
|
||||
_ => new UrlCodec()
|
||||
};
|
||||
var r = codec.TryParse(InputConnectionString);
|
||||
if (r.IsSuccess)
|
||||
descriptor = r.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
var r = _service.ParseToDescriptor(InputConnectionString);
|
||||
if (r.IsSuccess)
|
||||
descriptor = r.Value;
|
||||
}
|
||||
|
||||
if (descriptor != null)
|
||||
{
|
||||
// Map into our configuration (update existing)
|
||||
ServerConfigurationMapping.FromDescriptorInto(Configuration, descriptor);
|
||||
// Also set a sensible default OutputFormat to the detected/forced one
|
||||
if (forced == ForcedFormatOption.Auto)
|
||||
{
|
||||
if (DetectedFormat.HasValue) OutputFormat = DetectedFormat.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
OutputFormat = forced switch
|
||||
{
|
||||
ForcedFormatOption.Libpq => ConnStringFormat.Libpq,
|
||||
ForcedFormatOption.Npgsql => ConnStringFormat.Npgsql,
|
||||
ForcedFormatOption.Url => ConnStringFormat.Url,
|
||||
ForcedFormatOption.Jdbc => ConnStringFormat.Jdbc,
|
||||
_ => ConnStringFormat.Url
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void GenerateConnectionString()
|
||||
{
|
||||
// Build descriptor from current configuration
|
||||
var descriptor = ServerConfigurationMapping.ToDescriptor(Configuration);
|
||||
var r = _service.FormatFromDescriptor(descriptor, OutputFormat);
|
||||
if (r.IsSuccess)
|
||||
OutputConnectionString = r.Value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,205 +1,22 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reactive;
|
||||
using System.Threading.Tasks;
|
||||
using Npgsql;
|
||||
using System.Reactive;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using pgLabII.Model;
|
||||
|
||||
namespace pgLabII.ViewModels;
|
||||
|
||||
public class ColumnInfo
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? DisplayName { get; set; }
|
||||
public Type DataType { get; set; } = typeof(string);
|
||||
public bool IsSortable { get; set; } = true;
|
||||
public bool IsFilterable { get; set; } = true;
|
||||
// Add more metadata as needed (format, cell template, etc.)
|
||||
}
|
||||
|
||||
public partial class QueryToolViewModel : ViewModelBase, IViewItem
|
||||
{
|
||||
private readonly ServerConfigurationEntity? _serverConfig;
|
||||
[Reactive] private string _caption = "Cap";
|
||||
|
||||
// Tab caption
|
||||
[Reactive] private string _caption = "Query";
|
||||
[Reactive] private string _query = "";
|
||||
|
||||
// SQL text bound to the editor
|
||||
[Reactive] private string _userSql = "SELECT n, 1, 'hello', '2025-4-5'::date FROM generate_series(1, 2000) AS d(n)";
|
||||
public ReactiveCommand<Unit, Unit> EditCommand { get; }
|
||||
|
||||
// Summary and status labels
|
||||
[Reactive] private string _resultSummary = "Showing 0 rows";
|
||||
[Reactive] private string _status = "Ready";
|
||||
|
||||
// Paging flags
|
||||
[Reactive] private bool _canLoadMore;
|
||||
[Reactive] private bool _autoLoadMore;
|
||||
|
||||
// Rows shown in the DataGrid. For now, simple object rows for AutoGenerateColumns.
|
||||
public ObservableCollection<object> Rows { get; } = new();
|
||||
|
||||
[Reactive] private IReadOnlyList<ColumnInfo> _columns = new List<ColumnInfo>();
|
||||
|
||||
// Commands
|
||||
public ReactiveCommand<Unit, Unit> RunQuery { get; }
|
||||
public ReactiveCommand<Unit, Unit> LoadMore { get; }
|
||||
public ReactiveCommand<Unit, Unit> ExportResults { get; }
|
||||
public ReactiveCommand<Unit, Unit> OpenSqlFile { get; }
|
||||
public ReactiveCommand<Unit, Unit> SaveSqlFile { get; }
|
||||
|
||||
public QueryToolViewModel(ServerConfigurationEntity? serverConfig)
|
||||
public QueryToolViewModel()
|
||||
{
|
||||
_serverConfig = serverConfig;
|
||||
|
||||
// Create command that executes actual SQL queries
|
||||
RunQuery = ReactiveCommand.CreateFromTask(async () =>
|
||||
EditCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
await ExecuteQuery();
|
||||
Query += " test";
|
||||
});
|
||||
|
||||
LoadMore = ReactiveCommand.Create(() =>
|
||||
{
|
||||
// Add more demo rows to see paging UX
|
||||
for (int i = 1; i <= 3; i++)
|
||||
{
|
||||
Rows.Add(new RowData(10));
|
||||
}
|
||||
this.RaisePropertyChanged(nameof(Rows)); // Force DataGrid refresh
|
||||
ResultSummary = $"Showing {Rows.Count} rows";
|
||||
Status = "Loaded more (stub)";
|
||||
// Stop after a few loads visually
|
||||
CanLoadMore = Rows.Count < 12;
|
||||
});
|
||||
|
||||
ExportResults = ReactiveCommand.Create(() =>
|
||||
{
|
||||
Status = "Export invoked (stub)";
|
||||
});
|
||||
|
||||
OpenSqlFile = ReactiveCommand.Create(() =>
|
||||
{
|
||||
Status = "Open SQL file (stub)";
|
||||
});
|
||||
|
||||
SaveSqlFile = ReactiveCommand.Create(() =>
|
||||
{
|
||||
Status = "Save SQL file (stub)";
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ExecuteQuery()
|
||||
{
|
||||
if (_serverConfig == null)
|
||||
{
|
||||
Status = "Error: No server configuration selected";
|
||||
ResultSummary = "Showing 0 rows";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(UserSql))
|
||||
{
|
||||
Status = "Error: SQL query is empty";
|
||||
ResultSummary = "Showing 0 rows";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Status = "Executing query...";
|
||||
Rows.Clear();
|
||||
|
||||
var connStringBuilder = new NpgsqlConnectionStringBuilder
|
||||
{
|
||||
Host = _serverConfig.Host,
|
||||
Port = _serverConfig.Port,
|
||||
Database = _serverConfig.InitialDatabase,
|
||||
Username = _serverConfig.UserName,
|
||||
Password = _serverConfig.Password,
|
||||
SslMode = _serverConfig.SslMode,
|
||||
};
|
||||
|
||||
using var connection = new NpgsqlConnection(connStringBuilder.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
using var command = new NpgsqlCommand(UserSql, connection);
|
||||
using var reader = await command.ExecuteReaderAsync();
|
||||
|
||||
// Get column information - build in a temporary list to avoid multiple CollectionChanged events
|
||||
var schema = reader.GetColumnSchema();
|
||||
var columnList = new List<ColumnInfo>();
|
||||
foreach (var column in schema)
|
||||
{
|
||||
columnList.Add(new ColumnInfo
|
||||
{
|
||||
Name = column.ColumnName ?? "Unknown",
|
||||
DisplayName = column.ColumnName,
|
||||
DataType = column.DataType ?? typeof(string),
|
||||
IsSortable = true,
|
||||
IsFilterable = true
|
||||
});
|
||||
}
|
||||
|
||||
// Read rows - also build in a temporary list first
|
||||
var rowList = new List<object>();
|
||||
int rowCount = 0;
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var values = new object[reader.FieldCount];
|
||||
reader.GetValues(values);
|
||||
|
||||
// Convert to a dynamic object for the DataGrid
|
||||
var row = new RowData(reader.FieldCount);
|
||||
for (int i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
row.Values[i] = values[i];
|
||||
}
|
||||
|
||||
rowList.Add(row);
|
||||
rowCount++;
|
||||
}
|
||||
|
||||
// Swap the entire Columns list at once (single property change notification)
|
||||
Columns = columnList;
|
||||
|
||||
// Add all rows at once
|
||||
Rows.Clear();
|
||||
foreach (var row in rowList)
|
||||
{
|
||||
Rows.Add(row);
|
||||
}
|
||||
|
||||
ResultSummary = $"Showing {rowCount} rows";
|
||||
Status = "Query executed successfully";
|
||||
CanLoadMore = false; // TODO: Implement pagination if needed
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Status = $"Error: {ex.Message}";
|
||||
ResultSummary = "Showing 0 rows";
|
||||
Rows.Clear();
|
||||
Columns = new List<ColumnInfo>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic row container for displaying results in the DataGrid
|
||||
/// </summary>
|
||||
public class RowData
|
||||
{
|
||||
public RowData(int columnCount)
|
||||
{
|
||||
Values = new object[columnCount];
|
||||
}
|
||||
//public Dictionary<string, object?> Values { get; } = new();
|
||||
public object[] Values { get; }
|
||||
|
||||
public object? this[int idx]
|
||||
{
|
||||
get => Values[idx]; //.TryGetValue(key, out var value) ? value : null;
|
||||
set => Values[idx] = value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
using System;
|
||||
using System.Reactive;
|
||||
using Avalonia.Media;
|
||||
using Npgsql;
|
||||
using pgLabII.Model;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace pgLabII.ViewModels;
|
||||
|
||||
// UI ViewModel that wraps the persistence entity
|
||||
public class ServerConfigurationViewModel : ReactiveObject
|
||||
{
|
||||
private readonly ServerConfigurationEntity _entity;
|
||||
|
||||
public ServerConfigurationViewModel(ServerConfigurationEntity entity)
|
||||
{
|
||||
_entity = entity ?? throw new ArgumentNullException(nameof(entity));
|
||||
EditCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
var window = new Views.EditServerConfigurationWindow(new(this)) { New = false };
|
||||
window.Show();
|
||||
});
|
||||
ExploreCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
var window = new Views.SingleDatabaseWindow(entity);
|
||||
window.Show();
|
||||
});
|
||||
}
|
||||
|
||||
public ServerConfigurationEntity Entity => _entity;
|
||||
|
||||
public Guid Id
|
||||
{
|
||||
get => _entity.Id;
|
||||
set { if (_entity.Id != value) { _entity.Id = value; this.RaisePropertyChanged(); } }
|
||||
}
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => _entity.Name;
|
||||
set { if (_entity.Name != value) { _entity.Name = value; this.RaisePropertyChanged(); } }
|
||||
}
|
||||
|
||||
public string Host
|
||||
{
|
||||
get => _entity.Host;
|
||||
set { if (_entity.Host != value) { _entity.Host = value; this.RaisePropertyChanged(); } }
|
||||
}
|
||||
|
||||
public ushort Port
|
||||
{
|
||||
get => _entity.Port;
|
||||
set { if (_entity.Port != value) { _entity.Port = value; this.RaisePropertyChanged(); } }
|
||||
}
|
||||
|
||||
public string InitialDatabase
|
||||
{
|
||||
get => _entity.InitialDatabase;
|
||||
set { if (_entity.InitialDatabase != value) { _entity.InitialDatabase = value; this.RaisePropertyChanged(); } }
|
||||
}
|
||||
|
||||
public SslMode DefaultSslMode
|
||||
{
|
||||
get => _entity.SslMode;
|
||||
set { if (_entity.SslMode != value) { _entity.SslMode = value; this.RaisePropertyChanged(); } }
|
||||
}
|
||||
|
||||
public bool ColorEnabled
|
||||
{
|
||||
get => _entity.ColorEnabled;
|
||||
set { if (_entity.ColorEnabled != value) { _entity.ColorEnabled = value; this.RaisePropertyChanged(); this.RaisePropertyChanged(nameof(BackgroundBrush)); } }
|
||||
}
|
||||
|
||||
public Color Color
|
||||
{
|
||||
get => Color.FromUInt32((uint)_entity.ColorArgb);
|
||||
set
|
||||
{
|
||||
var argb = unchecked((int)value.ToUInt32());
|
||||
if (_entity.ColorArgb != argb)
|
||||
{
|
||||
_entity.ColorArgb = argb;
|
||||
this.RaisePropertyChanged();
|
||||
this.RaisePropertyChanged(nameof(BackgroundBrush));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IBrush? BackgroundBrush => ColorEnabled ? new SolidColorBrush(Color) : null;
|
||||
|
||||
public string UserName
|
||||
{
|
||||
get => _entity.UserName;
|
||||
set
|
||||
{
|
||||
if (_entity.UserName != value)
|
||||
{
|
||||
_entity.UserName = value;
|
||||
this.RaisePropertyChanged();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public string Password
|
||||
{
|
||||
get => _entity.Password;
|
||||
set
|
||||
{
|
||||
if (_entity.Password != value)
|
||||
{
|
||||
_entity.Password = value;
|
||||
this.RaisePropertyChanged();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public ReactiveCommand<Unit, Unit> EditCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> ExploreCommand { get; }
|
||||
}
|
||||
|
|
@ -9,36 +9,36 @@ namespace pgLabII.ViewModels;
|
|||
|
||||
public class ServerListViewModel : ViewModelBase
|
||||
{
|
||||
public ObservableCollection<ServerConfigurationViewModel> ServerConfigurations { get; } =
|
||||
public ObservableCollection<ServerConfiguration> ServerConfigurations { get; } =
|
||||
[
|
||||
new (new()
|
||||
new ServerConfiguration()
|
||||
{
|
||||
Name = "pg18",
|
||||
Name = "Local pg15",
|
||||
Color = Colors.Aquamarine,
|
||||
ColorEnabled = true,
|
||||
Host = "localhost",
|
||||
Port = 5418,
|
||||
InitialDatabase = "postgres",
|
||||
UserName = "postgres",
|
||||
Password = "admin",
|
||||
})
|
||||
{
|
||||
Color = Colors.Aquamarine,
|
||||
Port = 5434,
|
||||
User = new ()
|
||||
{
|
||||
Name = "postgres",
|
||||
Password = "admin",
|
||||
},
|
||||
},
|
||||
new (new ()
|
||||
new ServerConfiguration()
|
||||
{
|
||||
Name = "Bar",
|
||||
ColorEnabled = false,
|
||||
Host = "db.host.nl"
|
||||
}),
|
||||
}
|
||||
];
|
||||
|
||||
public ReactiveCommand<ServerConfigurationViewModel, Unit> RemoveServerCommand { get; }
|
||||
public ReactiveCommand<ServerConfiguration, Unit> RemoveServerCommand { get; }
|
||||
|
||||
public ReactiveCommand<Unit, Unit> AddServerCommand { get; }
|
||||
|
||||
public ServerListViewModel()
|
||||
{
|
||||
RemoveServerCommand = ReactiveCommand.Create<ServerConfigurationViewModel, Unit>((sc) =>
|
||||
RemoveServerCommand = ReactiveCommand.Create<ServerConfiguration, Unit>((sc) =>
|
||||
{
|
||||
ServerConfigurations.Remove(sc);
|
||||
return Unit.Default;
|
||||
|
|
@ -46,8 +46,8 @@ public class ServerListViewModel : ViewModelBase
|
|||
|
||||
AddServerCommand = ReactiveCommand.Create(() =>
|
||||
{
|
||||
EditServerConfigurationViewModel vm = new();
|
||||
EditServerConfigurationWindow window = new() { DataContext = vm, New = true };
|
||||
ServerConfiguration sc = new();
|
||||
EditServerConfigurationWindow window = new() { DataContext = sc, New = true };
|
||||
window.Show();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using pgLabII.Model;
|
||||
|
||||
namespace pgLabII.ViewModels;
|
||||
|
||||
|
|
@ -8,17 +7,9 @@ namespace pgLabII.ViewModels;
|
|||
/// </summary>
|
||||
public class ViewListViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ServerConfigurationEntity serverConfig;
|
||||
|
||||
public ViewListViewModel(ServerConfigurationEntity serverConfig)
|
||||
{
|
||||
this.serverConfig = serverConfig;
|
||||
|
||||
Views = [
|
||||
new QueryToolViewModel(serverConfig) { Caption = "Abc" },
|
||||
new QueryToolViewModel(serverConfig) { Caption = "Test" },
|
||||
];
|
||||
}
|
||||
public ObservableCollection<IViewItem> Views { get; private set; }
|
||||
public ObservableCollection<IViewItem> Views { get; } = [
|
||||
new QueryToolViewModel() { Caption = "Abc" },
|
||||
new QueryToolViewModel() { Caption = "Test" } ,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
using System.IO;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using AvaloniaEdit.Document;
|
||||
using AvaloniaEdit.TextMate;
|
||||
using TextMateSharp.Grammars;
|
||||
using System;
|
||||
using System.Reactive.Linq;
|
||||
|
||||
namespace pgLabII.Views.Controls;
|
||||
|
||||
|
|
@ -20,30 +17,12 @@ public partial class CodeEditorView : UserControl
|
|||
OriginalFilename = "",
|
||||
BaseCopyFilename = "",
|
||||
});
|
||||
|
||||
public static readonly AvaloniaProperty<string> TextProperty = AvaloniaProperty.Register<CodeEditorView, string>(
|
||||
nameof(Text), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
|
||||
|
||||
public string Text
|
||||
{
|
||||
get => Editor?.Text ?? string.Empty;
|
||||
set
|
||||
{
|
||||
if (Editor != null && Editor.Text != value)
|
||||
Editor.Text = value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public CodeEditorView()
|
||||
{
|
||||
InitializeComponent();
|
||||
this.GetObservable(TextProperty).Subscribe(text =>
|
||||
{
|
||||
if (Editor != null && Editor.Text != text)
|
||||
Editor.Text = text ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnAttachedToVisualTree(e);
|
||||
|
|
@ -51,15 +30,10 @@ public partial class CodeEditorView : UserControl
|
|||
var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
|
||||
_textMate = Editor.InstallTextMate(registryOptions);
|
||||
_textMate.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions.GetLanguageByExtension(".sql").Id));
|
||||
|
||||
|
||||
Editor.Document.Changed += DocumentChanged;
|
||||
Editor.TextChanged += Editor_TextChanged;
|
||||
}
|
||||
|
||||
private void Editor_TextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
SetValue(TextProperty, Editor.Text);
|
||||
}
|
||||
|
||||
private void OnSaveClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
|
|
@ -67,6 +41,7 @@ public partial class CodeEditorView : UserControl
|
|||
File.WriteAllText("final.sql", Editor.Text);
|
||||
}
|
||||
|
||||
|
||||
private void DocumentChanged(object? sender, DocumentChangeEventArgs e)
|
||||
{
|
||||
_editHistoryManager.AddEdit(e.Offset, e.InsertedText.Text, e.RemovedText.Text);
|
||||
|
|
|
|||
|
|
@ -2,97 +2,37 @@
|
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="650"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
xmlns:vm="clr-namespace:pgLabII.ViewModels"
|
||||
x:DataType="vm:EditServerConfigurationViewModel"
|
||||
x:Class="pgLabII.Views.EditServerConfigurationWindow"
|
||||
Title="Edit Server Configuration"
|
||||
Title="EditServerConfiguration"
|
||||
SizeToContent="WidthAndHeight">
|
||||
<Design.DataContext>
|
||||
<!-- This only sets the DataContext for the previewer in an IDE,
|
||||
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
|
||||
<vm:EditServerConfigurationViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*">
|
||||
<!-- Basic Details -->
|
||||
<StackPanel Grid.Row="0" Spacing="6">
|
||||
<TextBlock FontWeight="Bold" Text="Details" Margin="0,0,0,4"/>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Name"/>
|
||||
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Configuration.Name}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Color"/>
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<CheckBox IsChecked="{Binding Configuration.ColorEnabled}"/>
|
||||
<ColorPicker Color="{Binding Configuration.Color}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Database"/>
|
||||
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Configuration.InitialDatabase}"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Username"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Configuration.UserName}"/>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Host"/>
|
||||
<TextBox Grid.Row="0" Grid.Column="3" Text="{Binding Configuration.Host}"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Port"/>
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="3" Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="SSL Mode"/>
|
||||
<ComboBox Grid.Row="2" Grid.Column="3" SelectedIndex="{Binding Configuration.DefaultSslMode}">
|
||||
<!-- Order must match Npgsql.SslMode enum: Disable(0), Allow(1), Prefer(2), Require(3), VerifyCA(4), VerifyFull(5) -->
|
||||
<ComboBoxItem>Disable</ComboBoxItem>
|
||||
<ComboBoxItem>Allow</ComboBoxItem>
|
||||
<ComboBoxItem>Prefer</ComboBoxItem>
|
||||
<ComboBoxItem>Require</ComboBoxItem>
|
||||
<ComboBoxItem>VerifyCA</ComboBoxItem>
|
||||
<ComboBoxItem>VerifyFull</ComboBoxItem>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Password"/>
|
||||
<TextBox Grid.Row="3" Grid.Column="3" PasswordChar="*" Text="{Binding Configuration.Password}"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel >
|
||||
<TextBlock>Name:</TextBlock>
|
||||
<TextBox Text="{Binding Configuration.Name}"/>
|
||||
|
||||
<TextBlock>Color:</TextBlock>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<CheckBox IsChecked="{Binding Configuration.ColorEnabled}"/>
|
||||
<ColorPicker Color="{Binding Configuration.Color}"
|
||||
/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock>Host:</TextBlock>
|
||||
<TextBox Text="{Binding Configuration.Host}"/>
|
||||
|
||||
<!-- Input Connection String -->
|
||||
<StackPanel Grid.Row="1" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock FontWeight="Bold" Text="Connection string input"/>
|
||||
<TextBlock Text="Paste an existing connection string (libpq, Npgsql, URL, or JDBC)."/>
|
||||
<TextBox TextWrapping="Wrap" AcceptsReturn="True" MinHeight="60" Text="{Binding InputConnectionString}"/>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Interpret as"/>
|
||||
<!-- Bind SelectedIndex to enum (Auto=0, Libpq=1, Npgsql=2, Url=3, Jdbc=4) -->
|
||||
<ComboBox Grid.Column="1" SelectedIndex="{Binding ForcedFormat}">
|
||||
<ComboBoxItem>Auto</ComboBoxItem>
|
||||
<ComboBoxItem>Libpq</ComboBoxItem>
|
||||
<ComboBoxItem>Npgsql</ComboBoxItem>
|
||||
<ComboBoxItem>URL</ComboBoxItem>
|
||||
<ComboBoxItem>JDBC</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="2" Margin="12,0,8,0" VerticalAlignment="Center" Text="Detected:"/>
|
||||
<TextBlock Grid.Column="3" VerticalAlignment="Center" Text="{Binding DetectedFormat}"/>
|
||||
<Button Grid.Column="4" Margin="12,0,0,0" Content="Parse into fields" Command="{Binding ParseConnectionStringCommand}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<TextBlock>Port:</TextBlock>
|
||||
<NumericUpDown Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
|
||||
|
||||
<!-- Output Connection String -->
|
||||
<StackPanel Grid.Row="2" Margin="0,12,0,0" Spacing="6">
|
||||
<TextBlock FontWeight="Bold" Text="Connection string output"/>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Format"/>
|
||||
<!-- ConnStringFormat: Libpq=0, Npgsql=1, Url=2, Jdbc=3 -->
|
||||
<ComboBox Grid.Column="1" SelectedIndex="{Binding OutputFormat}">
|
||||
<ComboBoxItem>Libpq</ComboBoxItem>
|
||||
<ComboBoxItem>Npgsql</ComboBoxItem>
|
||||
<ComboBoxItem>URL</ComboBoxItem>
|
||||
<ComboBoxItem>JDBC</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<Button Grid.Column="2" Margin="12,0,0,0" Content="Generate" Command="{Binding GenerateConnectionStringCommand}"/>
|
||||
<Button Grid.Column="3" Margin="6,0,0,0" Content="Copy" Command="{Binding CopyOutputConnectionStringCommand}"/>
|
||||
</Grid>
|
||||
<TextBox TextWrapping="Wrap" AcceptsReturn="True" MinHeight="60" IsReadOnly="True" Text="{Binding OutputConnectionString}"/>
|
||||
</StackPanel>
|
||||
<TextBlock>Database:</TextBlock>
|
||||
<TextBox Text="{Binding Configuration.InitialDatabase}"/>
|
||||
|
||||
<!-- Spacer / Future buttons row could go here -->
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public partial class EditServerConfigurationWindow : Window
|
|||
InitializeComponent();
|
||||
|
||||
DataContext = viewModel ?? new EditServerConfigurationViewModel(
|
||||
new(new()));
|
||||
new());
|
||||
}
|
||||
|
||||
public bool New { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:pgLabII.Views.Controls"
|
||||
xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
|
||||
x:Class="pgLabII.Views.QueryToolView"
|
||||
x:DataType="viewModels:QueryToolViewModel">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*">
|
||||
<!-- Step 2: Open/Save toolbar -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4">
|
||||
<Button Content="Open" Command="{Binding OpenSqlFile}" ToolTip.Tip="Open .sql file (Ctrl+O)" />
|
||||
<Button Content="Save" Command="{Binding SaveSqlFile}" ToolTip.Tip="Save .sql file (Ctrl+S)" Margin="4,0,0,0"/>
|
||||
</StackPanel>
|
||||
<!-- Step 2: SQL Editor -->
|
||||
<controls:CodeEditorView Grid.Row="1"
|
||||
Text="{Binding UserSql, Mode=TwoWay}" />
|
||||
<!-- Step 3: Results toolbar -->
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="4">
|
||||
<Button Content="Run" Command="{Binding RunQuery}" />
|
||||
<Button Content="Load more" Command="{Binding LoadMore}" IsEnabled="{Binding CanLoadMore}" Margin="4,0,0,0"/>
|
||||
<Button Content="Export..." Command="{Binding ExportResults}" Margin="4,0,0,0"/>
|
||||
<CheckBox Content="Auto-load on scroll" IsChecked="{Binding AutoLoadMore}" Margin="8,0,0,0"/>
|
||||
<TextBlock Text="{Binding ResultSummary}" Margin="16,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Status}" Margin="8,0,0,0" VerticalAlignment="Center" Foreground="Gray"/>
|
||||
</StackPanel>
|
||||
<!-- Step 4: Results grid -->
|
||||
<DataGrid Grid.Row="3"
|
||||
x:Name="ResultsDataGrid"
|
||||
ItemsSource="{Binding Rows}"
|
||||
IsReadOnly="True"
|
||||
SelectionMode="Extended"
|
||||
CanUserSortColumns="True"
|
||||
AutoGenerateColumns="False"
|
||||
Margin="4">
|
||||
<DataGrid.Columns />
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using pgLabII.ViewModels;
|
||||
|
||||
namespace pgLabII.Views;
|
||||
|
||||
public partial class QueryToolView : UserControl
|
||||
{
|
||||
private QueryToolViewModel? _currentVm;
|
||||
|
||||
public QueryToolView()
|
||||
{
|
||||
InitializeComponent();
|
||||
this.DataContextChanged += (_, _) => WireColumns();
|
||||
WireColumns();
|
||||
}
|
||||
|
||||
private void WireColumns()
|
||||
{
|
||||
// Unsubscribe from previous ViewModel if it exists
|
||||
if (_currentVm != null)
|
||||
{
|
||||
_currentVm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
}
|
||||
|
||||
if (DataContext is QueryToolViewModel vm)
|
||||
{
|
||||
_currentVm = vm;
|
||||
vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||
RegenerateColumns(this.ResultsDataGrid, vm);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(QueryToolViewModel.Columns) && DataContext is QueryToolViewModel vm)
|
||||
{
|
||||
var grid = this.ResultsDataGrid;
|
||||
RegenerateColumns(grid, vm);
|
||||
}
|
||||
}
|
||||
|
||||
private void RegenerateColumns(DataGrid grid, QueryToolViewModel vm)
|
||||
{
|
||||
grid.Columns.Clear();
|
||||
//foreach (var col in vm.Columns)
|
||||
for (int i = 0; i < vm.Columns.Count; i++)
|
||||
{
|
||||
var col = vm.Columns[i];
|
||||
DataGridColumn gridCol;
|
||||
if (col.DataType == typeof(bool))
|
||||
{
|
||||
gridCol = new DataGridCheckBoxColumn
|
||||
{
|
||||
Header = col.DisplayName ?? col.Name,
|
||||
Binding = new Avalonia.Data.Binding($"Values[{i}]")
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
gridCol = new DataGridTextColumn
|
||||
{
|
||||
Header = col.DisplayName ?? col.Name,
|
||||
Binding = new Avalonia.Data.Binding($"Values[{i}]")
|
||||
};
|
||||
}
|
||||
grid.Columns.Add(gridCol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
|
||||
xmlns:views="clr-namespace:pgLabII.Views"
|
||||
xmlns:controls="clr-namespace:pgLabII.Views.Controls"
|
||||
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
|
|
@ -48,7 +47,12 @@
|
|||
|
||||
<TabControl.ContentTemplate>
|
||||
<DataTemplate DataType="viewModels:QueryToolViewModel">
|
||||
<views:QueryToolView />
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBox Text="{Binding Query, Mode=TwoWay}" DockPanel.Dock="Top"/>
|
||||
<Button Command="{Binding EditCommand}" DockPanel.Dock="Top">TEST</Button>
|
||||
|
||||
<controls:CodeEditorView />
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</TabControl.ContentTemplate>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue