wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -23,6 +23,7 @@ public static class ScmWebhookEndpoints
|
||||
|
||||
webhooks.MapPost("/github", HandleGitHubWebhookAsync)
|
||||
.WithName("ScmWebhookGitHub")
|
||||
.WithDescription("Inbound webhook endpoint for GitHub events. Validates the X-Hub-Signature-256 HMAC signature, extracts the event type and delivery ID, and dispatches the payload to the SCM webhook service for scan and SBOM trigger evaluation. Returns 202 Accepted on success.")
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
@@ -31,6 +32,7 @@ public static class ScmWebhookEndpoints
|
||||
|
||||
webhooks.MapPost("/gitlab", HandleGitLabWebhookAsync)
|
||||
.WithName("ScmWebhookGitLab")
|
||||
.WithDescription("Inbound webhook endpoint for GitLab events. Validates the X-Gitlab-Token header, extracts the event UUID and type, and dispatches the payload for scan and SBOM trigger evaluation. Returns 202 Accepted on success.")
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
@@ -39,6 +41,7 @@ public static class ScmWebhookEndpoints
|
||||
|
||||
webhooks.MapPost("/gitea", HandleGiteaWebhookAsync)
|
||||
.WithName("ScmWebhookGitea")
|
||||
.WithDescription("Inbound webhook endpoint for Gitea events. Validates the X-Hub-Signature-256 HMAC signature (falls back to X-Hub-Signature), extracts the event type and delivery ID, and dispatches the payload for scan and SBOM trigger evaluation. Returns 202 Accepted on success.")
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Signals.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for SignalsDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.SignalsDbContext))]
|
||||
public partial class SignalsDbContextModel : RuntimeModel
|
||||
{
|
||||
private static SignalsDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new SignalsDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Signals.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for SignalsDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class SignalsDbContextModel
|
||||
{
|
||||
partial void Initialize()
|
||||
{
|
||||
// Stub: when a real compiled model is generated, entity types will be registered here.
|
||||
// The runtime factory will fall back to reflection-based model building for all schemas
|
||||
// until this stub is replaced with a full compiled model.
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,532 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Signals module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// EF Core DbContext for the Signals module.
|
||||
/// Maps to the signals PostgreSQL schema: callgraphs, reachability_facts, unknowns,
|
||||
/// func_nodes, call_edges, cve_func_hits, deploy_refs, graph_metrics,
|
||||
/// scans, cg_nodes, cg_edges, entrypoints, artifacts, symbol_component_map,
|
||||
/// reachability_components, reachability_findings, runtime_samples,
|
||||
/// runtime_agents, runtime_facts, agent_heartbeats, agent_commands.
|
||||
/// </summary>
|
||||
public class SignalsDbContext : DbContext
|
||||
public partial class SignalsDbContext : DbContext
|
||||
{
|
||||
public SignalsDbContext(DbContextOptions<SignalsDbContext> options)
|
||||
private readonly string _schemaName;
|
||||
|
||||
public SignalsDbContext(DbContextOptions<SignalsDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "signals"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
// Document-store tables (repository-provisioned)
|
||||
public virtual DbSet<Callgraph> Callgraphs { get; set; }
|
||||
public virtual DbSet<ReachabilityFact> ReachabilityFacts { get; set; }
|
||||
public virtual DbSet<Unknown> Unknowns { get; set; }
|
||||
public virtual DbSet<FuncNode> FuncNodes { get; set; }
|
||||
public virtual DbSet<CallEdge> CallEdges { get; set; }
|
||||
public virtual DbSet<CveFuncHit> CveFuncHits { get; set; }
|
||||
public virtual DbSet<DeployRef> DeployRefs { get; set; }
|
||||
public virtual DbSet<GraphMetric> GraphMetrics { get; set; }
|
||||
|
||||
// Migration-defined relational tables
|
||||
public virtual DbSet<Scan> Scans { get; set; }
|
||||
public virtual DbSet<CgNode> CgNodes { get; set; }
|
||||
public virtual DbSet<CgEdge> CgEdges { get; set; }
|
||||
public virtual DbSet<Entrypoint> Entrypoints { get; set; }
|
||||
public virtual DbSet<SymbolComponentMap> SymbolComponentMaps { get; set; }
|
||||
public virtual DbSet<ReachabilityComponent> ReachabilityComponents { get; set; }
|
||||
public virtual DbSet<ReachabilityFinding> ReachabilityFindings { get; set; }
|
||||
|
||||
// Runtime agent tables
|
||||
public virtual DbSet<SignalsRuntimeAgent> RuntimeAgents { get; set; }
|
||||
public virtual DbSet<RuntimeFact> RuntimeFacts { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("signals");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// ── callgraphs ──────────────────────────────────────────────────
|
||||
modelBuilder.Entity<Callgraph>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("callgraphs_pkey");
|
||||
entity.ToTable("callgraphs", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.Component, "idx_callgraphs_component");
|
||||
entity.HasIndex(e => e.GraphHash, "idx_callgraphs_graph_hash");
|
||||
entity.HasIndex(e => e.IngestedAt, "idx_callgraphs_ingested_at")
|
||||
.IsDescending(true);
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Language).HasColumnName("language");
|
||||
entity.Property(e => e.Component).HasColumnName("component");
|
||||
entity.Property(e => e.Version).HasColumnName("version");
|
||||
entity.Property(e => e.GraphHash).HasColumnName("graph_hash");
|
||||
entity.Property(e => e.IngestedAt).HasColumnName("ingested_at");
|
||||
entity.Property(e => e.DocumentJson)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("document_json");
|
||||
});
|
||||
|
||||
// ── reachability_facts ──────────────────────────────────────────
|
||||
modelBuilder.Entity<ReachabilityFact>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.SubjectKey).HasName("reachability_facts_pkey");
|
||||
entity.ToTable("reachability_facts", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.CallgraphId, "idx_reachability_facts_callgraph_id");
|
||||
entity.HasIndex(e => e.ComputedAt, "idx_reachability_facts_computed_at");
|
||||
entity.HasIndex(e => e.Score, "idx_reachability_facts_score")
|
||||
.IsDescending(true);
|
||||
|
||||
entity.Property(e => e.SubjectKey).HasColumnName("subject_key");
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.CallgraphId).HasColumnName("callgraph_id");
|
||||
entity.Property(e => e.Score).HasColumnName("score");
|
||||
entity.Property(e => e.RiskScore).HasColumnName("risk_score");
|
||||
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
|
||||
entity.Property(e => e.DocumentJson)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("document_json");
|
||||
});
|
||||
|
||||
// ── unknowns ────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<Unknown>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.SubjectKey, e.Id }).HasName("unknowns_pkey");
|
||||
entity.ToTable("unknowns", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.Band, "idx_unknowns_band");
|
||||
entity.HasIndex(e => e.Score, "idx_unknowns_score_desc")
|
||||
.IsDescending(true);
|
||||
entity.HasIndex(e => new { e.Band, e.Score }, "idx_unknowns_band_score")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => e.NextScheduledRescan, "idx_unknowns_next_rescan")
|
||||
.HasFilter("(next_scheduled_rescan IS NOT NULL)");
|
||||
entity.HasIndex(e => e.Score, "idx_unknowns_hot_band")
|
||||
.IsDescending(true)
|
||||
.HasFilter("(band = 'hot')");
|
||||
entity.HasIndex(e => e.Purl, "idx_unknowns_purl");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.SubjectKey).HasColumnName("subject_key");
|
||||
entity.Property(e => e.CallgraphId).HasColumnName("callgraph_id");
|
||||
entity.Property(e => e.SymbolId).HasColumnName("symbol_id");
|
||||
entity.Property(e => e.CodeId).HasColumnName("code_id");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.PurlVersion).HasColumnName("purl_version");
|
||||
entity.Property(e => e.EdgeFrom).HasColumnName("edge_from");
|
||||
entity.Property(e => e.EdgeTo).HasColumnName("edge_to");
|
||||
entity.Property(e => e.Reason).HasColumnName("reason");
|
||||
entity.Property(e => e.PopularityP).HasDefaultValue(0.0).HasColumnName("popularity_p");
|
||||
entity.Property(e => e.DeploymentCount).HasDefaultValue(0).HasColumnName("deployment_count");
|
||||
entity.Property(e => e.ExploitPotentialE).HasDefaultValue(0.0).HasColumnName("exploit_potential_e");
|
||||
entity.Property(e => e.UncertaintyU).HasDefaultValue(0.0).HasColumnName("uncertainty_u");
|
||||
entity.Property(e => e.CentralityC).HasDefaultValue(0.0).HasColumnName("centrality_c");
|
||||
entity.Property(e => e.DegreeCentrality).HasDefaultValue(0).HasColumnName("degree_centrality");
|
||||
entity.Property(e => e.BetweennessCentrality).HasDefaultValue(0.0).HasColumnName("betweenness_centrality");
|
||||
entity.Property(e => e.StalenessS).HasDefaultValue(0.0).HasColumnName("staleness_s");
|
||||
entity.Property(e => e.DaysSinceAnalysis).HasDefaultValue(0).HasColumnName("days_since_analysis");
|
||||
entity.Property(e => e.Score).HasDefaultValue(0.0).HasColumnName("score");
|
||||
entity.Property(e => e.Band).HasDefaultValueSql("'cold'").HasColumnName("band");
|
||||
entity.Property(e => e.UnknownFlags)
|
||||
.HasDefaultValueSql("'{}'::jsonb")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("unknown_flags");
|
||||
entity.Property(e => e.NormalizationTrace)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("normalization_trace");
|
||||
entity.Property(e => e.RescanAttempts).HasDefaultValue(0).HasColumnName("rescan_attempts");
|
||||
entity.Property(e => e.LastRescanResult).HasColumnName("last_rescan_result");
|
||||
entity.Property(e => e.NextScheduledRescan).HasColumnName("next_scheduled_rescan");
|
||||
entity.Property(e => e.LastAnalyzedAt).HasColumnName("last_analyzed_at");
|
||||
entity.Property(e => e.GraphSliceHash).HasColumnName("graph_slice_hash");
|
||||
entity.Property(e => e.EvidenceSetHash).HasColumnName("evidence_set_hash");
|
||||
entity.Property(e => e.CallgraphAttemptHash).HasColumnName("callgraph_attempt_hash");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
// ── func_nodes ──────────────────────────────────────────────────
|
||||
modelBuilder.Entity<FuncNode>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("func_nodes_pkey");
|
||||
entity.ToTable("func_nodes", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.GraphHash, "idx_func_nodes_graph_hash");
|
||||
entity.HasIndex(e => e.SymbolDigest, "idx_func_nodes_symbol_digest")
|
||||
.HasFilter("(symbol_digest IS NOT NULL)");
|
||||
entity.HasIndex(e => e.Purl, "idx_func_nodes_purl")
|
||||
.HasFilter("(purl IS NOT NULL)");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.GraphHash).HasColumnName("graph_hash");
|
||||
entity.Property(e => e.SymbolId).HasColumnName("symbol_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.Kind).HasColumnName("kind");
|
||||
entity.Property(e => e.Namespace).HasColumnName("namespace");
|
||||
entity.Property(e => e.File).HasColumnName("file");
|
||||
entity.Property(e => e.Line).HasColumnName("line");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
|
||||
entity.Property(e => e.BuildId).HasColumnName("build_id");
|
||||
entity.Property(e => e.CodeId).HasColumnName("code_id");
|
||||
entity.Property(e => e.Language).HasColumnName("language");
|
||||
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
|
||||
entity.Property(e => e.Analyzer).HasColumnType("jsonb").HasColumnName("analyzer");
|
||||
entity.Property(e => e.IngestedAt).HasColumnName("ingested_at");
|
||||
});
|
||||
|
||||
// ── call_edges ──────────────────────────────────────────────────
|
||||
modelBuilder.Entity<CallEdge>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("call_edges_pkey");
|
||||
entity.ToTable("call_edges", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.GraphHash, "idx_call_edges_graph_hash");
|
||||
entity.HasIndex(e => e.SourceId, "idx_call_edges_source_id");
|
||||
entity.HasIndex(e => e.TargetId, "idx_call_edges_target_id");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.GraphHash).HasColumnName("graph_hash");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.TargetId).HasColumnName("target_id");
|
||||
entity.Property(e => e.Type).HasColumnName("type");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
|
||||
entity.Property(e => e.Candidates).HasColumnType("jsonb").HasColumnName("candidates");
|
||||
entity.Property(e => e.Confidence).HasColumnName("confidence");
|
||||
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
|
||||
entity.Property(e => e.IngestedAt).HasColumnName("ingested_at");
|
||||
});
|
||||
|
||||
// ── cve_func_hits ───────────────────────────────────────────────
|
||||
modelBuilder.Entity<CveFuncHit>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("cve_func_hits_pkey");
|
||||
entity.ToTable("cve_func_hits", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.SubjectKey, "idx_cve_func_hits_subject_key");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.SubjectKey).HasColumnName("subject_key");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.GraphHash).HasColumnName("graph_hash");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
|
||||
entity.Property(e => e.Reachable).HasDefaultValue(false).HasColumnName("reachable");
|
||||
entity.Property(e => e.Confidence).HasColumnName("confidence");
|
||||
entity.Property(e => e.LatticeState).HasColumnName("lattice_state");
|
||||
entity.Property(e => e.EvidenceUris).HasColumnType("jsonb").HasColumnName("evidence_uris");
|
||||
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
|
||||
});
|
||||
|
||||
// ── deploy_refs ─────────────────────────────────────────────────
|
||||
modelBuilder.Entity<DeployRef>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("deploy_refs_pkey");
|
||||
entity.ToTable("deploy_refs", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.Purl, e.ImageId, e.Environment })
|
||||
.HasName("uq_deploy_refs_purl_image_env");
|
||||
|
||||
entity.HasIndex(e => e.Purl, "idx_deploy_refs_purl");
|
||||
entity.HasIndex(e => e.LastSeenAt, "idx_deploy_refs_last_seen");
|
||||
entity.HasIndex(e => e.Environment, "idx_deploy_refs_environment");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.PurlVersion).HasColumnName("purl_version");
|
||||
entity.Property(e => e.ImageId).HasColumnName("image_id");
|
||||
entity.Property(e => e.ImageDigest).HasColumnName("image_digest");
|
||||
entity.Property(e => e.Environment).HasDefaultValueSql("'unknown'").HasColumnName("environment");
|
||||
entity.Property(e => e.Namespace).HasColumnName("namespace");
|
||||
entity.Property(e => e.Cluster).HasColumnName("cluster");
|
||||
entity.Property(e => e.Region).HasColumnName("region");
|
||||
entity.Property(e => e.FirstSeenAt).HasDefaultValueSql("now()").HasColumnName("first_seen_at");
|
||||
entity.Property(e => e.LastSeenAt).HasDefaultValueSql("now()").HasColumnName("last_seen_at");
|
||||
});
|
||||
|
||||
// ── graph_metrics ───────────────────────────────────────────────
|
||||
modelBuilder.Entity<GraphMetric>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("graph_metrics_pkey");
|
||||
entity.ToTable("graph_metrics", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.NodeId, e.CallgraphId })
|
||||
.HasName("uq_graph_metrics_node_graph");
|
||||
|
||||
entity.HasIndex(e => e.NodeId, "idx_graph_metrics_node");
|
||||
entity.HasIndex(e => e.CallgraphId, "idx_graph_metrics_callgraph");
|
||||
entity.HasIndex(e => e.BetweennessCentrality, "idx_graph_metrics_betweenness")
|
||||
.IsDescending(true);
|
||||
entity.HasIndex(e => e.ComputedAt, "idx_graph_metrics_computed");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.NodeId).HasColumnName("node_id");
|
||||
entity.Property(e => e.CallgraphId).HasColumnName("callgraph_id");
|
||||
entity.Property(e => e.NodeType).HasDefaultValueSql("'symbol'").HasColumnName("node_type");
|
||||
entity.Property(e => e.DegreeCentrality).HasDefaultValue(0).HasColumnName("degree_centrality");
|
||||
entity.Property(e => e.InDegree).HasDefaultValue(0).HasColumnName("in_degree");
|
||||
entity.Property(e => e.OutDegree).HasDefaultValue(0).HasColumnName("out_degree");
|
||||
entity.Property(e => e.BetweennessCentrality).HasDefaultValue(0.0).HasColumnName("betweenness_centrality");
|
||||
entity.Property(e => e.ClosenessCentrality).HasColumnName("closeness_centrality");
|
||||
entity.Property(e => e.EigenvectorCentrality).HasColumnName("eigenvector_centrality");
|
||||
entity.Property(e => e.NormalizedBetweenness).HasColumnName("normalized_betweenness");
|
||||
entity.Property(e => e.NormalizedDegree).HasColumnName("normalized_degree");
|
||||
entity.Property(e => e.ComputedAt).HasDefaultValueSql("now()").HasColumnName("computed_at");
|
||||
entity.Property(e => e.ComputationDurationMs).HasColumnName("computation_duration_ms");
|
||||
entity.Property(e => e.AlgorithmVersion).HasDefaultValueSql("'1.0'").HasColumnName("algorithm_version");
|
||||
entity.Property(e => e.TotalNodes).HasColumnName("total_nodes");
|
||||
entity.Property(e => e.TotalEdges).HasColumnName("total_edges");
|
||||
});
|
||||
|
||||
// ── scans ───────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<Scan>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.ScanId).HasName("scans_pkey");
|
||||
entity.ToTable("scans", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.Status, "idx_scans_status");
|
||||
entity.HasIndex(e => e.ArtifactDigest, "idx_scans_artifact");
|
||||
entity.HasIndex(e => e.CreatedAt, "idx_scans_created")
|
||||
.IsDescending(true);
|
||||
|
||||
entity.Property(e => e.ScanId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("scan_id");
|
||||
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
|
||||
entity.Property(e => e.RepoUri).HasColumnName("repo_uri");
|
||||
entity.Property(e => e.CommitSha).HasColumnName("commit_sha");
|
||||
entity.Property(e => e.SbomDigest).HasColumnName("sbom_digest");
|
||||
entity.Property(e => e.PolicyDigest).HasColumnName("policy_digest");
|
||||
entity.Property(e => e.Status).HasDefaultValueSql("'pending'").HasColumnName("status");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
|
||||
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
|
||||
});
|
||||
|
||||
// ── cg_nodes ────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<CgNode>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("cg_nodes_pkey");
|
||||
entity.ToTable("cg_nodes", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.ScanId, e.NodeId })
|
||||
.HasName("cg_nodes_scan_node_unique");
|
||||
|
||||
entity.HasIndex(e => e.ScanId, "idx_cg_nodes_scan");
|
||||
entity.HasIndex(e => e.SymbolKey, "idx_cg_nodes_symbol_key");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.UseIdentityByDefaultColumn()
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.ScanId).HasColumnName("scan_id");
|
||||
entity.Property(e => e.NodeId).HasColumnName("node_id");
|
||||
entity.Property(e => e.ArtifactKey).HasColumnName("artifact_key");
|
||||
entity.Property(e => e.SymbolKey).HasColumnName("symbol_key");
|
||||
entity.Property(e => e.Visibility).HasDefaultValueSql("'unknown'").HasColumnName("visibility");
|
||||
entity.Property(e => e.IsEntrypointCandidate).HasDefaultValue(false).HasColumnName("is_entrypoint_candidate");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.SymbolDigest).HasColumnName("symbol_digest");
|
||||
entity.Property(e => e.Flags).HasDefaultValue(0).HasColumnName("flags");
|
||||
entity.Property(e => e.Attributes).HasColumnType("jsonb").HasColumnName("attributes");
|
||||
});
|
||||
|
||||
// ── cg_edges ────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<CgEdge>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("cg_edges_pkey");
|
||||
entity.ToTable("cg_edges", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.ScanId, e.FromNodeId, e.ToNodeId, e.Kind, e.Reason })
|
||||
.HasName("cg_edges_unique");
|
||||
|
||||
entity.HasIndex(e => e.ScanId, "idx_cg_edges_scan");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.UseIdentityByDefaultColumn()
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.ScanId).HasColumnName("scan_id");
|
||||
entity.Property(e => e.FromNodeId).HasColumnName("from_node_id");
|
||||
entity.Property(e => e.ToNodeId).HasColumnName("to_node_id");
|
||||
entity.Property(e => e.Kind).HasDefaultValue((short)0).HasColumnName("kind");
|
||||
entity.Property(e => e.Reason).HasDefaultValue((short)0).HasColumnName("reason");
|
||||
entity.Property(e => e.Weight).HasDefaultValue(1.0f).HasColumnName("weight");
|
||||
entity.Property(e => e.OffsetBytes).HasColumnName("offset_bytes");
|
||||
entity.Property(e => e.IsResolved).HasDefaultValue(true).HasColumnName("is_resolved");
|
||||
entity.Property(e => e.Provenance).HasColumnName("provenance");
|
||||
});
|
||||
|
||||
// ── entrypoints ─────────────────────────────────────────────────
|
||||
modelBuilder.Entity<Entrypoint>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("entrypoints_pkey");
|
||||
entity.ToTable("entrypoints", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.ScanId, e.NodeId, e.Kind })
|
||||
.HasName("entrypoints_scan_node_unique");
|
||||
|
||||
entity.HasIndex(e => e.ScanId, "idx_entrypoints_scan");
|
||||
entity.HasIndex(e => e.Kind, "idx_entrypoints_kind");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.UseIdentityByDefaultColumn()
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.ScanId).HasColumnName("scan_id");
|
||||
entity.Property(e => e.NodeId).HasColumnName("node_id");
|
||||
entity.Property(e => e.Kind).HasColumnName("kind");
|
||||
entity.Property(e => e.Framework).HasColumnName("framework");
|
||||
entity.Property(e => e.Route).HasColumnName("route");
|
||||
entity.Property(e => e.HttpMethod).HasColumnName("http_method");
|
||||
entity.Property(e => e.Phase).HasDefaultValueSql("'runtime'").HasColumnName("phase");
|
||||
entity.Property(e => e.OrderIdx).HasDefaultValue(0).HasColumnName("order_idx");
|
||||
});
|
||||
|
||||
// ── symbol_component_map ────────────────────────────────────────
|
||||
modelBuilder.Entity<SymbolComponentMap>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("symbol_component_map_pkey");
|
||||
entity.ToTable("symbol_component_map", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.ScanId, e.NodeId, e.Purl })
|
||||
.HasName("symbol_component_map_unique");
|
||||
|
||||
entity.HasIndex(e => e.ScanId, "idx_symbol_component_scan");
|
||||
entity.HasIndex(e => e.Purl, "idx_symbol_component_purl");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.UseIdentityByDefaultColumn()
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.ScanId).HasColumnName("scan_id");
|
||||
entity.Property(e => e.NodeId).HasColumnName("node_id");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.MappingKind).HasColumnName("mapping_kind");
|
||||
entity.Property(e => e.Confidence).HasDefaultValue(1.0f).HasColumnName("confidence");
|
||||
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
|
||||
});
|
||||
|
||||
// ── reachability_components ─────────────────────────────────────
|
||||
modelBuilder.Entity<ReachabilityComponent>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("reachability_components_pkey");
|
||||
entity.ToTable("reachability_components", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.ScanId, e.Purl })
|
||||
.HasName("reachability_components_unique");
|
||||
|
||||
entity.HasIndex(e => e.ScanId, "idx_reachability_components_scan");
|
||||
entity.HasIndex(e => e.Purl, "idx_reachability_components_purl");
|
||||
entity.HasIndex(e => e.Status, "idx_reachability_components_status");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.UseIdentityByDefaultColumn()
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.ScanId).HasColumnName("scan_id");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.Status).HasDefaultValue((short)0).HasColumnName("status");
|
||||
entity.Property(e => e.LatticeState).HasColumnName("lattice_state");
|
||||
entity.Property(e => e.Confidence).HasDefaultValue(0f).HasColumnName("confidence");
|
||||
entity.Property(e => e.Why).HasColumnType("jsonb").HasColumnName("why");
|
||||
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
|
||||
entity.Property(e => e.ComputedAt).HasDefaultValueSql("now()").HasColumnName("computed_at");
|
||||
});
|
||||
|
||||
// ── reachability_findings ───────────────────────────────────────
|
||||
modelBuilder.Entity<ReachabilityFinding>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("reachability_findings_pkey");
|
||||
entity.ToTable("reachability_findings", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.ScanId, e.CveId, e.Purl })
|
||||
.HasName("reachability_findings_unique");
|
||||
|
||||
entity.HasIndex(e => e.ScanId, "idx_reachability_findings_scan");
|
||||
entity.HasIndex(e => e.CveId, "idx_reachability_findings_cve");
|
||||
entity.HasIndex(e => e.Purl, "idx_reachability_findings_purl");
|
||||
entity.HasIndex(e => e.Status, "idx_reachability_findings_status");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.UseIdentityByDefaultColumn()
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.ScanId).HasColumnName("scan_id");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.Status).HasDefaultValue((short)0).HasColumnName("status");
|
||||
entity.Property(e => e.LatticeState).HasColumnName("lattice_state");
|
||||
entity.Property(e => e.Confidence).HasDefaultValue(0f).HasColumnName("confidence");
|
||||
entity.Property(e => e.PathWitness).HasColumnName("path_witness");
|
||||
entity.Property(e => e.Why).HasColumnType("jsonb").HasColumnName("why");
|
||||
entity.Property(e => e.Evidence).HasColumnType("jsonb").HasColumnName("evidence");
|
||||
entity.Property(e => e.SpineId).HasColumnName("spine_id");
|
||||
entity.Property(e => e.ComputedAt).HasDefaultValueSql("now()").HasColumnName("computed_at");
|
||||
});
|
||||
|
||||
// ── runtime_agents ──────────────────────────────────────────────
|
||||
modelBuilder.Entity<SignalsRuntimeAgent>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.AgentId).HasName("runtime_agents_pkey");
|
||||
entity.ToTable("runtime_agents", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_runtime_agents_tenant");
|
||||
entity.HasIndex(e => e.ArtifactDigest, "idx_runtime_agents_artifact");
|
||||
entity.HasIndex(e => e.LastHeartbeatAt, "idx_runtime_agents_heartbeat");
|
||||
entity.HasIndex(e => e.State, "idx_runtime_agents_state");
|
||||
|
||||
entity.Property(e => e.AgentId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("agent_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
|
||||
entity.Property(e => e.Platform).HasColumnName("platform");
|
||||
entity.Property(e => e.Posture).HasDefaultValueSql("'sampled'").HasColumnName("posture");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.RegisteredAt).HasDefaultValueSql("now()").HasColumnName("registered_at");
|
||||
entity.Property(e => e.LastHeartbeatAt).HasColumnName("last_heartbeat_at");
|
||||
entity.Property(e => e.State).HasDefaultValueSql("'registered'").HasColumnName("state");
|
||||
entity.Property(e => e.Statistics).HasColumnType("jsonb").HasColumnName("statistics");
|
||||
entity.Property(e => e.Version).HasColumnName("version");
|
||||
entity.Property(e => e.Hostname).HasColumnName("hostname");
|
||||
entity.Property(e => e.ContainerId).HasColumnName("container_id");
|
||||
entity.Property(e => e.PodName).HasColumnName("pod_name");
|
||||
entity.Property(e => e.Namespace).HasColumnName("namespace");
|
||||
});
|
||||
|
||||
// ── runtime_facts ───────────────────────────────────────────────
|
||||
modelBuilder.Entity<RuntimeFact>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("runtime_facts_pkey");
|
||||
entity.ToTable("runtime_facts", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => new { e.TenantId, e.ArtifactDigest, e.CanonicalSymbolId })
|
||||
.HasName("runtime_facts_unique");
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_runtime_facts_tenant");
|
||||
entity.HasIndex(e => e.CanonicalSymbolId, "idx_runtime_facts_symbol");
|
||||
entity.HasIndex(e => e.LastSeen, "idx_runtime_facts_last_seen")
|
||||
.IsDescending(true);
|
||||
entity.HasIndex(e => e.HitCount, "idx_runtime_facts_hit_count")
|
||||
.IsDescending(true);
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
|
||||
entity.Property(e => e.CanonicalSymbolId).HasColumnName("canonical_symbol_id");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.HitCount).HasDefaultValue(0L).HasColumnName("hit_count");
|
||||
entity.Property(e => e.FirstSeen).HasColumnName("first_seen");
|
||||
entity.Property(e => e.LastSeen).HasColumnName("last_seen");
|
||||
entity.Property(e => e.Contexts)
|
||||
.HasDefaultValueSql("'[]'::jsonb")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("contexts");
|
||||
entity.Property(e => e.AgentIds).HasColumnName("agent_ids");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <see cref="SignalsDbContext"/>.
|
||||
/// Used by <c>dotnet ef</c> CLI tooling (scaffold, optimize).
|
||||
/// </summary>
|
||||
public sealed class SignalsDesignTimeDbContextFactory : IDesignTimeDbContextFactory<SignalsDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=signals,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_SIGNALS_EF_CONNECTION";
|
||||
|
||||
public SignalsDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<SignalsDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new SignalsDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.call_edges table.
|
||||
/// </summary>
|
||||
public partial class CallEdge
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string GraphHash { get; set; } = null!;
|
||||
public string SourceId { get; set; } = null!;
|
||||
public string TargetId { get; set; } = null!;
|
||||
public string Type { get; set; } = null!;
|
||||
public string? Purl { get; set; }
|
||||
public string? SymbolDigest { get; set; }
|
||||
public string? Candidates { get; set; }
|
||||
public double? Confidence { get; set; }
|
||||
public string? Evidence { get; set; }
|
||||
public DateTimeOffset IngestedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.callgraphs table.
|
||||
/// </summary>
|
||||
public partial class Callgraph
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Language { get; set; } = null!;
|
||||
public string Component { get; set; } = null!;
|
||||
public string Version { get; set; } = null!;
|
||||
public string GraphHash { get; set; } = null!;
|
||||
public DateTimeOffset IngestedAt { get; set; }
|
||||
public string DocumentJson { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.cg_edges table.
|
||||
/// </summary>
|
||||
public partial class CgEdge
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid ScanId { get; set; }
|
||||
public string FromNodeId { get; set; } = null!;
|
||||
public string ToNodeId { get; set; } = null!;
|
||||
public short Kind { get; set; }
|
||||
public short Reason { get; set; }
|
||||
public float Weight { get; set; }
|
||||
public int? OffsetBytes { get; set; }
|
||||
public bool IsResolved { get; set; }
|
||||
public string? Provenance { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.cg_nodes table.
|
||||
/// </summary>
|
||||
public partial class CgNode
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid ScanId { get; set; }
|
||||
public string NodeId { get; set; } = null!;
|
||||
public string? ArtifactKey { get; set; }
|
||||
public string SymbolKey { get; set; } = null!;
|
||||
public string Visibility { get; set; } = null!;
|
||||
public bool IsEntrypointCandidate { get; set; }
|
||||
public string? Purl { get; set; }
|
||||
public string? SymbolDigest { get; set; }
|
||||
public int Flags { get; set; }
|
||||
public string? Attributes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.cve_func_hits table.
|
||||
/// </summary>
|
||||
public partial class CveFuncHit
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string SubjectKey { get; set; } = null!;
|
||||
public string CveId { get; set; } = null!;
|
||||
public string GraphHash { get; set; } = null!;
|
||||
public string? Purl { get; set; }
|
||||
public string? SymbolDigest { get; set; }
|
||||
public bool Reachable { get; set; }
|
||||
public double? Confidence { get; set; }
|
||||
public string? LatticeState { get; set; }
|
||||
public string? EvidenceUris { get; set; }
|
||||
public DateTimeOffset ComputedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.deploy_refs table.
|
||||
/// </summary>
|
||||
public partial class DeployRef
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Purl { get; set; } = null!;
|
||||
public string? PurlVersion { get; set; }
|
||||
public string ImageId { get; set; } = null!;
|
||||
public string? ImageDigest { get; set; }
|
||||
public string Environment { get; set; } = null!;
|
||||
public string? Namespace { get; set; }
|
||||
public string? Cluster { get; set; }
|
||||
public string? Region { get; set; }
|
||||
public DateTimeOffset FirstSeenAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.entrypoints table.
|
||||
/// </summary>
|
||||
public partial class Entrypoint
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid ScanId { get; set; }
|
||||
public string NodeId { get; set; } = null!;
|
||||
public string Kind { get; set; } = null!;
|
||||
public string? Framework { get; set; }
|
||||
public string? Route { get; set; }
|
||||
public string? HttpMethod { get; set; }
|
||||
public string Phase { get; set; } = null!;
|
||||
public int OrderIdx { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.func_nodes table.
|
||||
/// </summary>
|
||||
public partial class FuncNode
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string GraphHash { get; set; } = null!;
|
||||
public string SymbolId { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public string Kind { get; set; } = null!;
|
||||
public string? Namespace { get; set; }
|
||||
public string? File { get; set; }
|
||||
public int? Line { get; set; }
|
||||
public string? Purl { get; set; }
|
||||
public string? SymbolDigest { get; set; }
|
||||
public string? BuildId { get; set; }
|
||||
public string? CodeId { get; set; }
|
||||
public string? Language { get; set; }
|
||||
public string? Evidence { get; set; }
|
||||
public string? Analyzer { get; set; }
|
||||
public DateTimeOffset IngestedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.graph_metrics table.
|
||||
/// </summary>
|
||||
public partial class GraphMetric
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string NodeId { get; set; } = null!;
|
||||
public string CallgraphId { get; set; } = null!;
|
||||
public string NodeType { get; set; } = null!;
|
||||
public int DegreeCentrality { get; set; }
|
||||
public int InDegree { get; set; }
|
||||
public int OutDegree { get; set; }
|
||||
public double BetweennessCentrality { get; set; }
|
||||
public double? ClosenessCentrality { get; set; }
|
||||
public double? EigenvectorCentrality { get; set; }
|
||||
public double? NormalizedBetweenness { get; set; }
|
||||
public double? NormalizedDegree { get; set; }
|
||||
public DateTimeOffset ComputedAt { get; set; }
|
||||
public int? ComputationDurationMs { get; set; }
|
||||
public string AlgorithmVersion { get; set; } = null!;
|
||||
public int? TotalNodes { get; set; }
|
||||
public int? TotalEdges { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.reachability_components table.
|
||||
/// </summary>
|
||||
public partial class ReachabilityComponent
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid ScanId { get; set; }
|
||||
public string Purl { get; set; } = null!;
|
||||
public short Status { get; set; }
|
||||
public string? LatticeState { get; set; }
|
||||
public float Confidence { get; set; }
|
||||
public string? Why { get; set; }
|
||||
public string? Evidence { get; set; }
|
||||
public DateTimeOffset ComputedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.reachability_facts table.
|
||||
/// </summary>
|
||||
public partial class ReachabilityFact
|
||||
{
|
||||
public string SubjectKey { get; set; } = null!;
|
||||
public string Id { get; set; } = null!;
|
||||
public string CallgraphId { get; set; } = null!;
|
||||
public double Score { get; set; }
|
||||
public double RiskScore { get; set; }
|
||||
public DateTimeOffset ComputedAt { get; set; }
|
||||
public string DocumentJson { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.reachability_findings table.
|
||||
/// </summary>
|
||||
public partial class ReachabilityFinding
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid ScanId { get; set; }
|
||||
public string CveId { get; set; } = null!;
|
||||
public string Purl { get; set; } = null!;
|
||||
public short Status { get; set; }
|
||||
public string? LatticeState { get; set; }
|
||||
public float Confidence { get; set; }
|
||||
public string[]? PathWitness { get; set; }
|
||||
public string? Why { get; set; }
|
||||
public string? Evidence { get; set; }
|
||||
public Guid? SpineId { get; set; }
|
||||
public DateTimeOffset ComputedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.runtime_agents table.
|
||||
/// </summary>
|
||||
public partial class SignalsRuntimeAgent
|
||||
{
|
||||
public Guid AgentId { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public string ArtifactDigest { get; set; } = null!;
|
||||
public string Platform { get; set; } = null!;
|
||||
public string Posture { get; set; } = null!;
|
||||
public string? Metadata { get; set; }
|
||||
public DateTimeOffset RegisteredAt { get; set; }
|
||||
public DateTimeOffset? LastHeartbeatAt { get; set; }
|
||||
public string State { get; set; } = null!;
|
||||
public string? Statistics { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Hostname { get; set; }
|
||||
public string? ContainerId { get; set; }
|
||||
public string? PodName { get; set; }
|
||||
public string? Namespace { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.runtime_facts table.
|
||||
/// </summary>
|
||||
public partial class RuntimeFact
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public string ArtifactDigest { get; set; } = null!;
|
||||
public string CanonicalSymbolId { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public long HitCount { get; set; }
|
||||
public DateTimeOffset FirstSeen { get; set; }
|
||||
public DateTimeOffset LastSeen { get; set; }
|
||||
public string Contexts { get; set; } = null!;
|
||||
public Guid[] AgentIds { get; set; } = [];
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.scans table.
|
||||
/// </summary>
|
||||
public partial class Scan
|
||||
{
|
||||
public Guid ScanId { get; set; }
|
||||
public string ArtifactDigest { get; set; } = null!;
|
||||
public string? RepoUri { get; set; }
|
||||
public string? CommitSha { get; set; }
|
||||
public string? SbomDigest { get; set; }
|
||||
public string? PolicyDigest { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.symbol_component_map table.
|
||||
/// </summary>
|
||||
public partial class SymbolComponentMap
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public Guid ScanId { get; set; }
|
||||
public string NodeId { get; set; } = null!;
|
||||
public string Purl { get; set; } = null!;
|
||||
public string MappingKind { get; set; } = null!;
|
||||
public float Confidence { get; set; }
|
||||
public string? Evidence { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace StellaOps.Signals.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for signals.unknowns table.
|
||||
/// </summary>
|
||||
public partial class Unknown
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string SubjectKey { get; set; } = null!;
|
||||
public string? CallgraphId { get; set; }
|
||||
public string? SymbolId { get; set; }
|
||||
public string? CodeId { get; set; }
|
||||
public string? Purl { get; set; }
|
||||
public string? PurlVersion { get; set; }
|
||||
public string? EdgeFrom { get; set; }
|
||||
public string? EdgeTo { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
|
||||
// Scoring factors (range: 0.0 - 1.0)
|
||||
public double PopularityP { get; set; }
|
||||
public int DeploymentCount { get; set; }
|
||||
public double ExploitPotentialE { get; set; }
|
||||
public double UncertaintyU { get; set; }
|
||||
public double CentralityC { get; set; }
|
||||
public int DegreeCentrality { get; set; }
|
||||
public double BetweennessCentrality { get; set; }
|
||||
public double StalenessS { get; set; }
|
||||
public int DaysSinceAnalysis { get; set; }
|
||||
|
||||
// Composite score and band
|
||||
public double Score { get; set; }
|
||||
public string Band { get; set; } = null!;
|
||||
|
||||
// JSONB columns
|
||||
public string UnknownFlags { get; set; } = null!;
|
||||
public string? NormalizationTrace { get; set; }
|
||||
|
||||
// Rescan scheduling
|
||||
public int RescanAttempts { get; set; }
|
||||
public string? LastRescanResult { get; set; }
|
||||
public DateTimeOffset? NextScheduledRescan { get; set; }
|
||||
public DateTimeOffset? LastAnalyzedAt { get; set; }
|
||||
|
||||
// Graph slice reference
|
||||
public byte[]? GraphSliceHash { get; set; }
|
||||
public byte[]? EvidenceSetHash { get; set; }
|
||||
public byte[]? CallgraphAttemptHash { get; set; }
|
||||
|
||||
// Timestamps
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
using StellaOps.Signals.Persistence.Postgres;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -18,6 +21,8 @@ namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ICallGraphProjectionRepository"/>.
|
||||
/// Projects callgraph documents into relational tables for efficient querying.
|
||||
/// Uses EF Core for simple operations; retains raw SQL for batch upserts and
|
||||
/// parameterized multi-row inserts.
|
||||
/// </summary>
|
||||
public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<SignalsDataSource>, ICallGraphProjectionRepository
|
||||
{
|
||||
@@ -45,6 +50,7 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
|
||||
string? commitSha = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL for RETURNING (xmax = 0) which EF cannot express
|
||||
const string sql = """
|
||||
INSERT INTO signals.scans (scan_id, artifact_digest, sbom_digest, repo_uri, commit_sha, status, created_at)
|
||||
VALUES (@scan_id, @artifact_digest, @sbom_digest, @repo_uri, @commit_sha, 'processing', NOW())
|
||||
@@ -74,34 +80,34 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
|
||||
/// <inheritdoc />
|
||||
public async Task CompleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE signals.scans
|
||||
SET status = 'completed', completed_at = NOW()
|
||||
WHERE scan_id = @scan_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@scan_id", scanId);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
WHERE scan_id = {0}
|
||||
""",
|
||||
scanId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task FailScanAsync(Guid scanId, string errorMessage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE signals.scans
|
||||
SET status = 'failed', error_message = @error_message, completed_at = NOW()
|
||||
WHERE scan_id = @scan_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@scan_id", scanId);
|
||||
AddParameter(command, "@error_message", errorMessage);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE signals.scans
|
||||
SET status = 'failed', error_message = {1}, completed_at = NOW()
|
||||
WHERE scan_id = {0}
|
||||
""",
|
||||
scanId,
|
||||
errorMessage,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -125,7 +131,7 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
|
||||
|
||||
try
|
||||
{
|
||||
// Process in batches
|
||||
// Process in batches - keep raw SQL for parameterized multi-row VALUES inserts
|
||||
for (var i = 0; i < sortedNodes.Count; i += BatchSize)
|
||||
{
|
||||
var batch = sortedNodes.Skip(i).Take(BatchSize).ToList();
|
||||
@@ -222,7 +228,7 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
|
||||
|
||||
try
|
||||
{
|
||||
// Process in batches
|
||||
// Process in batches - keep raw SQL for parameterized multi-row VALUES inserts
|
||||
for (var i = 0; i < sortedEdges.Count; i += BatchSize)
|
||||
{
|
||||
var batch = sortedEdges.Skip(i).Take(BatchSize).ToList();
|
||||
@@ -353,14 +359,14 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteScanAsync(Guid scanId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Delete from scans cascades to all related tables via FK
|
||||
const string sql = "DELETE FROM signals.scans WHERE scan_id = @scan_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@scan_id", scanId);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Delete from scans cascades to all related tables via FK
|
||||
await dbContext.Scans
|
||||
.Where(e => e.ScanId == scanId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ===== HELPER METHODS =====
|
||||
@@ -464,4 +470,6 @@ public sealed class PostgresCallGraphProjectionRepository : RepositoryBase<Signa
|
||||
_ => "runtime"
|
||||
};
|
||||
}
|
||||
|
||||
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
using StellaOps.Signals.Persistence.Postgres;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -12,6 +14,8 @@ namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
/// <summary>
|
||||
/// Repository for querying call graph data across scans.
|
||||
/// Optimized for analytics and cross-artifact queries.
|
||||
/// Uses EF Core DbContext for connection management; retains raw SQL for
|
||||
/// complex CTEs and recursive queries that cannot be expressed in LINQ.
|
||||
/// </summary>
|
||||
public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDataSource>, ICallGraphQueryRepository
|
||||
{
|
||||
@@ -24,6 +28,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
|
||||
|
||||
/// <summary>
|
||||
/// Finds all paths to a CVE across all scans.
|
||||
/// Retains raw SQL: multi-CTE with JOINs across four tables cannot be expressed in LINQ.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<CvePath>> FindPathsToCveAsync(
|
||||
string cveId,
|
||||
@@ -83,6 +88,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
|
||||
|
||||
/// <summary>
|
||||
/// Gets symbols reachable from an entrypoint.
|
||||
/// Retains raw SQL: recursive CTE with cycle detection cannot be expressed in LINQ.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> GetReachableSymbolsAsync(
|
||||
Guid scanId,
|
||||
@@ -137,6 +143,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
|
||||
|
||||
/// <summary>
|
||||
/// Gets graph statistics for a scan.
|
||||
/// Retains raw SQL: multiple sub-selects across different tables with conditional counts.
|
||||
/// </summary>
|
||||
public async Task<CallGraphStats> GetStatsAsync(
|
||||
Guid scanId,
|
||||
@@ -175,6 +182,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
|
||||
|
||||
/// <summary>
|
||||
/// Finds common paths between two symbols.
|
||||
/// Retains raw SQL: recursive CTE with cycle detection and path accumulation.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string[]>> FindPathsBetweenAsync(
|
||||
Guid scanId,
|
||||
@@ -238,6 +246,7 @@ public sealed class PostgresCallGraphQueryRepository : RepositoryBase<SignalsDat
|
||||
|
||||
/// <summary>
|
||||
/// Searches nodes by symbol key pattern.
|
||||
/// Retains raw SQL: ILIKE pattern matching with correlated sub-queries for edge counts.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<CallGraphNodeSummary>> SearchNodesAsync(
|
||||
Guid scanId,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ICallgraphRepository"/>.
|
||||
/// Uses EF Core for persistence operations.
|
||||
/// </summary>
|
||||
public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSource>, ICallgraphRepository
|
||||
{
|
||||
@@ -37,9 +40,16 @@ public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSour
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
var documentJson = JsonSerializer.Serialize(document, JsonOptions);
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for UPSERT with ON CONFLICT since the callgraphs table uses a single-column PK upsert
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO signals.callgraphs (id, language, component, version, graph_hash, ingested_at, document_json)
|
||||
VALUES (@id, @language, @component, @version, @graph_hash, @ingested_at, @document_json)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}::jsonb)
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET
|
||||
language = EXCLUDED.language,
|
||||
@@ -48,22 +58,15 @@ public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSour
|
||||
graph_hash = EXCLUDED.graph_hash,
|
||||
ingested_at = EXCLUDED.ingested_at,
|
||||
document_json = EXCLUDED.document_json
|
||||
RETURNING id";
|
||||
|
||||
var documentJson = JsonSerializer.Serialize(document, JsonOptions);
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "@id", document.Id);
|
||||
AddParameter(command, "@language", document.Language ?? string.Empty);
|
||||
AddParameter(command, "@component", document.Component ?? string.Empty);
|
||||
AddParameter(command, "@version", document.Version ?? string.Empty);
|
||||
AddParameter(command, "@graph_hash", document.GraphHash ?? string.Empty);
|
||||
AddParameter(command, "@ingested_at", document.IngestedAt);
|
||||
AddJsonbParameter(command, "@document_json", documentJson);
|
||||
|
||||
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
document.Id,
|
||||
document.Language ?? string.Empty,
|
||||
document.Component ?? string.Empty,
|
||||
document.Version ?? string.Empty,
|
||||
document.GraphHash ?? string.Empty,
|
||||
document.IngestedAt,
|
||||
documentJson,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return document;
|
||||
}
|
||||
@@ -77,23 +80,21 @@ public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSour
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT document_json
|
||||
FROM signals.callgraphs
|
||||
WHERE id = @id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@id", id.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
var entity = await dbContext.Callgraphs
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Id == id.Trim())
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var documentJson = reader.GetString(0);
|
||||
return JsonSerializer.Deserialize<CallgraphDocument>(documentJson, JsonOptions);
|
||||
return JsonSerializer.Deserialize<CallgraphDocument>(entity.DocumentJson, JsonOptions);
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
@@ -126,4 +127,6 @@ public sealed class PostgresCallgraphRepository : RepositoryBase<SignalsDataSour
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
using StellaOps.Signals.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IDeploymentRefsRepository"/>.
|
||||
/// Tracks package deployments for popularity scoring (P factor).
|
||||
/// Uses EF Core for read operations; preserves raw SQL for UPSERT patterns.
|
||||
/// </summary>
|
||||
public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDataSource>, IDeploymentRefsRepository
|
||||
{
|
||||
@@ -24,6 +29,7 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Keep raw SQL for COUNT(DISTINCT ...) with date-interval filter that EF doesn't translate cleanly
|
||||
const string sql = @"
|
||||
SELECT COUNT(DISTINCT image_id)
|
||||
FROM signals.deploy_refs
|
||||
@@ -45,6 +51,7 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Keep raw SQL for DISTINCT with date-interval filter
|
||||
const string sql = @"
|
||||
SELECT DISTINCT image_id
|
||||
FROM signals.deploy_refs
|
||||
@@ -75,14 +82,19 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Keep raw SQL for UPSERT with ON CONFLICT and COALESCE/NOW() expressions
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO signals.deploy_refs (
|
||||
purl, purl_version, image_id, image_digest,
|
||||
environment, namespace, cluster, region,
|
||||
first_seen_at, last_seen_at
|
||||
) VALUES (
|
||||
@purl, @purl_version, @image_id, @image_digest,
|
||||
@environment, @namespace, @cluster, @region,
|
||||
{0}, {1}, {2}, {3},
|
||||
{4}, {5}, {6}, {7},
|
||||
NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (purl, image_id, environment)
|
||||
@@ -92,21 +104,17 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
|
||||
namespace = COALESCE(EXCLUDED.namespace, signals.deploy_refs.namespace),
|
||||
cluster = COALESCE(EXCLUDED.cluster, signals.deploy_refs.cluster),
|
||||
region = COALESCE(EXCLUDED.region, signals.deploy_refs.region),
|
||||
last_seen_at = NOW()";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "@purl", deployment.Purl.Trim());
|
||||
AddParameter(command, "@purl_version", (object?)deployment.PurlVersion ?? DBNull.Value);
|
||||
AddParameter(command, "@image_id", deployment.ImageId.Trim());
|
||||
AddParameter(command, "@image_digest", (object?)deployment.ImageDigest ?? DBNull.Value);
|
||||
AddParameter(command, "@environment", deployment.Environment.Trim());
|
||||
AddParameter(command, "@namespace", (object?)deployment.Namespace ?? DBNull.Value);
|
||||
AddParameter(command, "@cluster", (object?)deployment.Cluster ?? DBNull.Value);
|
||||
AddParameter(command, "@region", (object?)deployment.Region ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
last_seen_at = NOW()
|
||||
""",
|
||||
deployment.Purl.Trim(),
|
||||
(object?)deployment.PurlVersion ?? DBNull.Value,
|
||||
deployment.ImageId.Trim(),
|
||||
(object?)deployment.ImageDigest ?? DBNull.Value,
|
||||
deployment.Environment.Trim(),
|
||||
(object?)deployment.Namespace ?? DBNull.Value,
|
||||
(object?)deployment.Cluster ?? DBNull.Value,
|
||||
(object?)deployment.Region ?? DBNull.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task BulkUpsertAsync(IEnumerable<DeploymentRef> deployments, CancellationToken cancellationToken = default)
|
||||
@@ -144,7 +152,7 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
|
||||
if (deployment is null)
|
||||
continue;
|
||||
|
||||
await using var command = CreateCommand(sql, connection, transaction);
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
|
||||
AddParameter(command, "@purl", deployment.Purl.Trim());
|
||||
AddParameter(command, "@purl_version", (object?)deployment.PurlVersion ?? DBNull.Value);
|
||||
@@ -174,6 +182,7 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Keep raw SQL for aggregate functions with date-interval filter and GROUP BY
|
||||
const string sql = @"
|
||||
SELECT
|
||||
purl,
|
||||
@@ -207,11 +216,6 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
|
||||
};
|
||||
}
|
||||
|
||||
private static Npgsql.NpgsqlCommand CreateCommand(string sql, Npgsql.NpgsqlConnection connection, Npgsql.NpgsqlTransaction transaction)
|
||||
{
|
||||
return new Npgsql.NpgsqlCommand(sql, connection, transaction);
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
@@ -246,4 +250,6 @@ public sealed class PostgresDeploymentRefsRepository : RepositoryBase<SignalsDat
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
using StellaOps.Signals.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IGraphMetricsRepository"/>.
|
||||
/// Stores computed centrality metrics for call graph nodes (C factor).
|
||||
/// Uses EF Core for read operations; preserves raw SQL for UPSERT patterns.
|
||||
/// </summary>
|
||||
public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataSource>, IGraphMetricsRepository
|
||||
{
|
||||
@@ -27,28 +32,22 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT
|
||||
node_id, callgraph_id, node_type,
|
||||
degree_centrality, in_degree, out_degree,
|
||||
betweenness_centrality, closeness_centrality,
|
||||
normalized_betweenness, normalized_degree,
|
||||
computed_at, computation_duration_ms, algorithm_version,
|
||||
total_nodes, total_edges
|
||||
FROM signals.graph_metrics
|
||||
WHERE node_id = @node_id AND callgraph_id = @callgraph_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@node_id", symbolId.Trim());
|
||||
AddParameter(command, "@callgraph_id", callgraphId.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var normalizedNodeId = symbolId.Trim();
|
||||
var normalizedCallgraphId = callgraphId.Trim();
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
var entity = await dbContext.GraphMetrics
|
||||
.AsNoTracking()
|
||||
.Where(e => e.NodeId == normalizedNodeId && e.CallgraphId == normalizedCallgraphId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
return null;
|
||||
|
||||
return MapMetrics(reader);
|
||||
return MapEntityToModel(entity);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(GraphMetrics metrics, CancellationToken cancellationToken = default)
|
||||
@@ -57,7 +56,12 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Keep raw SQL for UPSERT with ON CONFLICT on composite unique constraint
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO signals.graph_metrics (
|
||||
node_id, callgraph_id, node_type,
|
||||
degree_centrality, in_degree, out_degree,
|
||||
@@ -66,12 +70,12 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
computed_at, computation_duration_ms, algorithm_version,
|
||||
total_nodes, total_edges
|
||||
) VALUES (
|
||||
@node_id, @callgraph_id, @node_type,
|
||||
@degree_centrality, @in_degree, @out_degree,
|
||||
@betweenness_centrality, @closeness_centrality,
|
||||
@normalized_betweenness, @normalized_degree,
|
||||
@computed_at, @computation_duration_ms, @algorithm_version,
|
||||
@total_nodes, @total_edges
|
||||
{0}, {1}, {2},
|
||||
{3}, {4}, {5},
|
||||
{6}, {7},
|
||||
{8}, {9},
|
||||
{10}, {11}, {12},
|
||||
{13}, {14}
|
||||
)
|
||||
ON CONFLICT (node_id, callgraph_id)
|
||||
DO UPDATE SET
|
||||
@@ -87,14 +91,24 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
computation_duration_ms = EXCLUDED.computation_duration_ms,
|
||||
algorithm_version = EXCLUDED.algorithm_version,
|
||||
total_nodes = EXCLUDED.total_nodes,
|
||||
total_edges = EXCLUDED.total_edges";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddMetricsParameters(command, metrics);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
total_edges = EXCLUDED.total_edges
|
||||
""",
|
||||
metrics.NodeId.Trim(),
|
||||
metrics.CallgraphId.Trim(),
|
||||
metrics.NodeType,
|
||||
metrics.Degree,
|
||||
metrics.InDegree,
|
||||
metrics.OutDegree,
|
||||
metrics.Betweenness,
|
||||
(object?)metrics.Closeness ?? DBNull.Value,
|
||||
(object?)metrics.NormalizedBetweenness ?? DBNull.Value,
|
||||
(object?)metrics.NormalizedDegree ?? DBNull.Value,
|
||||
metrics.ComputedAt == default ? DateTimeOffset.UtcNow : metrics.ComputedAt,
|
||||
(object?)metrics.ComputationDurationMs ?? DBNull.Value,
|
||||
metrics.AlgorithmVersion,
|
||||
(object?)metrics.TotalNodes ?? DBNull.Value,
|
||||
(object?)metrics.TotalEdges ?? DBNull.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task BulkUpsertAsync(IEnumerable<GraphMetrics> metrics, CancellationToken cancellationToken = default)
|
||||
@@ -145,7 +159,7 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
if (m is null)
|
||||
continue;
|
||||
|
||||
await using var command = CreateCommand(sql, connection, transaction);
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
AddMetricsParameters(command, m);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -166,29 +180,20 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT DISTINCT callgraph_id
|
||||
FROM signals.graph_metrics
|
||||
WHERE computed_at < @cutoff
|
||||
ORDER BY callgraph_id
|
||||
LIMIT @limit";
|
||||
|
||||
var cutoff = DateTimeOffset.UtcNow - maxAge;
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@cutoff", cutoff);
|
||||
AddParameter(command, "@limit", limit);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<string>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return results;
|
||||
return await dbContext.GraphMetrics
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ComputedAt < cutoff)
|
||||
.Select(e => e.CallgraphId)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteByCallgraphAsync(string callgraphId, CancellationToken cancellationToken = default)
|
||||
@@ -198,16 +203,18 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = "DELETE FROM signals.graph_metrics WHERE callgraph_id = @callgraph_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@callgraph_id", callgraphId.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var normalizedId = callgraphId.Trim();
|
||||
|
||||
await dbContext.GraphMetrics
|
||||
.Where(e => e.CallgraphId == normalizedId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void AddMetricsParameters(Npgsql.NpgsqlCommand command, GraphMetrics metrics)
|
||||
private void AddMetricsParameters(NpgsqlCommand command, GraphMetrics metrics)
|
||||
{
|
||||
AddParameter(command, "@node_id", metrics.NodeId.Trim());
|
||||
AddParameter(command, "@callgraph_id", metrics.CallgraphId.Trim());
|
||||
@@ -226,30 +233,25 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
AddParameter(command, "@total_edges", metrics.TotalEdges.HasValue ? metrics.TotalEdges.Value : DBNull.Value);
|
||||
}
|
||||
|
||||
private static Npgsql.NpgsqlCommand CreateCommand(string sql, Npgsql.NpgsqlConnection connection, Npgsql.NpgsqlTransaction transaction)
|
||||
{
|
||||
return new Npgsql.NpgsqlCommand(sql, connection, transaction);
|
||||
}
|
||||
|
||||
private static GraphMetrics MapMetrics(Npgsql.NpgsqlDataReader reader)
|
||||
private static GraphMetrics MapEntityToModel(EfCore.Models.GraphMetric entity)
|
||||
{
|
||||
return new GraphMetrics
|
||||
{
|
||||
NodeId = reader.GetString(0),
|
||||
CallgraphId = reader.GetString(1),
|
||||
NodeType = reader.IsDBNull(2) ? "symbol" : reader.GetString(2),
|
||||
Degree = reader.IsDBNull(3) ? 0 : reader.GetInt32(3),
|
||||
InDegree = reader.IsDBNull(4) ? 0 : reader.GetInt32(4),
|
||||
OutDegree = reader.IsDBNull(5) ? 0 : reader.GetInt32(5),
|
||||
Betweenness = reader.IsDBNull(6) ? 0.0 : reader.GetDouble(6),
|
||||
Closeness = reader.IsDBNull(7) ? null : reader.GetDouble(7),
|
||||
NormalizedBetweenness = reader.IsDBNull(8) ? null : reader.GetDouble(8),
|
||||
NormalizedDegree = reader.IsDBNull(9) ? null : reader.GetDouble(9),
|
||||
ComputedAt = reader.IsDBNull(10) ? DateTimeOffset.UtcNow : reader.GetFieldValue<DateTimeOffset>(10),
|
||||
ComputationDurationMs = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
AlgorithmVersion = reader.IsDBNull(12) ? "1.0" : reader.GetString(12),
|
||||
TotalNodes = reader.IsDBNull(13) ? null : reader.GetInt32(13),
|
||||
TotalEdges = reader.IsDBNull(14) ? null : reader.GetInt32(14)
|
||||
NodeId = entity.NodeId,
|
||||
CallgraphId = entity.CallgraphId,
|
||||
NodeType = entity.NodeType ?? "symbol",
|
||||
Degree = entity.DegreeCentrality,
|
||||
InDegree = entity.InDegree,
|
||||
OutDegree = entity.OutDegree,
|
||||
Betweenness = entity.BetweennessCentrality,
|
||||
Closeness = entity.ClosenessCentrality,
|
||||
NormalizedBetweenness = entity.NormalizedBetweenness,
|
||||
NormalizedDegree = entity.NormalizedDegree,
|
||||
ComputedAt = entity.ComputedAt,
|
||||
ComputationDurationMs = entity.ComputationDurationMs,
|
||||
AlgorithmVersion = entity.AlgorithmVersion ?? "1.0",
|
||||
TotalNodes = entity.TotalNodes,
|
||||
TotalEdges = entity.TotalEdges
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,4 +295,6 @@ public sealed class PostgresGraphMetricsRepository : RepositoryBase<SignalsDataS
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IReachabilityFactRepository"/>.
|
||||
/// Uses EF Core for persistence operations.
|
||||
/// </summary>
|
||||
public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsDataSource>, IReachabilityFactRepository
|
||||
{
|
||||
@@ -37,9 +40,15 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
var documentJson = JsonSerializer.Serialize(document, JsonOptions);
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO signals.reachability_facts (subject_key, id, callgraph_id, score, risk_score, computed_at, document_json)
|
||||
VALUES (@subject_key, @id, @callgraph_id, @score, @risk_score, @computed_at, @document_json)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}::jsonb)
|
||||
ON CONFLICT (subject_key)
|
||||
DO UPDATE SET
|
||||
id = EXCLUDED.id,
|
||||
@@ -48,22 +57,15 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
|
||||
risk_score = EXCLUDED.risk_score,
|
||||
computed_at = EXCLUDED.computed_at,
|
||||
document_json = EXCLUDED.document_json
|
||||
RETURNING subject_key";
|
||||
|
||||
var documentJson = JsonSerializer.Serialize(document, JsonOptions);
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "@subject_key", document.SubjectKey);
|
||||
AddParameter(command, "@id", document.Id);
|
||||
AddParameter(command, "@callgraph_id", document.CallgraphId ?? string.Empty);
|
||||
AddParameter(command, "@score", document.Score);
|
||||
AddParameter(command, "@risk_score", document.RiskScore);
|
||||
AddParameter(command, "@computed_at", document.ComputedAt);
|
||||
AddJsonbParameter(command, "@document_json", documentJson);
|
||||
|
||||
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
document.SubjectKey,
|
||||
document.Id,
|
||||
document.CallgraphId ?? string.Empty,
|
||||
document.Score,
|
||||
document.RiskScore,
|
||||
document.ComputedAt,
|
||||
documentJson,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return document;
|
||||
}
|
||||
@@ -77,48 +79,42 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT document_json
|
||||
FROM signals.reachability_facts
|
||||
WHERE subject_key = @subject_key";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@subject_key", subjectKey.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
var entity = await dbContext.ReachabilityFacts
|
||||
.AsNoTracking()
|
||||
.Where(e => e.SubjectKey == subjectKey.Trim())
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var documentJson = reader.GetString(0);
|
||||
return JsonSerializer.Deserialize<ReachabilityFactDocument>(documentJson, JsonOptions);
|
||||
return JsonSerializer.Deserialize<ReachabilityFactDocument>(entity.DocumentJson, JsonOptions);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset cutoff, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT document_json
|
||||
FROM signals.reachability_facts
|
||||
WHERE computed_at < @cutoff
|
||||
ORDER BY computed_at ASC
|
||||
LIMIT @limit";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@cutoff", cutoff);
|
||||
AddParameter(command, "@limit", limit);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var entities = await dbContext.ReachabilityFacts
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ComputedAt < cutoff)
|
||||
.OrderBy(e => e.ComputedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var results = new List<ReachabilityFactDocument>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var documentJson = reader.GetString(0);
|
||||
var document = JsonSerializer.Deserialize<ReachabilityFactDocument>(documentJson, JsonOptions);
|
||||
var document = JsonSerializer.Deserialize<ReachabilityFactDocument>(entity.DocumentJson, JsonOptions);
|
||||
if (document is not null)
|
||||
{
|
||||
results.Add(document);
|
||||
@@ -137,15 +133,14 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
DELETE FROM signals.reachability_facts
|
||||
WHERE subject_key = @subject_key";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@subject_key", subjectKey.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await dbContext.ReachabilityFacts
|
||||
.Where(e => e.SubjectKey == subjectKey.Trim())
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
@@ -158,6 +153,7 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Keep raw SQL for JSONB path extraction which EF doesn't translate well
|
||||
const string sql = @"
|
||||
SELECT COALESCE(jsonb_array_length(document_json->'runtimeFacts'), 0)
|
||||
FROM signals.reachability_facts
|
||||
@@ -232,4 +228,6 @@ public sealed class PostgresReachabilityFactRepository : RepositoryBase<SignalsD
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Models.ReachabilityStore;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
using StellaOps.Signals.Persistence.Postgres;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IReachabilityStoreRepository"/>.
|
||||
/// Uses EF Core for read operations; preserves raw SQL for complex UPSERT patterns.
|
||||
/// </summary>
|
||||
public sealed class PostgresReachabilityStoreRepository : RepositoryBase<SignalsDataSource>, IReachabilityStoreRepository
|
||||
{
|
||||
@@ -52,7 +56,7 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
|
||||
try
|
||||
{
|
||||
// Upsert func nodes
|
||||
// Upsert func nodes - keep raw SQL for complex ON CONFLICT with many columns
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var symbolId = node.Id?.Trim() ?? string.Empty;
|
||||
@@ -76,7 +80,7 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
analyzer = EXCLUDED.analyzer,
|
||||
ingested_at = EXCLUDED.ingested_at";
|
||||
|
||||
await using var nodeCommand = CreateCommand(nodeSql, connection, transaction);
|
||||
await using var nodeCommand = new NpgsqlCommand(nodeSql, connection, transaction);
|
||||
AddParameter(nodeCommand, "@id", id);
|
||||
AddParameter(nodeCommand, "@graph_hash", normalizedGraphHash);
|
||||
AddParameter(nodeCommand, "@symbol_id", symbolId);
|
||||
@@ -97,7 +101,7 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
await nodeCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Upsert call edges
|
||||
// Upsert call edges - keep raw SQL for complex ON CONFLICT
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var sourceId = edge.SourceId?.Trim() ?? string.Empty;
|
||||
@@ -116,7 +120,7 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
evidence = EXCLUDED.evidence,
|
||||
ingested_at = EXCLUDED.ingested_at";
|
||||
|
||||
await using var edgeCommand = CreateCommand(edgeSql, connection, transaction);
|
||||
await using var edgeCommand = new NpgsqlCommand(edgeSql, connection, transaction);
|
||||
AddParameter(edgeCommand, "@id", id);
|
||||
AddParameter(edgeCommand, "@graph_hash", normalizedGraphHash);
|
||||
AddParameter(edgeCommand, "@source_id", sourceId);
|
||||
@@ -150,25 +154,39 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, graph_hash, symbol_id, name, kind, namespace, file, line, purl, symbol_digest, build_id, code_id, language, evidence, analyzer, ingested_at
|
||||
FROM signals.func_nodes
|
||||
WHERE graph_hash = @graph_hash
|
||||
ORDER BY symbol_id, purl, symbol_digest";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@graph_hash", graphHash.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var normalizedHash = graphHash.Trim();
|
||||
|
||||
var results = new List<FuncNodeDocument>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
var entities = await dbContext.FuncNodes
|
||||
.AsNoTracking()
|
||||
.Where(e => e.GraphHash == normalizedHash)
|
||||
.OrderBy(e => e.SymbolId)
|
||||
.ThenBy(e => e.Purl)
|
||||
.ThenBy(e => e.SymbolDigest)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(e => new FuncNodeDocument
|
||||
{
|
||||
results.Add(MapFuncNode(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
Id = e.Id,
|
||||
GraphHash = e.GraphHash,
|
||||
SymbolId = e.SymbolId,
|
||||
Name = e.Name,
|
||||
Kind = e.Kind,
|
||||
Namespace = e.Namespace,
|
||||
File = e.File,
|
||||
Line = e.Line,
|
||||
Purl = e.Purl,
|
||||
SymbolDigest = e.SymbolDigest,
|
||||
BuildId = e.BuildId,
|
||||
CodeId = e.CodeId,
|
||||
Language = e.Language,
|
||||
Evidence = string.IsNullOrEmpty(e.Evidence) ? null : JsonSerializer.Deserialize<List<string>>(e.Evidence, JsonOptions),
|
||||
Analyzer = string.IsNullOrEmpty(e.Analyzer) ? null : JsonSerializer.Deserialize<Dictionary<string, string?>>(e.Analyzer, JsonOptions),
|
||||
IngestedAt = e.IngestedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CallEdgeDocument>> GetCallEdgesByGraphAsync(string graphHash, CancellationToken cancellationToken)
|
||||
@@ -180,25 +198,34 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, graph_hash, source_id, target_id, type, purl, symbol_digest, candidates, confidence, evidence, ingested_at
|
||||
FROM signals.call_edges
|
||||
WHERE graph_hash = @graph_hash
|
||||
ORDER BY source_id, target_id, type";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@graph_hash", graphHash.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var normalizedHash = graphHash.Trim();
|
||||
|
||||
var results = new List<CallEdgeDocument>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
var entities = await dbContext.CallEdges
|
||||
.AsNoTracking()
|
||||
.Where(e => e.GraphHash == normalizedHash)
|
||||
.OrderBy(e => e.SourceId)
|
||||
.ThenBy(e => e.TargetId)
|
||||
.ThenBy(e => e.Type)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(e => new CallEdgeDocument
|
||||
{
|
||||
results.Add(MapCallEdge(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
Id = e.Id,
|
||||
GraphHash = e.GraphHash,
|
||||
SourceId = e.SourceId,
|
||||
TargetId = e.TargetId,
|
||||
Type = e.Type,
|
||||
Purl = e.Purl,
|
||||
SymbolDigest = e.SymbolDigest,
|
||||
Candidates = string.IsNullOrEmpty(e.Candidates) ? null : JsonSerializer.Deserialize<List<string>>(e.Candidates, JsonOptions),
|
||||
Confidence = e.Confidence,
|
||||
Evidence = string.IsNullOrEmpty(e.Evidence) ? null : JsonSerializer.Deserialize<List<string>>(e.Evidence, JsonOptions),
|
||||
IngestedAt = e.IngestedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task UpsertCveFuncHitsAsync(IReadOnlyCollection<CveFuncHitDocument> hits, CancellationToken cancellationToken)
|
||||
@@ -216,32 +243,34 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
|
||||
var id = $"{hit.SubjectKey.Trim()}|{hit.CveId.Trim().ToUpperInvariant()}|{hit.Purl?.Trim() ?? string.Empty}|{hit.SymbolDigest?.Trim()?.ToLowerInvariant() ?? string.Empty}";
|
||||
|
||||
const string sql = @"
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Keep raw SQL for UPSERT with ON CONFLICT
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO signals.cve_func_hits (id, subject_key, cve_id, graph_hash, purl, symbol_digest, reachable, confidence, lattice_state, evidence_uris, computed_at)
|
||||
VALUES (@id, @subject_key, @cve_id, @graph_hash, @purl, @symbol_digest, @reachable, @confidence, @lattice_state, @evidence_uris, @computed_at)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}::jsonb, {10})
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
graph_hash = EXCLUDED.graph_hash,
|
||||
reachable = EXCLUDED.reachable,
|
||||
confidence = EXCLUDED.confidence,
|
||||
lattice_state = EXCLUDED.lattice_state,
|
||||
evidence_uris = EXCLUDED.evidence_uris,
|
||||
computed_at = EXCLUDED.computed_at";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@id", id);
|
||||
AddParameter(command, "@subject_key", hit.SubjectKey.Trim());
|
||||
AddParameter(command, "@cve_id", hit.CveId.Trim().ToUpperInvariant());
|
||||
AddParameter(command, "@graph_hash", hit.GraphHash ?? string.Empty);
|
||||
AddParameter(command, "@purl", (object?)hit.Purl?.Trim() ?? DBNull.Value);
|
||||
AddParameter(command, "@symbol_digest", (object?)hit.SymbolDigest?.Trim()?.ToLowerInvariant() ?? DBNull.Value);
|
||||
AddParameter(command, "@reachable", hit.Reachable);
|
||||
AddParameter(command, "@confidence", (object?)hit.Confidence ?? DBNull.Value);
|
||||
AddParameter(command, "@lattice_state", (object?)hit.LatticeState ?? DBNull.Value);
|
||||
AddJsonbParameter(command, "@evidence_uris", hit.EvidenceUris is null ? null : JsonSerializer.Serialize(hit.EvidenceUris, JsonOptions));
|
||||
AddParameter(command, "@computed_at", hit.ComputedAt);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
computed_at = EXCLUDED.computed_at
|
||||
""",
|
||||
id,
|
||||
hit.SubjectKey.Trim(),
|
||||
hit.CveId.Trim().ToUpperInvariant(),
|
||||
hit.GraphHash ?? string.Empty,
|
||||
(object?)hit.Purl?.Trim() ?? DBNull.Value,
|
||||
(object?)hit.SymbolDigest?.Trim()?.ToLowerInvariant() ?? DBNull.Value,
|
||||
hit.Reachable,
|
||||
(object?)hit.Confidence ?? DBNull.Value,
|
||||
(object?)hit.LatticeState ?? DBNull.Value,
|
||||
hit.EvidenceUris is null ? DBNull.Value : JsonSerializer.Serialize(hit.EvidenceUris, JsonOptions),
|
||||
hit.ComputedAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,82 +286,35 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT id, subject_key, cve_id, graph_hash, purl, symbol_digest, reachable, confidence, lattice_state, evidence_uris, computed_at
|
||||
FROM signals.cve_func_hits
|
||||
WHERE subject_key = @subject_key AND UPPER(cve_id) = UPPER(@cve_id)
|
||||
ORDER BY cve_id, purl, symbol_digest";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@subject_key", subjectKey.Trim());
|
||||
AddParameter(command, "@cve_id", cveId.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var normalizedSubjectKey = subjectKey.Trim();
|
||||
var normalizedCveId = cveId.Trim().ToUpperInvariant();
|
||||
|
||||
var results = new List<CveFuncHitDocument>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
var entities = await dbContext.CveFuncHits
|
||||
.AsNoTracking()
|
||||
.Where(e => e.SubjectKey == normalizedSubjectKey && e.CveId.ToUpper() == normalizedCveId)
|
||||
.OrderBy(e => e.CveId)
|
||||
.ThenBy(e => e.Purl)
|
||||
.ThenBy(e => e.SymbolDigest)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(e => new CveFuncHitDocument
|
||||
{
|
||||
results.Add(MapCveFuncHit(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static FuncNodeDocument MapFuncNode(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
GraphHash = reader.GetString(1),
|
||||
SymbolId = reader.GetString(2),
|
||||
Name = reader.GetString(3),
|
||||
Kind = reader.GetString(4),
|
||||
Namespace = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
File = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
Line = reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
Purl = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
SymbolDigest = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
BuildId = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
CodeId = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
Language = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
Evidence = reader.IsDBNull(13) ? null : JsonSerializer.Deserialize<List<string>>(reader.GetString(13), JsonOptions),
|
||||
Analyzer = reader.IsDBNull(14) ? null : JsonSerializer.Deserialize<Dictionary<string, string?>>(reader.GetString(14), JsonOptions),
|
||||
IngestedAt = reader.GetFieldValue<DateTimeOffset>(15)
|
||||
};
|
||||
|
||||
private static CallEdgeDocument MapCallEdge(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
GraphHash = reader.GetString(1),
|
||||
SourceId = reader.GetString(2),
|
||||
TargetId = reader.GetString(3),
|
||||
Type = reader.GetString(4),
|
||||
Purl = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
SymbolDigest = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
Candidates = reader.IsDBNull(7) ? null : JsonSerializer.Deserialize<List<string>>(reader.GetString(7), JsonOptions),
|
||||
Confidence = reader.IsDBNull(8) ? null : reader.GetDouble(8),
|
||||
Evidence = reader.IsDBNull(9) ? null : JsonSerializer.Deserialize<List<string>>(reader.GetString(9), JsonOptions),
|
||||
IngestedAt = reader.GetFieldValue<DateTimeOffset>(10)
|
||||
};
|
||||
|
||||
private static CveFuncHitDocument MapCveFuncHit(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
SubjectKey = reader.GetString(1),
|
||||
CveId = reader.GetString(2),
|
||||
GraphHash = reader.GetString(3),
|
||||
Purl = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
SymbolDigest = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
Reachable = reader.GetBoolean(6),
|
||||
Confidence = reader.IsDBNull(7) ? null : reader.GetDouble(7),
|
||||
LatticeState = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
EvidenceUris = reader.IsDBNull(9) ? null : JsonSerializer.Deserialize<List<string>>(reader.GetString(9), JsonOptions),
|
||||
ComputedAt = reader.GetFieldValue<DateTimeOffset>(10)
|
||||
};
|
||||
|
||||
private static NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction transaction)
|
||||
{
|
||||
var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
return command;
|
||||
Id = e.Id,
|
||||
SubjectKey = e.SubjectKey,
|
||||
CveId = e.CveId,
|
||||
GraphHash = e.GraphHash,
|
||||
Purl = e.Purl,
|
||||
SymbolDigest = e.SymbolDigest,
|
||||
Reachable = e.Reachable,
|
||||
Confidence = e.Confidence,
|
||||
LatticeState = e.LatticeState,
|
||||
EvidenceUris = string.IsNullOrEmpty(e.EvidenceUris) ? null : JsonSerializer.Deserialize<List<string>>(e.EvidenceUris, JsonOptions),
|
||||
ComputedAt = e.ComputedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
@@ -410,4 +392,6 @@ public sealed class PostgresReachabilityStoreRepository : RepositoryBase<Signals
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
using StellaOps.Signals.Persistence.EfCore.Models;
|
||||
using StellaOps.Signals.Persistence.Postgres;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
@@ -12,6 +16,7 @@ namespace StellaOps.Signals.Persistence.Postgres.Repositories;
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IUnknownsRepository"/>.
|
||||
/// Supports full scoring schema per Sprint 1102.
|
||||
/// Uses EF Core for persistence operations.
|
||||
/// </summary>
|
||||
public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSource>, IUnknownsRepository
|
||||
{
|
||||
@@ -37,20 +42,22 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
|
||||
var normalizedSubjectKey = subjectKey.Trim();
|
||||
|
||||
// Use raw connection + transaction for delete-then-insert pattern within a single transaction
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await dbContext.Database.UseTransactionAsync(transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Delete existing items for this subject
|
||||
const string deleteSql = "DELETE FROM signals.unknowns WHERE subject_key = @subject_key";
|
||||
await using (var deleteCommand = CreateCommand(deleteSql, connection, transaction))
|
||||
{
|
||||
AddParameter(deleteCommand, "@subject_key", normalizedSubjectKey);
|
||||
await deleteCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
// Delete existing items for this subject via EF Core
|
||||
await dbContext.Unknowns
|
||||
.Where(e => e.SubjectKey == normalizedSubjectKey)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Insert new items with all scoring columns
|
||||
// Insert new items with all scoring columns via raw SQL (complex parameter mapping)
|
||||
const string insertSql = @"
|
||||
INSERT INTO signals.unknowns (
|
||||
id, subject_key, callgraph_id, symbol_id, code_id, purl, purl_version,
|
||||
@@ -89,7 +96,7 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
|
||||
var itemId = string.IsNullOrWhiteSpace(item.Id) ? Guid.NewGuid().ToString("N") : item.Id.Trim();
|
||||
|
||||
await using var insertCommand = CreateCommand(insertSql, connection, transaction);
|
||||
await using var insertCommand = new NpgsqlCommand(insertSql, connection, transaction);
|
||||
AddInsertParameters(insertCommand, itemId, normalizedSubjectKey, item);
|
||||
|
||||
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -110,24 +117,20 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = SelectAllColumns + @"
|
||||
FROM signals.unknowns
|
||||
WHERE subject_key = @subject_key
|
||||
ORDER BY score DESC, created_at DESC";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@subject_key", subjectKey.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var normalizedKey = subjectKey.Trim();
|
||||
|
||||
var results = new List<UnknownSymbolDocument>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapUnknownSymbol(reader));
|
||||
}
|
||||
var entities = await dbContext.Unknowns
|
||||
.AsNoTracking()
|
||||
.Where(e => e.SubjectKey == normalizedKey)
|
||||
.OrderByDescending(e => e.Score)
|
||||
.ThenByDescending(e => e.CreatedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results;
|
||||
return entities.Select(MapEntityToDocument).ToList();
|
||||
}
|
||||
|
||||
public async Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
@@ -136,17 +139,15 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT COUNT(*)
|
||||
FROM signals.unknowns
|
||||
WHERE subject_key = @subject_key";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@subject_key", subjectKey.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is long count ? (int)count : 0;
|
||||
var normalizedKey = subjectKey.Trim();
|
||||
|
||||
return await dbContext.Unknowns
|
||||
.Where(e => e.SubjectKey == normalizedKey)
|
||||
.CountAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
|
||||
@@ -192,7 +193,7 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
continue;
|
||||
}
|
||||
|
||||
await using var updateCommand = CreateCommand(updateSql, connection, transaction);
|
||||
await using var updateCommand = new NpgsqlCommand(updateSql, connection, transaction);
|
||||
AddUpdateParameters(updateCommand, item);
|
||||
|
||||
await updateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -211,23 +212,16 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT DISTINCT subject_key
|
||||
FROM signals.unknowns
|
||||
ORDER BY subject_key";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<string>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return results;
|
||||
return await dbContext.Unknowns
|
||||
.AsNoTracking()
|
||||
.Select(e => e.SubjectKey)
|
||||
.Distinct()
|
||||
.OrderBy(k => k)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
|
||||
@@ -238,28 +232,21 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bandValue = band.ToString().ToLowerInvariant();
|
||||
|
||||
const string sql = SelectAllColumns + @"
|
||||
FROM signals.unknowns
|
||||
WHERE band = @band
|
||||
AND (next_scheduled_rescan IS NULL OR next_scheduled_rescan <= NOW())
|
||||
ORDER BY score DESC
|
||||
LIMIT @limit";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@band", bandValue);
|
||||
AddParameter(command, "@limit", limit);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var entities = await dbContext.Unknowns
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Band == bandValue &&
|
||||
(e.NextScheduledRescan == null || e.NextScheduledRescan <= now))
|
||||
.OrderByDescending(e => e.Score)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var results = new List<UnknownSymbolDocument>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapUnknownSymbol(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
return entities.Select(MapEntityToDocument).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(
|
||||
@@ -270,39 +257,26 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = SelectAllColumns + @"
|
||||
FROM signals.unknowns
|
||||
WHERE 1=1";
|
||||
|
||||
if (band.HasValue)
|
||||
{
|
||||
sql += " AND band = @band";
|
||||
}
|
||||
|
||||
sql += @"
|
||||
ORDER BY score DESC, created_at DESC
|
||||
LIMIT @limit OFFSET @offset";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<Unknown> query = dbContext.Unknowns.AsNoTracking();
|
||||
|
||||
if (band.HasValue)
|
||||
{
|
||||
AddParameter(command, "@band", band.Value.ToString().ToLowerInvariant());
|
||||
var bandValue = band.Value.ToString().ToLowerInvariant();
|
||||
query = query.Where(e => e.Band == bandValue);
|
||||
}
|
||||
|
||||
AddParameter(command, "@limit", limit);
|
||||
AddParameter(command, "@offset", offset);
|
||||
var entities = await query
|
||||
.OrderByDescending(e => e.Score)
|
||||
.ThenByDescending(e => e.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<UnknownSymbolDocument>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapUnknownSymbol(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
return entities.Select(MapEntityToDocument).ToList();
|
||||
}
|
||||
|
||||
public async Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
||||
@@ -312,35 +286,77 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = SelectAllColumns + @"
|
||||
FROM signals.unknowns
|
||||
WHERE id = @id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@id", id.Trim());
|
||||
await using var dbContext = SignalsDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var normalizedId = id.Trim();
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
return null;
|
||||
var entity = await dbContext.Unknowns
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Id == normalizedId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return MapUnknownSymbol(reader);
|
||||
return entity is null ? null : MapEntityToDocument(entity);
|
||||
}
|
||||
|
||||
private const string SelectAllColumns = @"
|
||||
SELECT id, subject_key, callgraph_id, symbol_id, code_id, purl, purl_version,
|
||||
edge_from, edge_to, reason,
|
||||
popularity_p, deployment_count,
|
||||
exploit_potential_e,
|
||||
uncertainty_u,
|
||||
centrality_c, degree_centrality, betweenness_centrality,
|
||||
staleness_s, days_since_analysis,
|
||||
score, band,
|
||||
unknown_flags, normalization_trace,
|
||||
rescan_attempts, last_rescan_result, next_scheduled_rescan, last_analyzed_at,
|
||||
graph_slice_hash, evidence_set_hash, callgraph_attempt_hash,
|
||||
created_at, updated_at";
|
||||
private static UnknownSymbolDocument MapEntityToDocument(Unknown entity)
|
||||
{
|
||||
return new UnknownSymbolDocument
|
||||
{
|
||||
Id = entity.Id,
|
||||
SubjectKey = entity.SubjectKey,
|
||||
CallgraphId = entity.CallgraphId,
|
||||
SymbolId = entity.SymbolId,
|
||||
CodeId = entity.CodeId,
|
||||
Purl = entity.Purl,
|
||||
PurlVersion = entity.PurlVersion,
|
||||
EdgeFrom = entity.EdgeFrom,
|
||||
EdgeTo = entity.EdgeTo,
|
||||
Reason = entity.Reason,
|
||||
|
||||
// Scoring factors
|
||||
PopularityScore = entity.PopularityP,
|
||||
DeploymentCount = entity.DeploymentCount,
|
||||
ExploitPotentialScore = entity.ExploitPotentialE,
|
||||
UncertaintyScore = entity.UncertaintyU,
|
||||
CentralityScore = entity.CentralityC,
|
||||
DegreeCentrality = entity.DegreeCentrality,
|
||||
BetweennessCentrality = entity.BetweennessCentrality,
|
||||
StalenessScore = entity.StalenessS,
|
||||
DaysSinceLastAnalysis = entity.DaysSinceAnalysis,
|
||||
|
||||
// Composite
|
||||
Score = entity.Score,
|
||||
Band = ParseBand(entity.Band ?? "cold"),
|
||||
|
||||
// JSONB columns
|
||||
Flags = string.IsNullOrEmpty(entity.UnknownFlags) ? new UnknownFlags() : JsonSerializer.Deserialize<UnknownFlags>(entity.UnknownFlags, JsonOptions) ?? new UnknownFlags(),
|
||||
NormalizationTrace = string.IsNullOrEmpty(entity.NormalizationTrace) ? null : JsonSerializer.Deserialize<UnknownsNormalizationTrace>(entity.NormalizationTrace, JsonOptions),
|
||||
|
||||
// Rescan scheduling
|
||||
RescanAttempts = entity.RescanAttempts,
|
||||
LastRescanResult = entity.LastRescanResult,
|
||||
NextScheduledRescan = entity.NextScheduledRescan,
|
||||
LastAnalyzedAt = entity.LastAnalyzedAt,
|
||||
|
||||
// Hashes
|
||||
GraphSliceHash = entity.GraphSliceHash is not null ? Convert.ToHexString(entity.GraphSliceHash).ToLowerInvariant() : null,
|
||||
EvidenceSetHash = entity.EvidenceSetHash is not null ? Convert.ToHexString(entity.EvidenceSetHash).ToLowerInvariant() : null,
|
||||
CallgraphAttemptHash = entity.CallgraphAttemptHash is not null ? Convert.ToHexString(entity.CallgraphAttemptHash).ToLowerInvariant() : null,
|
||||
|
||||
// Timestamps
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownsBand ParseBand(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"hot" => UnknownsBand.Hot,
|
||||
"warm" => UnknownsBand.Warm,
|
||||
_ => UnknownsBand.Cold
|
||||
};
|
||||
|
||||
private void AddInsertParameters(NpgsqlCommand command, string itemId, string subjectKey, UnknownSymbolDocument item)
|
||||
{
|
||||
@@ -435,83 +451,6 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
param.Value = value != null ? JsonSerializer.Serialize(value, JsonOptions) : DBNull.Value;
|
||||
}
|
||||
|
||||
private static UnknownSymbolDocument MapUnknownSymbol(NpgsqlDataReader reader)
|
||||
{
|
||||
var doc = new UnknownSymbolDocument
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
SubjectKey = reader.GetString(1),
|
||||
CallgraphId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
SymbolId = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CodeId = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
Purl = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
PurlVersion = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
EdgeFrom = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
EdgeTo = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
Reason = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
|
||||
// Scoring factors
|
||||
PopularityScore = reader.IsDBNull(10) ? 0.0 : reader.GetDouble(10),
|
||||
DeploymentCount = reader.IsDBNull(11) ? 0 : reader.GetInt32(11),
|
||||
ExploitPotentialScore = reader.IsDBNull(12) ? 0.0 : reader.GetDouble(12),
|
||||
UncertaintyScore = reader.IsDBNull(13) ? 0.0 : reader.GetDouble(13),
|
||||
CentralityScore = reader.IsDBNull(14) ? 0.0 : reader.GetDouble(14),
|
||||
DegreeCentrality = reader.IsDBNull(15) ? 0 : reader.GetInt32(15),
|
||||
BetweennessCentrality = reader.IsDBNull(16) ? 0.0 : reader.GetDouble(16),
|
||||
StalenessScore = reader.IsDBNull(17) ? 0.0 : reader.GetDouble(17),
|
||||
DaysSinceLastAnalysis = reader.IsDBNull(18) ? 0 : reader.GetInt32(18),
|
||||
|
||||
// Composite
|
||||
Score = reader.IsDBNull(19) ? 0.0 : reader.GetDouble(19),
|
||||
Band = ParseBand(reader.IsDBNull(20) ? "cold" : reader.GetString(20)),
|
||||
|
||||
// JSONB columns
|
||||
Flags = ParseJson<UnknownFlags>(reader, 21) ?? new UnknownFlags(),
|
||||
NormalizationTrace = ParseJson<UnknownsNormalizationTrace>(reader, 22),
|
||||
|
||||
// Rescan scheduling
|
||||
RescanAttempts = reader.IsDBNull(23) ? 0 : reader.GetInt32(23),
|
||||
LastRescanResult = reader.IsDBNull(24) ? null : reader.GetString(24),
|
||||
NextScheduledRescan = reader.IsDBNull(25) ? null : reader.GetFieldValue<DateTimeOffset>(25),
|
||||
LastAnalyzedAt = reader.IsDBNull(26) ? null : reader.GetFieldValue<DateTimeOffset>(26),
|
||||
|
||||
// Hashes
|
||||
GraphSliceHash = reader.IsDBNull(27) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(27)).ToLowerInvariant(),
|
||||
EvidenceSetHash = reader.IsDBNull(28) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(28)).ToLowerInvariant(),
|
||||
CallgraphAttemptHash = reader.IsDBNull(29) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(29)).ToLowerInvariant(),
|
||||
|
||||
// Timestamps
|
||||
CreatedAt = reader.IsDBNull(30) ? DateTimeOffset.UtcNow : reader.GetFieldValue<DateTimeOffset>(30),
|
||||
UpdatedAt = reader.IsDBNull(31) ? DateTimeOffset.UtcNow : reader.GetFieldValue<DateTimeOffset>(31)
|
||||
};
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static UnknownsBand ParseBand(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"hot" => UnknownsBand.Hot,
|
||||
"warm" => UnknownsBand.Warm,
|
||||
_ => UnknownsBand.Cold
|
||||
};
|
||||
|
||||
private static T? ParseJson<T>(NpgsqlDataReader reader, int ordinal) where T : class
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private static NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction transaction)
|
||||
{
|
||||
var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
return command;
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
@@ -589,4 +528,6 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => SignalsDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Signals.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Signals.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Signals.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="SignalsDbContext"/> instances.
|
||||
/// Uses the static compiled model when schema matches the default; falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class SignalsDbContextFactory
|
||||
{
|
||||
public static SignalsDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? SignalsDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<SignalsDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, SignalsDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(SignalsDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new SignalsDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@
|
||||
LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\SignalsDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
|
||||
Reference in New Issue
Block a user