From ef7659deebdc6dca4798edac66f5768528495489 Mon Sep 17 00:00:00 2001 From: J-Savier Date: Mon, 14 Sep 2026 16:46:47 +0800 Subject: [PATCH 1/4] Add Supabase database provider integration --- DatabaseExplorer/App.xaml.cs | 2 + .../Core/Models/DatabaseProviderType.cs | 3 +- .../Supabase/SupabaseConnectionInfo.cs | 105 +++++++++ .../Supabase/SupabaseDatabaseConnection.cs | 148 +++++++++++++ .../Supabase/SupabaseDatabaseProvider.cs | 20 ++ .../Supabase/SupabaseQueryService.cs | 204 ++++++++++++++++++ DatabaseExplorer/DatabaseExplorer.csproj | 7 +- DatabaseExplorer/ViewModels/MainViewModel.cs | 30 ++- DatabaseExplorer/Views/MainWindow.xaml | 25 ++- 9 files changed, 538 insertions(+), 6 deletions(-) create mode 100644 DatabaseExplorer/DataProviders/Supabase/SupabaseConnectionInfo.cs create mode 100644 DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseConnection.cs create mode 100644 DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseProvider.cs create mode 100644 DatabaseExplorer/DataProviders/Supabase/SupabaseQueryService.cs diff --git a/DatabaseExplorer/App.xaml.cs b/DatabaseExplorer/App.xaml.cs index c20445a..7d2e522 100644 --- a/DatabaseExplorer/App.xaml.cs +++ b/DatabaseExplorer/App.xaml.cs @@ -6,6 +6,7 @@ using DatabaseExplorer.DataProviders.PostgreSql; using DatabaseExplorer.DataProviders.Sqlite; using DatabaseExplorer.DataProviders.SqlServer; +using DatabaseExplorer.DataProviders.Supabase; using DatabaseExplorer.Services; using DatabaseExplorer.ViewModels; using DatabaseExplorer.Views; @@ -45,6 +46,7 @@ private static void ConfigureServices(HostBuilderContext context, IServiceCollec services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/DatabaseExplorer/Core/Models/DatabaseProviderType.cs b/DatabaseExplorer/Core/Models/DatabaseProviderType.cs index 31191cf..17daecc 100644 --- a/DatabaseExplorer/Core/Models/DatabaseProviderType.cs +++ b/DatabaseExplorer/Core/Models/DatabaseProviderType.cs @@ -4,5 +4,6 @@ public enum DatabaseProviderType { SqlServer, PostgreSql, - Sqlite + Sqlite, + Supabase } diff --git a/DatabaseExplorer/DataProviders/Supabase/SupabaseConnectionInfo.cs b/DatabaseExplorer/DataProviders/Supabase/SupabaseConnectionInfo.cs new file mode 100644 index 0000000..c1ddd96 --- /dev/null +++ b/DatabaseExplorer/DataProviders/Supabase/SupabaseConnectionInfo.cs @@ -0,0 +1,105 @@ +namespace DatabaseExplorer.DataProviders.Supabase; + +public sealed record SupabaseConnectionInfo(string Url, string ApiKey) +{ + public static bool TryParse(string connectionString, out SupabaseConnectionInfo? info, out string? error) + { + info = null; + error = null; + + if (string.IsNullOrWhiteSpace(connectionString)) + { + error = "Connection string cannot be empty."; + return false; + } + + string? url = null; + string? apiKey = null; + + foreach (var segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var idx = segment.IndexOf('='); + if (idx <= 0) + { + continue; + } + + var key = segment[..idx].Trim().ToLowerInvariant(); + var value = segment[(idx + 1)..].Trim(); + + switch (key) + { + case "url": + case "project": + case "projecturl": + url = value; + break; + case "apikey": + case "key": + case "anonkey": + apiKey = value; + break; + } + } + + if (string.IsNullOrWhiteSpace(url)) + { + error = "Missing 'Url' (e.g. Url=https://your-project.supabase.co;)."; + return false; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != "https" && uri.Scheme != "http")) + { + error = "The Supabase Url is not a valid http(s) address."; + return false; + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + error = "Missing 'ApiKey' (your Supabase publishable or secret key)."; + return false; + } + + info = new SupabaseConnectionInfo(url.TrimEnd('/'), apiKey); + return true; + } + + public static (string Url, string ApiKey) ParseLenient(string connectionString) + { + var url = string.Empty; + var apiKey = string.Empty; + + if (string.IsNullOrWhiteSpace(connectionString)) + { + return (url, apiKey); + } + + foreach (var segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var idx = segment.IndexOf('='); + if (idx <= 0) + { + continue; + } + + var key = segment[..idx].Trim().ToLowerInvariant(); + var value = segment[(idx + 1)..].Trim(); + + switch (key) + { + case "url": + case "project": + case "projecturl": + url = value; + break; + case "apikey": + case "key": + case "anonkey": + apiKey = value; + break; + } + } + + return (url, apiKey); + } +} \ No newline at end of file diff --git a/DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseConnection.cs b/DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseConnection.cs new file mode 100644 index 0000000..640d490 --- /dev/null +++ b/DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseConnection.cs @@ -0,0 +1,148 @@ +using DatabaseExplorer.Core.Exceptions; +using DatabaseExplorer.Core.Interfaces; +using DatabaseExplorer.Core.Models; +using System.Data; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace DatabaseExplorer.DataProviders.Supabase; + +public sealed class SupabaseDatabaseConnection : IDatabaseConnection +{ + private readonly SupabaseConnectionInfo _info; + private HttpClient? _httpClient; + private ConnectionState _state = ConnectionState.Closed; + private bool _disposed; + + internal JsonNode? OpenApiSpec { get; private set; } + + internal HttpClient HttpClient => + _httpClient ?? throw new InvalidOperationException("Not connected."); + + public SupabaseDatabaseConnection(string connectionString) + { + if (!SupabaseConnectionInfo.TryParse(connectionString, out var info, out var error)) + { + throw new DatabaseConnectionException( + error ?? "Invalid Supabase connection string.", + DatabaseConnectionFailureReason.InvalidConnectionString); + } + + _info = info!; + QueryService = new SupabaseQueryService(this); + } + + public DatabaseProviderType ProviderType => DatabaseProviderType.Supabase; + + public ConnectionState State => _state; + + public string? DatabaseName => _state == ConnectionState.Open ? "public" : null; + + public string? ServerName => _state == ConnectionState.Open ? new Uri(_info.Url).Host : null; + + public IDatabaseQueryService QueryService { get; } + + public async Task OpenAsync(CancellationToken cancellationToken = default) + { + var client = new HttpClient + { + BaseAddress = new Uri(_info.Url + "/"), + Timeout = TimeSpan.FromSeconds(20) + }; + client.DefaultRequestHeaders.Add("apikey", _info.ApiKey); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _info.ApiKey); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + try + { + using var response = await client.GetAsync("rest/v1/", cancellationToken).ConfigureAwait(false); + + if (response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden) + { + client.Dispose(); + throw new DatabaseConnectionException( + "The API key was rejected. Check your Supabase publishable/secret key.", + DatabaseConnectionFailureReason.AuthenticationFailed); + } + + if (!response.IsSuccessStatusCode) + { + client.Dispose(); + throw new DatabaseConnectionException( + $"Supabase responded with HTTP {(int)response.StatusCode} {response.ReasonPhrase}.", + DatabaseConnectionFailureReason.Unknown); + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + OpenApiSpec = JsonNode.Parse(body); + + _httpClient = client; + _state = ConnectionState.Open; + } + catch (OperationCanceledException) + { + client.Dispose(); + throw; + } + catch (DatabaseConnectionException) + { + throw; + } + catch (HttpRequestException ex) + { + client.Dispose(); + throw new DatabaseConnectionException( + $"Could not reach '{_info.Url}': {ex.Message}", + DatabaseConnectionFailureReason.ServerNotFound, + ex); + } + catch (Exception ex) + { + client.Dispose(); + throw new DatabaseConnectionException( + $"Could not connect to Supabase: {ex.Message}", + DatabaseConnectionFailureReason.Unknown, + ex); + } + } + + public Task CloseAsync() + { + _httpClient?.Dispose(); + _httpClient = null; + OpenApiSpec = null; + _state = ConnectionState.Closed; + return Task.CompletedTask; + } + + public async Task TestConnectionAsync(CancellationToken cancellationToken = default) + { + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + client.DefaultRequestHeaders.Add("apikey", _info.ApiKey); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _info.ApiKey); + + using var response = await client.GetAsync($"{_info.Url}/rest/v1/", cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + await CloseAsync().ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseProvider.cs b/DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseProvider.cs new file mode 100644 index 0000000..d64e1de --- /dev/null +++ b/DatabaseExplorer/DataProviders/Supabase/SupabaseDatabaseProvider.cs @@ -0,0 +1,20 @@ +using DatabaseExplorer.Core.Interfaces; +using DatabaseExplorer.Core.Models; + +namespace DatabaseExplorer.DataProviders.Supabase; + +public sealed class SupabaseDatabaseProvider : IDatabaseProvider +{ + public DatabaseProviderType ProviderType => DatabaseProviderType.Supabase; + + public string DisplayName => "Supabase"; + + public string ConnectionStringPlaceholder => + "Url=https://YOUR_PROJECT.supabase.co;ApiKey=YOUR_PUBLISHABLE_KEY;"; + + public IDatabaseConnection CreateConnection(string connectionString) => + new SupabaseDatabaseConnection(connectionString); + + public string? ValidateConnectionString(string connectionString) => + SupabaseConnectionInfo.TryParse(connectionString, out _, out var error) ? null : error; +} diff --git a/DatabaseExplorer/DataProviders/Supabase/SupabaseQueryService.cs b/DatabaseExplorer/DataProviders/Supabase/SupabaseQueryService.cs new file mode 100644 index 0000000..1417522 --- /dev/null +++ b/DatabaseExplorer/DataProviders/Supabase/SupabaseQueryService.cs @@ -0,0 +1,204 @@ +using System.Data; +using System.Diagnostics; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using DatabaseExplorer.Core.Exceptions; +using DatabaseExplorer.Core.Interfaces; +using DatabaseExplorer.Core.Models; + +namespace DatabaseExplorer.DataProviders.Supabase; + +public sealed class SupabaseQueryService : IDatabaseQueryService +{ + private readonly SupabaseDatabaseConnection _connection; + + public SupabaseQueryService(SupabaseDatabaseConnection connection) + { + _connection = connection; + } + + public Task> GetSchemasAsync(CancellationToken cancellationToken = default) => + Task.FromResult>(new[] { new SchemaInfo("public") }); + + public Task> GetTablesAsync(string schema, CancellationToken cancellationToken = default) + { + var definitions = _connection.OpenApiSpec?["definitions"] as JsonObject; + var names = definitions?.Select(kvp => kvp.Key) ?? Enumerable.Empty(); + + var tables = names + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .Select(n => new TableInfo(schema, n)) + .ToList(); + + return Task.FromResult>(tables); + } + + public Task> GetViewsAsync(string schema, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task> GetProceduresAsync(string schema, CancellationToken cancellationToken = default) + { + var paths = _connection.OpenApiSpec?["paths"] as JsonObject; + + var procedures = (paths?.Select(kvp => kvp.Key) ?? Enumerable.Empty()) + .Where(p => p.StartsWith("/rpc/", StringComparison.OrdinalIgnoreCase)) + .Select(p => p["/rpc/".Length..]) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .Select(n => new ProcedureInfo(schema, n)) + .ToList(); + + return Task.FromResult>(procedures); + } + + public Task> GetColumnsAsync(string schema, string objectName, CancellationToken cancellationToken = default) + { + var definition = _connection.OpenApiSpec?["definitions"]?[objectName] as JsonObject; + var properties = definition?["properties"] as JsonObject; + var required = (definition?["required"] as JsonArray)? + .Select(n => n?.GetValue()) + .Where(n => n is not null) + .ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? new HashSet(StringComparer.OrdinalIgnoreCase); + + var columns = new List(); + var position = 0; + + if (properties is not null) + { + foreach (var (name, node) in properties) + { + position++; + var description = node?["description"]?.GetValue() ?? string.Empty; + var type = node?["format"]?.GetValue() ?? node?["type"]?.GetValue() ?? "unknown"; + var isPrimaryKey = description.Contains("Primary Key", StringComparison.OrdinalIgnoreCase); + var isNullable = !required.Contains(name!); + + columns.Add(new ColumnInfo(name!, type, isNullable, isPrimaryKey, null, position)); + } + } + + return Task.FromResult>(columns); + } + + public async Task GetObjectDataAsync( + string schema, + string objectName, + int? rowLimit, + IProgress? progress, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var query = rowLimit is int limit + ? $"rest/v1/{objectName}?select=*&limit={limit}" + : $"rest/v1/{objectName}?select=*"; + + try + { + progress?.Report(rowLimit is int l + ? $"Loading first {l:N0} row(s) of {objectName}..." + : $"Loading {objectName}..."); + + using var response = await _connection.HttpClient.GetAsync(query, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var table = BuildDataTable(objectName, body); + + stopwatch.Stop(); + progress?.Report($"Loaded {table.Rows.Count:N0} row(s) from {objectName}."); + + return new QueryResult { Data = table, SourceName = $"{schema}.{objectName}", Elapsed = stopwatch.Elapsed }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new DatabaseQueryException($"Could not load data from '{objectName}': {ex.Message}", ex); + } + } + + public Task ExecuteQueryAsync(string sql, IProgress? progress, CancellationToken cancellationToken = default) => + throw new DatabaseQueryException( + "Supabase REST connections don't support raw SQL. Expose a Postgres function (RPC) instead — it will show up under Procedures."); + + public async Task GetRowCountAsync(string schema, string objectName, CancellationToken cancellationToken = default) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Head, $"rest/v1/{objectName}?select=*"); + request.Headers.Add("Prefer", "count=exact"); + + using var response = await _connection.HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + string? contentRange = null; + if (response.Content.Headers.TryGetValues("Content-Range", out var values)) + { + contentRange = values.FirstOrDefault(); + } + + var slashIndex = contentRange?.IndexOf('/') ?? -1; + if (slashIndex >= 0 && slashIndex < contentRange!.Length - 1) + { + var totalPart = contentRange[(slashIndex + 1)..]; + if (long.TryParse(totalPart, out var total)) + { + return total; + } + } + + return 0; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new DatabaseQueryException($"Could not determine row count for '{schema}.{objectName}': {ex.Message}", ex); + } + } + + private static DataTable BuildDataTable(string tableName, string json) + { + var table = new DataTable(tableName); + using var doc = JsonDocument.Parse(json); + + if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0) + { + return table; + } + + foreach (var prop in doc.RootElement[0].EnumerateObject()) + { + table.Columns.Add(prop.Name, typeof(string)); + } + + foreach (var row in doc.RootElement.EnumerateArray()) + { + var values = new object?[table.Columns.Count]; + var i = 0; + + foreach (var prop in row.EnumerateObject()) + { + values[i++] = prop.Value.ValueKind switch + { + JsonValueKind.Null => DBNull.Value, + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => prop.Value.GetRawText() + }; + } + + table.Rows.Add(values); + } + + return table; + } +} diff --git a/DatabaseExplorer/DatabaseExplorer.csproj b/DatabaseExplorer/DatabaseExplorer.csproj index a77c545..4e013c0 100644 --- a/DatabaseExplorer/DatabaseExplorer.csproj +++ b/DatabaseExplorer/DatabaseExplorer.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -29,7 +29,12 @@ + + + + + diff --git a/DatabaseExplorer/ViewModels/MainViewModel.cs b/DatabaseExplorer/ViewModels/MainViewModel.cs index 27ccb35..fc55dbf 100644 --- a/DatabaseExplorer/ViewModels/MainViewModel.cs +++ b/DatabaseExplorer/ViewModels/MainViewModel.cs @@ -10,6 +10,7 @@ using DatabaseExplorer.Core.Exceptions; using DatabaseExplorer.Core.Interfaces; using DatabaseExplorer.Core.Models; +using DatabaseExplorer.DataProviders.Supabase; using DatabaseExplorer.Helpers; namespace DatabaseExplorer.ViewModels; @@ -82,6 +83,25 @@ private void OnThemeServiceThemeChanged(object? sender, AppTheme theme) => public string ConnectionStringPlaceholder => _providerFactory.GetProvider(SelectedProviderType).ConnectionStringPlaceholder; + public bool IsSupabaseSelected => SelectedProviderType == DatabaseProviderType.Supabase; + + public string ConnectionSectionLabel => IsSupabaseSelected ? "Supabase project" : "Connection string"; + + public string SupabaseUrl + { + get => SupabaseConnectionInfo.ParseLenient(ConnectionString).Url; + set => ConnectionString = ComposeSupabaseConnectionString(value, SupabaseApiKey); + } + + public string SupabaseApiKey + { + get => SupabaseConnectionInfo.ParseLenient(ConnectionString).ApiKey; + set => ConnectionString = ComposeSupabaseConnectionString(SupabaseUrl, value); + } + + private static string ComposeSupabaseConnectionString(string url, string apiKey) => + $"Url={url};ApiKey={apiKey};"; + public ObservableCollection TreeNodes { get; } = new ObservableCollection(); [ObservableProperty] @@ -168,13 +188,19 @@ or AppConnectionState.Scanning // ----- Property change hooks (keep computed state & command availability in sync) -- - partial void OnSelectedProviderTypeChanged(DatabaseProviderType value) => + partial void OnSelectedProviderTypeChanged(DatabaseProviderType value) + { OnPropertyChanged(nameof(ConnectionStringPlaceholder)); + OnPropertyChanged(nameof(IsSupabaseSelected)); + OnPropertyChanged(nameof(ConnectionSectionLabel)); + } partial void OnConnectionStringChanged(string value) { ConnectCommand.NotifyCanExecuteChanged(); SaveConnectionCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(SupabaseUrl)); + OnPropertyChanged(nameof(SupabaseApiKey)); } partial void OnIsBusyChanged(bool value) => RefreshCommandStates(); @@ -1018,4 +1044,4 @@ public async ValueTask DisposeAsync() _connection = null; } } -} +} \ No newline at end of file diff --git a/DatabaseExplorer/Views/MainWindow.xaml b/DatabaseExplorer/Views/MainWindow.xaml index ae79174..0b327a1 100644 --- a/DatabaseExplorer/Views/MainWindow.xaml +++ b/DatabaseExplorer/Views/MainWindow.xaml @@ -178,7 +178,7 @@ Command="{Binding DeleteSavedConnectionCommand}" /> - + + VerticalScrollBarVisibility="Disabled" + Visibility="{Binding IsSupabaseSelected, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" /> + + + + +