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>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user