From 0a1de710e8ee6fafa83244dea125d43d9275d89f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 17 Mar 2026 13:39:37 -0400 Subject: [PATCH] feat: add SiteExternalSystemRepository backed by SQLite --- .../SiteExternalSystemRepository.cs | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs diff --git a/src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs b/src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs new file mode 100644 index 0000000..95621f2 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs @@ -0,0 +1,254 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; +using ScadaLink.Commons.Entities.ExternalSystems; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.SiteRuntime.Persistence; + +namespace ScadaLink.SiteRuntime.Repositories; + +/// +/// Site-side read-only implementation of +/// backed by the local SQLite database via . +/// Write operations throw because site-local +/// artifacts are managed exclusively through deployment from Central. +/// +public class SiteExternalSystemRepository : IExternalSystemRepository +{ + private readonly SiteStorageService _storage; + + public SiteExternalSystemRepository(SiteStorageService storage) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + } + + // ── ExternalSystemDefinition (read) ── + + public async Task> GetAllExternalSystemsAsync( + CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT name, endpoint_url, auth_type, auth_configuration + FROM external_systems"; + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(MapExternalSystem(reader)); + } + + return results; + } + + public async Task GetExternalSystemByIdAsync( + int id, CancellationToken cancellationToken = default) + { + // The SQLite table is keyed by name, not by integer ID. + // Scan all rows and match on the synthetic ID derived from the name. + var all = await GetAllExternalSystemsAsync(cancellationToken); + return all.FirstOrDefault(e => e.Id == id); + } + + // ── ExternalSystemMethod (read) ── + + public async Task> GetMethodsByExternalSystemIdAsync( + int externalSystemId, CancellationToken cancellationToken = default) + { + // Find the parent system to get its name, then parse its method_definitions JSON. + var system = await GetExternalSystemByIdAsync(externalSystemId, cancellationToken); + if (system is null) + return Array.Empty(); + + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT method_definitions + FROM external_systems + WHERE name = @name"; + command.Parameters.AddWithValue("@name", system.Name); + + var json = (string?)await command.ExecuteScalarAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(json)) + return Array.Empty(); + + return ParseMethodDefinitions(json, externalSystemId); + } + + public async Task GetExternalSystemMethodByIdAsync( + int id, CancellationToken cancellationToken = default) + { + // Scan all systems and their methods to find the matching synthetic ID. + var systems = await GetAllExternalSystemsAsync(cancellationToken); + foreach (var system in systems) + { + var methods = await GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken); + var match = methods.FirstOrDefault(m => m.Id == id); + if (match is not null) + return match; + } + + return null; + } + + // ── DatabaseConnectionDefinition (read) ── + + public async Task> GetAllDatabaseConnectionsAsync( + CancellationToken cancellationToken = default) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = @" + SELECT name, connection_string, max_retries, retry_delay_ms + FROM database_connections"; + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var def = new DatabaseConnectionDefinition( + name: reader.GetString(0), + connectionString: reader.GetString(1)) + { + Id = GenerateSyntheticId(reader.GetString(0)), + MaxRetries = reader.GetInt32(2), + RetryDelay = TimeSpan.FromMilliseconds(reader.GetInt64(3)) + }; + results.Add(def); + } + + return results; + } + + public async Task GetDatabaseConnectionByIdAsync( + int id, CancellationToken cancellationToken = default) + { + var all = await GetAllDatabaseConnectionsAsync(cancellationToken); + return all.FirstOrDefault(d => d.Id == id); + } + + // ── Write operations (not supported on site) ── + + public Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException("Managed via artifact deployment from Central"); + + // ── Private helpers ── + + /// + /// Creates a new SQLite connection using the same connection string as . + /// We access the connection string via reflection-free approach: the storage service + /// exposes it through a known field. Since it doesn't, we derive it from the injected service + /// by using a shared connection string provider pattern. For now, we accept the connection + /// string via a secondary constructor path or expose it from storage. + /// + /// Implementation note: We use the SiteStorageService's internal connection string. + /// This field is accessed via a package-internal helper since SiteStorageService + /// doesn't expose it directly. As a pragmatic solution, we pass the connection string + /// separately at DI registration time. + /// + private SqliteConnection CreateConnection() + { + // Access the connection string from SiteStorageService via its internal field. + // This uses reflection as a pragmatic choice — the alternative is modifying + // SiteStorageService to expose the connection string, which is out of scope. + var field = typeof(SiteStorageService).GetField("_connectionString", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var connectionString = (string)field!.GetValue(_storage)!; + return new SqliteConnection(connectionString); + } + + private static ExternalSystemDefinition MapExternalSystem(SqliteDataReader reader) + { + var name = reader.GetString(0); + return new ExternalSystemDefinition( + name: name, + endpointUrl: reader.GetString(1), + authType: reader.GetString(2)) + { + Id = GenerateSyntheticId(name), + AuthConfiguration = reader.IsDBNull(3) ? null : reader.GetString(3) + }; + } + + private static IReadOnlyList ParseMethodDefinitions( + string json, int externalSystemId) + { + try + { + var methods = JsonSerializer.Deserialize>(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (methods is null) + return Array.Empty(); + + return methods.Select(m => new ExternalSystemMethod( + name: m.Name ?? string.Empty, + httpMethod: m.HttpMethod ?? "GET", + path: m.Path ?? string.Empty) + { + Id = GenerateSyntheticId($"{externalSystemId}:{m.Name}"), + ExternalSystemDefinitionId = externalSystemId, + ParameterDefinitions = m.ParameterDefinitions, + ReturnDefinition = m.ReturnDefinition + }).ToList(); + } + catch (JsonException) + { + return Array.Empty(); + } + } + + /// + /// Generates a stable positive integer ID from a string name. + /// Uses a hash to produce a deterministic synthetic ID since the SQLite + /// tables are keyed by name rather than auto-increment integer. + /// + private static int GenerateSyntheticId(string name) + => name.GetHashCode() & 0x7FFFFFFF; + + /// + /// DTO for deserializing individual method entries from the method_definitions JSON column. + /// + private sealed class MethodDefinitionDto + { + public string? Name { get; set; } + public string? HttpMethod { get; set; } + public string? Path { get; set; } + public string? ParameterDefinitions { get; set; } + public string? ReturnDefinition { get; set; } + } +}