Files
git.stella-ops.org/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphSavedViewStore.cs
master 285f761c77 Add Graph saved views persistence and compatibility endpoints
Introduce PostgresGraphSavedViewStore with SQL migration, in-memory fallback,
CompatibilityEndpoints for UI contract alignment, and integration tests
with a shared Postgres fixture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:52:37 +03:00

183 lines
7.3 KiB
C#

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<PostgresOptions> 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<IReadOnlyList<GraphSavedViewRecord>> 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<GraphSavedViewRecord>();
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<DateTimeOffset>(6)));
}
return items;
}
public async Task<GraphSavedViewRecord> 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<bool> 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<string>? 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<string> ReadStringArray(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return [];
}
return JsonSerializer.Deserialize<string[]>(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();
}