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>
183 lines
7.3 KiB
C#
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();
|
|
}
|