using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Extensions.Options; using Npgsql; using StellaOps.Infrastructure.Postgres.Options; namespace StellaOps.Graph.Api.Services; public sealed class PostgresGraphSavedViewStore : IGraphSavedViewStore, IAsyncDisposable { public const string DefaultSchemaName = "graph"; private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private readonly NpgsqlDataSource _dataSource; private readonly string _schemaName; private readonly TimeProvider _timeProvider; public PostgresGraphSavedViewStore(IOptions options, TimeProvider timeProvider) { ArgumentNullException.ThrowIfNull(options); var connectionString = options.Value.ConnectionString ?? throw new InvalidOperationException("Graph saved-view persistence requires a PostgreSQL connection string."); _dataSource = CreateDataSource(connectionString); _schemaName = string.IsNullOrWhiteSpace(options.Value.SchemaName) ? DefaultSchemaName : options.Value.SchemaName.Trim(); _timeProvider = timeProvider; } public async Task> ListAsync( string tenant, string graphId, CancellationToken cancellationToken) { await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); var sql = $""" SELECT view_id, name, description, filters_json, layout_json, overlays_json, created_at FROM {QuoteIdentifier(_schemaName)}.saved_views WHERE tenant_id = @tenant AND graph_id = @graphId ORDER BY created_at DESC, view_id """; await using var command = new NpgsqlCommand(sql, connection); command.Parameters.AddWithValue("tenant", tenant); command.Parameters.AddWithValue("graphId", graphId); var items = new List(); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { items.Add(new GraphSavedViewRecord( ViewId: reader.GetString(0), GraphId: graphId, Name: reader.GetString(1), Description: reader.IsDBNull(2) ? null : reader.GetString(2), Filters: ReadJsonObject(reader, 3), Layout: ReadJsonObject(reader, 4), Overlays: ReadStringArray(reader, 5), CreatedAt: reader.GetFieldValue(6))); } return items; } public async Task CreateAsync( string tenant, string graphId, CreateGraphSavedViewRequest request, CancellationToken cancellationToken) { var record = new GraphSavedViewRecord( ViewId: $"view-{Guid.NewGuid():N}", GraphId: graphId, Name: request.Name.Trim(), Description: string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Filters: request.Filters?.DeepClone() as JsonObject, Layout: request.Layout?.DeepClone() as JsonObject, Overlays: NormalizeOverlays(request.Overlays), CreatedAt: _timeProvider.GetUtcNow()); await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); var sql = $""" INSERT INTO {QuoteIdentifier(_schemaName)}.saved_views (tenant_id, graph_id, view_id, name, description, filters_json, layout_json, overlays_json, created_at) VALUES (@tenant, @graphId, @viewId, @name, @description, CAST(@filtersJson AS jsonb), CAST(@layoutJson AS jsonb), CAST(@overlaysJson AS jsonb), @createdAt) """; await using var command = new NpgsqlCommand(sql, connection); command.Parameters.AddWithValue("tenant", tenant); command.Parameters.AddWithValue("graphId", graphId); command.Parameters.AddWithValue("viewId", record.ViewId); command.Parameters.AddWithValue("name", record.Name); command.Parameters.AddWithValue("description", (object?)record.Description ?? DBNull.Value); command.Parameters.AddWithValue("filtersJson", SerializeJsonObject(record.Filters)); command.Parameters.AddWithValue("layoutJson", SerializeJsonObject(record.Layout)); command.Parameters.AddWithValue("overlaysJson", JsonSerializer.Serialize(record.Overlays, SerializerOptions)); command.Parameters.AddWithValue("createdAt", record.CreatedAt); await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); return record; } public async Task DeleteAsync( string tenant, string graphId, string viewId, CancellationToken cancellationToken) { await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); var sql = $""" DELETE FROM {QuoteIdentifier(_schemaName)}.saved_views WHERE tenant_id = @tenant AND graph_id = @graphId AND view_id = @viewId """; await using var command = new NpgsqlCommand(sql, connection); command.Parameters.AddWithValue("tenant", tenant); command.Parameters.AddWithValue("graphId", graphId); command.Parameters.AddWithValue("viewId", viewId); var deleted = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); return deleted > 0; } private static string[] NormalizeOverlays(IReadOnlyList? overlays) { return (overlays ?? []) .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } private static JsonObject? ReadJsonObject(NpgsqlDataReader reader, int ordinal) { if (reader.IsDBNull(ordinal)) { return null; } return JsonNode.Parse(reader.GetString(ordinal)) as JsonObject; } private static IReadOnlyList ReadStringArray(NpgsqlDataReader reader, int ordinal) { if (reader.IsDBNull(ordinal)) { return []; } return JsonSerializer.Deserialize(reader.GetString(ordinal), SerializerOptions) ?? []; } private static string SerializeJsonObject(JsonObject? value) => value?.ToJsonString() ?? "null"; private static string QuoteIdentifier(string identifier) => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\""; private static NpgsqlDataSource CreateDataSource(string connectionString) { ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString) { ApplicationName = "stellaops-graph-saved-views" }; return new NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString).Build(); } public ValueTask DisposeAsync() => _dataSource.DisposeAsync(); }