Introduce PostgresGraphSavedViewStore with SQL migration, in-memory fallback, CompatibilityEndpoints for UI contract alignment, and integration tests with a shared Postgres fixture. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
479 lines
20 KiB
C#
479 lines
20 KiB
C#
using StellaOps.Graph.Api.Contracts;
|
|
|
|
namespace StellaOps.Graph.Api.Services;
|
|
|
|
public sealed class InMemoryGraphRepository
|
|
{
|
|
private static readonly string[] CompatibilityKinds = ["artifact", "component", "vuln"];
|
|
private static readonly DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero);
|
|
private readonly List<NodeTile> _nodes;
|
|
private readonly List<EdgeTile> _edges;
|
|
private readonly Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> _snapshots;
|
|
|
|
public InMemoryGraphRepository(IEnumerable<NodeTile>? seed = null, IEnumerable<EdgeTile>? edges = null)
|
|
{
|
|
_nodes = seed?.ToList() ?? new List<NodeTile>
|
|
{
|
|
new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "example", ["version"] = "1.0.0" } },
|
|
new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm", ["displayName"] = "widget", ["version"] = "2.0.0" } },
|
|
new() { Id = "gn:acme:artifact:sha256:abc", Kind = "artifact", Tenant = "acme", Attributes = new() { ["digest"] = "sha256:abc", ["ecosystem"] = "container", ["displayName"] = "auth-service", ["version"] = "2026.04.04" } },
|
|
new() { Id = "gn:acme:sbom:sha256:sbom-a", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-a", ["artifact_digest"] = "sha256:abc", ["format"] = "cyclonedx" } },
|
|
new() { Id = "gn:acme:sbom:sha256:sbom-b", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-b", ["artifact_digest"] = "sha256:abc", ["format"] = "spdx" } },
|
|
new() { Id = "gn:acme:component:gamma", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:nuget/Gamma@3.1.4", ["ecosystem"] = "nuget", ["displayName"] = "gamma", ["version"] = "3.1.4" } },
|
|
new() { Id = "gn:acme:vuln:CVE-2026-1234", Kind = "vuln", Tenant = "acme", Attributes = new() { ["displayName"] = "CVE-2026-1234", ["severity"] = "critical", ["cveId"] = "CVE-2026-1234" } },
|
|
new() { Id = "gn:acme:vuln:CVE-2026-5678", Kind = "vuln", Tenant = "acme", Attributes = new() { ["displayName"] = "CVE-2026-5678", ["severity"] = "high", ["cveId"] = "CVE-2026-5678" } },
|
|
new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm", ["displayName"] = "widget", ["version"] = "2.0.0" } },
|
|
new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo", Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container", ["displayName"] = "transform-service", ["version"] = "2026.04.04" } },
|
|
new() { Id = "gn:bravo:vuln:CVE-2026-9001", Kind = "vuln", Tenant = "bravo", Attributes = new() { ["displayName"] = "CVE-2026-9001", ["severity"] = "medium", ["cveId"] = "CVE-2026-9001" } },
|
|
};
|
|
|
|
_edges = edges?.ToList() ?? new List<EdgeTile>
|
|
{
|
|
new() { Id = "ge:acme:artifact->component", Kind = "builds", Tenant = "acme", Source = "gn:acme:artifact:sha256:abc", Target = "gn:acme:component:example", Attributes = new() { ["reason"] = "sbom" } },
|
|
new() { Id = "ge:acme:component->component", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:example", Target = "gn:acme:component:widget", Attributes = new() { ["scope"] = "runtime" } },
|
|
new() { Id = "ge:acme:component->component:gamma", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:widget", Target = "gn:acme:component:gamma", Attributes = new() { ["scope"] = "runtime" } },
|
|
new() { Id = "ge:acme:component->vuln:widget", Kind = "affects", Tenant = "acme", Source = "gn:acme:component:widget", Target = "gn:acme:vuln:CVE-2026-1234", Attributes = new() { ["scope"] = "runtime" } },
|
|
new() { Id = "ge:acme:component->vuln:gamma", Kind = "affects", Tenant = "acme", Source = "gn:acme:component:gamma", Target = "gn:acme:vuln:CVE-2026-5678", Attributes = new() { ["scope"] = "runtime" } },
|
|
new() { Id = "ge:acme:sbom->artifact", Kind = "SBOM_VERSION_OF", Tenant = "acme", Source = "gn:acme:sbom:sha256:sbom-b", Target = "gn:acme:artifact:sha256:abc", Attributes = new() { ["relationship"] = "version_of" } },
|
|
new() { Id = "ge:acme:sbom->sbom", Kind = "SBOM_LINEAGE_PARENT", Tenant = "acme", Source = "gn:acme:sbom:sha256:sbom-b", Target = "gn:acme:sbom:sha256:sbom-a", Attributes = new() { ["relationship"] = "parent" } },
|
|
new() { Id = "ge:bravo:artifact->component", Kind = "builds", Tenant = "bravo", Source = "gn:bravo:artifact:sha256:def", Target = "gn:bravo:component:widget", Attributes = new() { ["reason"] = "sbom" } },
|
|
new() { Id = "ge:bravo:component->vuln:widget", Kind = "affects", Tenant = "bravo", Source = "gn:bravo:component:widget", Target = "gn:bravo:vuln:CVE-2026-9001", Attributes = new() { ["scope"] = "runtime" } },
|
|
};
|
|
|
|
// Drop edges whose endpoints aren't present in the current node set to avoid invalid graph seeds in tests.
|
|
var nodeIds = _nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
|
_edges = _edges.Where(e => nodeIds.Contains(e.Source) && nodeIds.Contains(e.Target)).ToList();
|
|
|
|
_snapshots = SeedSnapshots();
|
|
}
|
|
|
|
public IEnumerable<NodeTile> Query(string tenant, GraphSearchRequest request)
|
|
{
|
|
var queryable = _nodes
|
|
.Where(n => n.Tenant.Equals(tenant, StringComparison.Ordinal))
|
|
.Where(n => request.Kinds is null || request.Kinds.Length == 0 || request.Kinds.Contains(n.Kind, StringComparer.OrdinalIgnoreCase));
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.Query))
|
|
{
|
|
queryable = queryable.Where(n => MatchesQuery(n, request.Query!));
|
|
}
|
|
|
|
if (request.Filters is not null)
|
|
{
|
|
queryable = queryable.Where(n => FiltersMatch(n, request.Filters!));
|
|
}
|
|
|
|
return queryable;
|
|
}
|
|
|
|
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) QueryGraph(string tenant, GraphQueryRequest request)
|
|
{
|
|
var nodes = Query(tenant, new GraphSearchRequest
|
|
{
|
|
Kinds = request.Kinds,
|
|
Query = request.Query,
|
|
Filters = request.Filters,
|
|
Limit = request.Limit,
|
|
Cursor = request.Cursor
|
|
}).ToList();
|
|
|
|
var nodeIds = nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
|
var edges = request.IncludeEdges
|
|
? _edges.Where(e => e.Tenant.Equals(tenant, StringComparison.Ordinal) && nodeIds.Contains(e.Source) && nodeIds.Contains(e.Target))
|
|
.OrderBy(e => e.Id, StringComparer.Ordinal)
|
|
.ToList()
|
|
: new List<EdgeTile>();
|
|
|
|
return (nodes, edges);
|
|
}
|
|
|
|
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) GetLineage(string tenant, GraphLineageRequest request)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
var maxDepth = request.MaxDepth ?? 3;
|
|
if (maxDepth < 1)
|
|
{
|
|
maxDepth = 1;
|
|
}
|
|
|
|
var allowedKinds = BuildLineageKindFilter(request.RelationshipKinds);
|
|
var tenantNodes = _nodes
|
|
.Where(n => n.Tenant.Equals(tenant, StringComparison.Ordinal))
|
|
.ToList();
|
|
|
|
if (tenantNodes.Count == 0)
|
|
{
|
|
return (Array.Empty<NodeTile>(), Array.Empty<EdgeTile>());
|
|
}
|
|
|
|
var nodeById = tenantNodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
|
var seedIds = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
|
{
|
|
var digest = request.ArtifactDigest.Trim();
|
|
foreach (var node in tenantNodes.Where(n => HasAttribute(n, "artifact_digest", digest)))
|
|
{
|
|
seedIds.Add(node.Id);
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.SbomDigest))
|
|
{
|
|
var digest = request.SbomDigest.Trim();
|
|
foreach (var node in tenantNodes.Where(n => HasAttribute(n, "sbom_digest", digest)))
|
|
{
|
|
seedIds.Add(node.Id);
|
|
}
|
|
}
|
|
|
|
if (seedIds.Count == 0)
|
|
{
|
|
return (Array.Empty<NodeTile>(), Array.Empty<EdgeTile>());
|
|
}
|
|
|
|
var tenantEdges = _edges
|
|
.Where(e => e.Tenant.Equals(tenant, StringComparison.Ordinal))
|
|
.Where(e => IsLineageEdgeAllowed(e, allowedKinds))
|
|
.ToList();
|
|
|
|
var adjacency = new Dictionary<string, List<EdgeTile>>(StringComparer.Ordinal);
|
|
foreach (var edge in tenantEdges)
|
|
{
|
|
AddAdjacency(adjacency, edge.Source, edge);
|
|
AddAdjacency(adjacency, edge.Target, edge);
|
|
}
|
|
|
|
var visitedNodes = new HashSet<string>(seedIds, StringComparer.Ordinal);
|
|
var visitedEdges = new HashSet<string>(StringComparer.Ordinal);
|
|
var frontier = new HashSet<string>(seedIds, StringComparer.Ordinal);
|
|
|
|
for (var depth = 0; depth < maxDepth && frontier.Count > 0; depth++)
|
|
{
|
|
var next = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var nodeId in frontier)
|
|
{
|
|
if (!adjacency.TryGetValue(nodeId, out var edges))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var edge in edges)
|
|
{
|
|
if (visitedEdges.Add(edge.Id))
|
|
{
|
|
var other = string.Equals(edge.Source, nodeId, StringComparison.Ordinal)
|
|
? edge.Target
|
|
: edge.Source;
|
|
if (visitedNodes.Add(other))
|
|
{
|
|
next.Add(other);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
frontier = next;
|
|
}
|
|
|
|
var resultNodes = new List<NodeTile>();
|
|
foreach (var nodeId in visitedNodes.OrderBy(id => id, StringComparer.Ordinal))
|
|
{
|
|
if (nodeById.TryGetValue(nodeId, out var node))
|
|
{
|
|
resultNodes.Add(node);
|
|
}
|
|
}
|
|
|
|
var resultEdges = tenantEdges
|
|
.Where(edge => visitedEdges.Contains(edge.Id))
|
|
.Where(edge => visitedNodes.Contains(edge.Source) && visitedNodes.Contains(edge.Target))
|
|
.OrderBy(edge => edge.Id, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
return (resultNodes, resultEdges);
|
|
}
|
|
|
|
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId)
|
|
{
|
|
if (_snapshots.TryGetValue($"{tenant}:{snapshotId}", out var snap))
|
|
{
|
|
return (snap.Nodes, snap.Edges);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public IReadOnlyList<NodeTile> GetCompatibilityNodes(string tenant)
|
|
=> _nodes
|
|
.Where(node => node.Tenant.Equals(tenant, StringComparison.Ordinal))
|
|
.Where(node => CompatibilityKinds.Contains(node.Kind, StringComparer.OrdinalIgnoreCase))
|
|
.OrderBy(node => node.Id, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
public IReadOnlyList<EdgeTile> GetCompatibilityEdges(string tenant)
|
|
{
|
|
var nodeIds = GetCompatibilityNodes(tenant).Select(node => node.Id).ToHashSet(StringComparer.Ordinal);
|
|
return _edges
|
|
.Where(edge => edge.Tenant.Equals(tenant, StringComparison.Ordinal))
|
|
.Where(edge => nodeIds.Contains(edge.Source) && nodeIds.Contains(edge.Target))
|
|
.OrderBy(edge => edge.Id, StringComparer.Ordinal)
|
|
.ToList();
|
|
}
|
|
|
|
public static string BuildGraphId(string tenant)
|
|
=> $"graph::{tenant}::main";
|
|
|
|
public bool GraphExists(string tenant, string graphId)
|
|
=> string.Equals(BuildGraphId(tenant), graphId, StringComparison.OrdinalIgnoreCase);
|
|
|
|
public NodeTile? GetNode(string tenant, string nodeId)
|
|
=> _nodes.FirstOrDefault(node =>
|
|
node.Tenant.Equals(tenant, StringComparison.Ordinal)
|
|
&& node.Id.Equals(nodeId, StringComparison.Ordinal));
|
|
|
|
public (IReadOnlyList<EdgeTile> Incoming, IReadOnlyList<EdgeTile> Outgoing) GetAdjacency(string tenant, string nodeId)
|
|
{
|
|
var edges = GetCompatibilityEdges(tenant);
|
|
var incoming = edges.Where(edge => edge.Target.Equals(nodeId, StringComparison.Ordinal)).ToList();
|
|
var outgoing = edges.Where(edge => edge.Source.Equals(nodeId, StringComparison.Ordinal)).ToList();
|
|
return (incoming, outgoing);
|
|
}
|
|
|
|
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth)
|
|
{
|
|
var nodes = GetCompatibilityNodes(tenant).ToDictionary(node => node.Id, StringComparer.Ordinal);
|
|
if (!nodes.ContainsKey(sourceId) || !nodes.ContainsKey(targetId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var edges = GetCompatibilityEdges(tenant);
|
|
var adjacency = new Dictionary<string, List<EdgeTile>>(StringComparer.Ordinal);
|
|
foreach (var edge in edges)
|
|
{
|
|
if (!adjacency.TryGetValue(edge.Source, out var list))
|
|
{
|
|
list = new List<EdgeTile>();
|
|
adjacency[edge.Source] = list;
|
|
}
|
|
|
|
list.Add(edge);
|
|
}
|
|
|
|
var queue = new Queue<(string NodeId, List<EdgeTile> PathEdges)>();
|
|
var visited = new HashSet<string>(StringComparer.Ordinal) { sourceId };
|
|
queue.Enqueue((sourceId, new List<EdgeTile>()));
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var (nodeId, pathEdges) = queue.Dequeue();
|
|
if (nodeId.Equals(targetId, StringComparison.Ordinal))
|
|
{
|
|
var pathNodes = BuildNodeListFromEdges(nodes, sourceId, pathEdges);
|
|
return (pathNodes, pathEdges);
|
|
}
|
|
|
|
if (pathEdges.Count >= maxDepth || !adjacency.TryGetValue(nodeId, out var outgoing))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var edge in outgoing.OrderBy(edge => edge.Id, StringComparer.Ordinal))
|
|
{
|
|
if (!visited.Add(edge.Target))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var nextEdges = new List<EdgeTile>(pathEdges.Count + 1);
|
|
nextEdges.AddRange(pathEdges);
|
|
nextEdges.Add(edge);
|
|
queue.Enqueue((edge.Target, nextEdges));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public DateTimeOffset GetSnapshotTimestamp()
|
|
=> FixedSnapshotAt;
|
|
|
|
private Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> SeedSnapshots()
|
|
{
|
|
var dict = new Dictionary<string, (List<NodeTile>, List<EdgeTile>)>(StringComparer.Ordinal);
|
|
|
|
dict["acme:snapA"] = (new List<NodeTile>(_nodes), new List<EdgeTile>(_edges));
|
|
|
|
var updatedNodes = new List<NodeTile>(_nodes.Select(n => n with
|
|
{
|
|
Attributes = new Dictionary<string, object?>(n.Attributes)
|
|
}));
|
|
|
|
var widget = updatedNodes.FirstOrDefault(n => n.Id == "gn:acme:component:widget");
|
|
if (widget is null)
|
|
{
|
|
// Custom seeds may not include the default widget node; skip optional snapshot wiring in that case.
|
|
return dict;
|
|
}
|
|
|
|
widget.Attributes["purl"] = "pkg:npm/widget@2.1.0";
|
|
widget.Attributes["version"] = "2.1.0";
|
|
|
|
updatedNodes.Add(new NodeTile
|
|
{
|
|
Id = "gn:acme:component:newlib",
|
|
Kind = "component",
|
|
Tenant = "acme",
|
|
Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "newlib", ["version"] = "1.0.0" }
|
|
});
|
|
|
|
var updatedEdges = new List<EdgeTile>(_edges)
|
|
{
|
|
new()
|
|
{
|
|
Id = "ge:acme:component->component:new",
|
|
Kind = "depends_on",
|
|
Tenant = "acme",
|
|
Source = widget.Id,
|
|
Target = "gn:acme:component:newlib",
|
|
Attributes = new() { ["scope"] = "runtime" }
|
|
}
|
|
};
|
|
|
|
dict["acme:snapB"] = (updatedNodes, updatedEdges);
|
|
return dict;
|
|
}
|
|
|
|
private static bool HasAttribute(NodeTile node, string key, string expected)
|
|
{
|
|
if (!node.Attributes.TryGetValue(key, out var value) || value is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return string.Equals(value.ToString(), expected, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static HashSet<string> BuildLineageKindFilter(string[]? relationshipKinds)
|
|
{
|
|
if (relationshipKinds is null || relationshipKinds.Length == 0)
|
|
{
|
|
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var value in relationshipKinds)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
set.Add(value.Trim());
|
|
}
|
|
|
|
return set;
|
|
}
|
|
|
|
private static bool IsLineageEdgeAllowed(EdgeTile edge, HashSet<string> allowedKinds)
|
|
{
|
|
if (allowedKinds.Count == 0)
|
|
{
|
|
return edge.Kind.StartsWith("SBOM_LINEAGE_", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(edge.Kind, "SBOM_VERSION_OF", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
if (allowedKinds.Contains(edge.Kind))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (edge.Attributes.TryGetValue("relationship", out var relationship) && relationship is not null)
|
|
{
|
|
return allowedKinds.Contains(relationship.ToString() ?? string.Empty);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void AddAdjacency(Dictionary<string, List<EdgeTile>> adjacency, string nodeId, EdgeTile edge)
|
|
{
|
|
if (!adjacency.TryGetValue(nodeId, out var list))
|
|
{
|
|
list = new List<EdgeTile>();
|
|
adjacency[nodeId] = list;
|
|
}
|
|
|
|
list.Add(edge);
|
|
}
|
|
|
|
private static bool MatchesQuery(NodeTile node, string query)
|
|
{
|
|
var q = query.ToLowerInvariant();
|
|
return node.Id.ToLowerInvariant().Contains(q)
|
|
|| node.Attributes.Values.OfType<string>().Any(v => v.Contains(q, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static bool FiltersMatch(NodeTile node, IReadOnlyDictionary<string, object> filters)
|
|
{
|
|
foreach (var kvp in filters)
|
|
{
|
|
if (!node.Attributes.TryGetValue(kvp.Key, out var value))
|
|
{
|
|
return false;
|
|
}
|
|
if (kvp.Value is null)
|
|
{
|
|
continue;
|
|
}
|
|
if (!kvp.Value.ToString()!.Equals(value?.ToString(), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static IReadOnlyList<NodeTile> BuildNodeListFromEdges(
|
|
IDictionary<string, NodeTile> nodes,
|
|
string sourceId,
|
|
IReadOnlyList<EdgeTile> edges)
|
|
{
|
|
var list = new List<NodeTile>();
|
|
if (nodes.TryGetValue(sourceId, out var firstNode))
|
|
{
|
|
list.Add(firstNode);
|
|
}
|
|
|
|
foreach (var edge in edges)
|
|
{
|
|
if (nodes.TryGetValue(edge.Target, out var node))
|
|
{
|
|
list.Add(node);
|
|
}
|
|
}
|
|
|
|
return list;
|
|
}
|
|
}
|
|
|
|
internal static class CursorCodec
|
|
{
|
|
public static string Encode(int offset) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(offset.ToString()));
|
|
|
|
public static int Decode(string? token)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(token)) return 0;
|
|
try
|
|
{
|
|
var text = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(token));
|
|
return int.TryParse(text, out var value) ? value : 0;
|
|
}
|
|
catch
|
|
{
|
|
return 0;
|
|
}
|
|
}
|
|
}
|