From 14dc832e7c020de5e2aba79c09c68d4df76128bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:51:06 +0000 Subject: [PATCH 1/5] Initial plan From d40568240b4e46f64b657937fde5c1fb7ee3d9ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:07:05 +0000 Subject: [PATCH 2/5] Implement QuadStoreStorageProvider with IStorageProvider, IQueryableStorage, IUpdateableStorage Co-authored-by: aabs <157775+aabs@users.noreply.github.com> --- .../QuadStoreStorageProvider.cs | 465 ++++++++++++ .../QuadStoreStorageProviderTests.cs | 683 ++++++++++++++++++ 2 files changed, 1148 insertions(+) create mode 100644 src/TripleStore.Core/QuadStoreStorageProvider.cs create mode 100644 test/TripleStore.Tests/QuadStoreStorageProviderTests.cs diff --git a/src/TripleStore.Core/QuadStoreStorageProvider.cs b/src/TripleStore.Core/QuadStoreStorageProvider.cs new file mode 100644 index 0000000..8e3cfeb --- /dev/null +++ b/src/TripleStore.Core/QuadStoreStorageProvider.cs @@ -0,0 +1,465 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using VDS.RDF; +using VDS.RDF.Parsing; +using VDS.RDF.Query; +using VDS.RDF.Query.Datasets; +using VDS.RDF.Storage; +using VDS.RDF.Storage.Management; + +namespace TripleStore.Core; + +/// +/// Adapter that exposes as a dotNetRDF , +/// enabling compatibility with the Leviathan SPARQL query engine and other dotNetRDF tooling. +/// +/// +/// This implementation maps dotNetRDF storage operations to QuadStore's underlying +/// columnar bitmap store. Quad support is enabled: named graphs are surfaced as required +/// by dotNetRDF. +/// Limitations: +/// +/// Graph deletion is not supported (QuadStore is append-only). +/// Triple removal via +/// is not supported. +/// +/// SPARQL Update via is not supported. +/// +/// +/// +/// +/// // Create or open a QuadStore +/// using var qs = new QuadStore("/path/to/data"); +/// +/// // Wrap in the storage provider adapter +/// IStorageProvider provider = new QuadStoreStorageProvider(qs); +/// +/// // Load a named graph +/// var g = new Graph(); +/// provider.LoadGraph(g, new Uri("http://example.org/mygraph")); +/// +/// // Use with the Leviathan SPARQL query engine +/// var queryProvider = (IQueryableStorage)provider; +/// var results = (SparqlResultSet)queryProvider.Query("SELECT * WHERE { ?s ?p ?o }"); +/// +/// +public sealed class QuadStoreStorageProvider : IStorageProvider, IQueryableStorage, IUpdateableStorage +{ + private readonly QuadStore _store; + + /// + /// Initializes a new instance of . + /// + /// The underlying instance. + /// Thrown when is null. + public QuadStoreStorageProvider(QuadStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + // ── IStorageCapabilities ──────────────────────────────────────────────── + + /// + public bool IsReady => true; + + /// + public bool IsReadOnly => false; + + /// + public IOBehaviour IOBehaviour => + IOBehaviour.IsQuadStore | + IOBehaviour.HasNamedGraphs | + IOBehaviour.HasDefaultGraph | + IOBehaviour.AppendTriples | + IOBehaviour.CanUpdateAddTriples; + + /// + public bool UpdateSupported => true; + + /// + /// Returns . QuadStore is append-only and does not support graph deletion. + /// + public bool DeleteSupported => false; + + /// + public bool ListGraphsSupported => true; + + // ── IStorageProvider ──────────────────────────────────────────────────── + + /// + /// Returns . QuadStore is a standalone store with no parent server. + /// + public IStorageServer ParentServer => null; + + /// + public void LoadGraph(IGraph g, Uri graphUri) + { + if (g == null) throw new ArgumentNullException(nameof(g)); + LoadGraphInternal(g, graphUri?.AbsoluteUri); + if (graphUri != null) + g.BaseUri = graphUri; + } + + /// + public void LoadGraph(IGraph g, string graphUri) + { + if (g == null) throw new ArgumentNullException(nameof(g)); + LoadGraphInternal(g, graphUri); + if (graphUri != null && Uri.TryCreate(graphUri, UriKind.Absolute, out var uri)) + g.BaseUri = uri; + } + + /// + public void LoadGraph(IRdfHandler handler, Uri graphUri) + { + if (handler == null) throw new ArgumentNullException(nameof(handler)); + LoadGraphHandlerInternal(handler, graphUri?.AbsoluteUri); + } + + /// + public void LoadGraph(IRdfHandler handler, string graphUri) + { + if (handler == null) throw new ArgumentNullException(nameof(handler)); + LoadGraphHandlerInternal(handler, graphUri); + } + + /// + /// + /// The graph is saved using its URI (if set) or + /// as the named graph identifier. + /// If neither is available, an empty string is used as the graph identifier. + /// + public void SaveGraph(IGraph g) + { + if (g == null) throw new ArgumentNullException(nameof(g)); + string graphUri = g.Name is IUriNode uriNode + ? uriNode.Uri.AbsoluteUri + : g.BaseUri?.AbsoluteUri ?? string.Empty; + + foreach (var triple in g.Triples) + { + _store.Append( + NodeToString(triple.Subject), + NodeToString(triple.Predicate), + NodeToString(triple.Object), + graphUri); + } + } + + /// + /// Removals are not supported; pass or an empty collection. + public void UpdateGraph(IRefNode graphName, IEnumerable additions, IEnumerable removals) + { + string graphUri = graphName is IUriNode un ? un.Uri.AbsoluteUri : string.Empty; + UpdateGraphInternal(graphUri, additions, removals); + } + + /// + /// Removals are not supported; pass or an empty collection. + public void UpdateGraph(Uri graphUri, IEnumerable additions, IEnumerable removals) + { + UpdateGraphInternal(graphUri?.AbsoluteUri ?? string.Empty, additions, removals); + } + + /// + /// Removals are not supported; pass or an empty collection. + public void UpdateGraph(string graphUri, IEnumerable additions, IEnumerable removals) + { + UpdateGraphInternal(graphUri ?? string.Empty, additions, removals); + } + + /// + /// Not supported. QuadStore is append-only and does not support graph deletion. + /// + /// Always thrown. + public void DeleteGraph(Uri graphUri) + => throw new RdfStorageException("QuadStore is append-only and does not support graph deletion."); + + /// + /// Not supported. QuadStore is append-only and does not support graph deletion. + /// + /// Always thrown. + public void DeleteGraph(string graphUri) + => throw new RdfStorageException("QuadStore is append-only and does not support graph deletion."); + + /// + public IEnumerable ListGraphs() + { + return _store.Query() + .Select(q => q.graph) + .Distinct() + .Where(g => Uri.TryCreate(g, UriKind.Absolute, out _)) + .Select(g => new Uri(g)); + } + + /// + public IEnumerable ListGraphNames() + { + return _store.Query() + .Select(q => q.graph) + .Distinct(); + } + + // ── IQueryableStorage ─────────────────────────────────────────────────── + + /// + /// Executes a SPARQL SELECT, ASK, CONSTRUCT, or DESCRIBE query over all graphs in the store + /// using the dotNetRDF Leviathan in-memory SPARQL engine. + /// + /// The SPARQL query string. + /// + /// A for SELECT/ASK queries, or an + /// for CONSTRUCT/DESCRIBE queries. + /// + /// Thrown when is null. + public object Query(string sparqlQuery) + { + if (sparqlQuery == null) throw new ArgumentNullException(nameof(sparqlQuery)); + var processor = CreateQueryProcessor(); + var query = new SparqlQueryParser().ParseFromString(sparqlQuery); + return processor.ProcessQuery(query); + } + + /// + /// Executes a SPARQL query, delivering results to the supplied handlers using the + /// dotNetRDF Leviathan in-memory SPARQL engine. + /// + /// Handler for CONSTRUCT/DESCRIBE results (graph results). + /// Handler for SELECT/ASK results (tabular results). + /// The SPARQL query string. + /// Thrown when is null. + public void Query(IRdfHandler rdfHandler, ISparqlResultsHandler resultsHandler, string sparqlQuery) + { + if (sparqlQuery == null) throw new ArgumentNullException(nameof(sparqlQuery)); + var processor = CreateQueryProcessor(); + var query = new SparqlQueryParser().ParseFromString(sparqlQuery); + processor.ProcessQuery(rdfHandler, resultsHandler, query); + } + + // ── IUpdateableStorage ────────────────────────────────────────────────── + + /// + /// Not supported. QuadStore is append-only; use or + /// to add triples. + /// + /// Always thrown. + public void Update(string sparqlUpdate) + => throw new RdfStorageException( + "QuadStore does not support SPARQL Update (append-only store). " + + "Use SaveGraph or UpdateGraph to add triples."); + + // ── IDisposable ───────────────────────────────────────────────────────── + + /// + /// Disposes this adapter. + /// The lifecycle of the underlying is managed by the caller. + /// + public void Dispose() + { + // QuadStore lifecycle is managed externally; nothing to dispose here. + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private void LoadGraphInternal(IGraph g, string graphUri) + { + foreach (var (s, p, o, _) in QueryByGraph(graphUri)) + { + g.Assert(new Triple( + StringToNode(s, g), + StringToNode(p, g), + StringToNode(o, g))); + } + } + + private void LoadGraphHandlerInternal(IRdfHandler handler, string graphUri) + { + var factory = new NodeFactory(); + handler.StartRdf(); + try + { + foreach (var (s, p, o, _) in QueryByGraph(graphUri)) + { + handler.HandleTriple(new Triple( + StringToNode(s, factory), + StringToNode(p, factory), + StringToNode(o, factory))); + } + handler.EndRdf(true); + } + catch + { + handler.EndRdf(false); + throw; + } + } + + private void UpdateGraphInternal(string graphUri, IEnumerable additions, IEnumerable removals) + { + if (removals != null) + { + var removalList = removals.ToList(); + if (removalList.Count > 0) + throw new RdfStorageException( + "QuadStore is append-only and does not support triple removal."); + } + + if (additions != null) + { + foreach (var triple in additions) + { + _store.Append( + NodeToString(triple.Subject), + NodeToString(triple.Predicate), + NodeToString(triple.Object), + graphUri); + } + } + } + + /// + /// Queries the store for triples in the given named graph, trying both the plain URI and + /// the angle-bracketed form to cover data inserted in either format. + /// + private IEnumerable<(string s, string p, string o, string g)> QueryByGraph(string graphUri) + { + if (graphUri == null) + return _store.Query(); + + // Normalise: derive both the plain and angle-bracketed form of the URI. + string plain = graphUri.StartsWith("<") && graphUri.EndsWith(">") + ? graphUri.Substring(1, graphUri.Length - 2) + : graphUri; + string bracketed = $"<{plain}>"; + + var seen = new HashSet<(string, string, string, string)>(); + IEnumerable<(string, string, string, string)> Merge() + { + foreach (var row in _store.Query(graph: plain)) + if (seen.Add(row)) yield return row; + foreach (var row in _store.Query(graph: bracketed)) + if (seen.Add(row)) yield return row; + } + return Merge(); + } + + private LeviathanQueryProcessor CreateQueryProcessor() + { + var tripleStore = new VDS.RDF.TripleStore(); + // Materialise the graph names eagerly to release the QuadStore read lock + // before calling LoadGraph (which also acquires the same lock). + var graphNames = ListGraphNames().ToList(); + foreach (var graphName in graphNames) + { + var g = new Graph(); + LoadGraph(g, graphName); + tripleStore.Add(g, mergeIfExists: true); + } + return new LeviathanQueryProcessor(new InMemoryDataset(tripleStore, unionDefaultGraph: true)); + } + + private const string XsdStringUri = "http://www.w3.org/2001/XMLSchema#string"; + + /// + /// Converts a dotNetRDF to the string representation used by + /// : + /// + /// URI nodes → plain absolute URI string (e.g. http://example.org/) + /// Language-tagged literals → "value"@lang + /// Typed literals (non-xsd:string) → "value"^^<datatype> + /// Plain literals (or xsd:string) → "value" + /// Blank nodes → _:id + /// + /// + public static string NodeToString(INode node) + { + if (node == null) return string.Empty; + return node switch + { + IUriNode uriNode => uriNode.Uri.AbsoluteUri, + // Language-tagged literals first (DataType may be rdf:langString in dotNetRDF 3.x) + ILiteralNode litNode when !string.IsNullOrEmpty(litNode.Language) => + $"\"{EscapeLiteral(litNode.Value)}\"@{litNode.Language}", + // Typed literals with a non-xsd:string datatype (use AbsoluteUri for fragment-aware comparison) + ILiteralNode litNode when litNode.DataType != null + && litNode.DataType.AbsoluteUri != XsdStringUri => + $"\"{EscapeLiteral(litNode.Value)}\"^^<{litNode.DataType.AbsoluteUri}>", + // Plain literals (null datatype or xsd:string) + ILiteralNode litNode => $"\"{EscapeLiteral(litNode.Value)}\"", + IBlankNode blankNode => $"_:{blankNode.InternalID}", + _ => node.ToString() + }; + } + + /// + /// Converts a QuadStore string value back to a dotNetRDF . + /// Handles blank nodes, angle-bracketed and plain absolute URIs, typed literals, + /// language-tagged literals, and plain literals. + /// + public static INode StringToNode(string value, INodeFactory factory) + { + if (string.IsNullOrEmpty(value)) + return factory.CreateBlankNode(); + + // Blank node: _:id + if (value.StartsWith("_:")) + return factory.CreateBlankNode(value.Substring(2)); + + // Angle-bracketed URI: + if (value.StartsWith("<") && value.EndsWith(">")) + { + var uriStr = value.Substring(1, value.Length - 2); + if (Uri.TryCreate(uriStr, UriKind.Absolute, out var uri)) + return factory.CreateUriNode(uri); + } + + // Plain absolute URI: http://... or https://... etc. + if (Uri.TryCreate(value, UriKind.Absolute, out var plainUri)) + return factory.CreateUriNode(plainUri); + + // Quoted literal (plain, typed, or language-tagged) + if (value.StartsWith("\"")) + { + // Typed literal: "value"^^ + int dtIdx = value.LastIndexOf("\"^^<"); + if (dtIdx > 0) + { + var litVal = UnescapeLiteral(value.Substring(1, dtIdx - 1)); + var dtStr = value.Substring(dtIdx + 4, value.Length - dtIdx - 5); + if (Uri.TryCreate(dtStr, UriKind.Absolute, out var dtUri)) + return factory.CreateLiteralNode(litVal, dtUri); + } + + // Language-tagged literal: "value"@lang + int langIdx = value.LastIndexOf("\"@"); + if (langIdx > 0) + { + var litVal = UnescapeLiteral(value.Substring(1, langIdx - 1)); + var lang = value.Substring(langIdx + 2); + return factory.CreateLiteralNode(litVal, lang); + } + + // Plain literal: "value" + if (value.EndsWith("\"") && value.Length >= 2) + return factory.CreateLiteralNode(UnescapeLiteral(value.Substring(1, value.Length - 2))); + } + + // Fallback: treat the raw string as a plain literal + return factory.CreateLiteralNode(value); + } + + private static string EscapeLiteral(string value) => + value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r"); + + private static string UnescapeLiteral(string value) => + value + .Replace("\\\"", "\"") + .Replace("\\n", "\n") + .Replace("\\r", "\r") + .Replace("\\\\", "\\"); +} diff --git a/test/TripleStore.Tests/QuadStoreStorageProviderTests.cs b/test/TripleStore.Tests/QuadStoreStorageProviderTests.cs new file mode 100644 index 0000000..ef1f26a --- /dev/null +++ b/test/TripleStore.Tests/QuadStoreStorageProviderTests.cs @@ -0,0 +1,683 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using TripleStore.Core; +using VDS.RDF; +using VDS.RDF.Parsing.Handlers; +using VDS.RDF.Query; +using VDS.RDF.Storage; +using Xunit; + +namespace TripleStore.Tests; + +/// +/// Tests for , verifying compliance with the +/// dotNetRDF , , and +/// contracts. +/// +public class QuadStoreStorageProviderTests +{ + // ── Helpers ────────────────────────────────────────────────────────────── + + private static QuadStore NewStore() + { + var dir = Path.Combine(Path.GetTempPath(), "qsp_" + Guid.NewGuid()); + Directory.CreateDirectory(dir); + return new QuadStore(dir); + } + + private static QuadStoreStorageProvider NewProvider(QuadStore? store = null) + => new QuadStoreStorageProvider(store ?? NewStore()); + + private static Uri U(string uri) => new Uri(uri); + + // ── Constructor ────────────────────────────────────────────────────────── + + [Fact] + public void Constructor_NullStore_Throws() + { + Action act = () => new QuadStoreStorageProvider(null!); + act.Should().Throw().WithParameterName("store"); + } + + [Fact] + public void Constructor_ValidStore_Succeeds() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + provider.Should().NotBeNull(); + provider.Dispose(); // should be a no-op + } + + // ── IStorageCapabilities ───────────────────────────────────────────────── + + [Fact] + public void IsReady_ReturnsTrue() + => NewProvider().IsReady.Should().BeTrue(); + + [Fact] + public void IsReadOnly_ReturnsFalse() + => NewProvider().IsReadOnly.Should().BeFalse(); + + [Fact] + public void UpdateSupported_ReturnsTrue() + => NewProvider().UpdateSupported.Should().BeTrue(); + + [Fact] + public void DeleteSupported_ReturnsFalse() + => NewProvider().DeleteSupported.Should().BeFalse(); + + [Fact] + public void ListGraphsSupported_ReturnsTrue() + => NewProvider().ListGraphsSupported.Should().BeTrue(); + + [Fact] + public void IOBehaviour_ContainsQuadStoreFlag() + => (NewProvider().IOBehaviour & IOBehaviour.IsQuadStore).Should().Be(IOBehaviour.IsQuadStore); + + [Fact] + public void ParentServer_ReturnsNull() + => NewProvider().ParentServer.Should().BeNull(); + + // ── SaveGraph ──────────────────────────────────────────────────────────── + + [Fact] + public void SaveGraph_Null_Throws() + { + var provider = NewProvider(); + provider.Invoking(p => p.SaveGraph(null!)) + .Should().Throw().WithParameterName("g"); + } + + [Fact] + public void SaveGraph_AddsTriplesToStore() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + + var g = new Graph(); + g.BaseUri = U("http://example.org/g1"); + var factory = new NodeFactory(); + var s = factory.CreateUriNode(U("http://example.org/Ada")); + var p = factory.CreateUriNode(U("http://example.org/knows")); + var o = factory.CreateUriNode(U("http://example.org/Bob")); + g.Assert(new Triple(s, p, o)); + + provider.SaveGraph(g); + + var rows = store.Query(graph: "http://example.org/g1").ToList(); + rows.Should().ContainSingle(); + rows[0].subject.Should().Be("http://example.org/Ada"); + rows[0].predicate.Should().Be("http://example.org/knows"); + rows[0].obj.Should().Be("http://example.org/Bob"); + } + + [Fact] + public void SaveGraph_WithLiterals_StoresCorrectly() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + + var g = new Graph(); + g.BaseUri = U("http://example.org/g2"); + var factory = new NodeFactory(); + var s = factory.CreateUriNode(U("http://example.org/Ada")); + var p = factory.CreateUriNode(U("http://example.org/name")); + var o = factory.CreateLiteralNode("Ada Lovelace", "en"); + g.Assert(new Triple(s, p, o)); + + provider.SaveGraph(g); + + var rows = store.Query(graph: "http://example.org/g2").ToList(); + rows.Should().ContainSingle(); + rows[0].obj.Should().Be("\"Ada Lovelace\"@en"); + } + + [Fact] + public void SaveGraph_WithTypedLiteral_StoresCorrectly() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + + var g = new Graph(); + g.BaseUri = U("http://example.org/g3"); + var factory = new NodeFactory(); + var s = factory.CreateUriNode(U("http://example.org/item")); + var p = factory.CreateUriNode(U("http://example.org/count")); + var o = factory.CreateLiteralNode("42", U("http://www.w3.org/2001/XMLSchema#integer")); + g.Assert(new Triple(s, p, o)); + + provider.SaveGraph(g); + + var rows = store.Query(graph: "http://example.org/g3").ToList(); + rows.Should().ContainSingle(); + rows[0].obj.Should().Be("\"42\"^^"); + } + + // ── LoadGraph (IGraph, Uri) ────────────────────────────────────────────── + + [Fact] + public void LoadGraph_ByUri_Null_IGraph_Throws() + { + var provider = NewProvider(); + provider.Invoking(p => p.LoadGraph((IGraph)null!, U("http://example.org/g"))) + .Should().Throw().WithParameterName("g"); + } + + [Fact] + public void LoadGraph_ByUri_PopulatesGraph() + { + using var store = NewStore(); + store.Append("http://example.org/Ada", "http://example.org/knows", "http://example.org/Bob", "http://example.org/g1"); + store.Append("http://example.org/Ada", "http://example.org/type", "http://example.org/Person", "http://example.org/g2"); + + var provider = new QuadStoreStorageProvider(store); + var g = new Graph(); + provider.LoadGraph(g, U("http://example.org/g1")); + + g.Triples.Should().HaveCount(1); + g.BaseUri.Should().Be(U("http://example.org/g1")); + } + + [Fact] + public void LoadGraph_ByUri_Null_Uri_LoadsAllTriples() + { + using var store = NewStore(); + store.Append("http://example.org/Ada", "http://example.org/knows", "http://example.org/Bob", "http://example.org/g1"); + store.Append("http://example.org/Bob", "http://example.org/knows", "http://example.org/Eve", "http://example.org/g2"); + + var provider = new QuadStoreStorageProvider(store); + var g = new Graph(); + provider.LoadGraph(g, (Uri)null!); + + g.Triples.Should().HaveCount(2); + } + + // ── LoadGraph (IGraph, string) ─────────────────────────────────────────── + + [Fact] + public void LoadGraph_ByString_Null_IGraph_Throws() + { + var provider = NewProvider(); + provider.Invoking(p => p.LoadGraph((IGraph)null!, "http://example.org/g")) + .Should().Throw().WithParameterName("g"); + } + + [Fact] + public void LoadGraph_ByString_PopulatesGraph() + { + using var store = NewStore(); + store.Append("http://example.org/Ada", "http://example.org/knows", "http://example.org/Bob", "http://example.org/g1"); + + var provider = new QuadStoreStorageProvider(store); + var g = new Graph(); + provider.LoadGraph(g, "http://example.org/g1"); + + g.Triples.Should().HaveCount(1); + g.BaseUri.Should().Be(U("http://example.org/g1")); + } + + [Fact] + public void LoadGraph_ByString_HandlesAngularBracketedUri() + { + using var store = NewStore(); + // Data stored with angle-bracket format + store.Append("", "", "", ""); + + var provider = new QuadStoreStorageProvider(store); + var g = new Graph(); + provider.LoadGraph(g, "http://example.org/g"); + + g.Triples.Should().HaveCount(1); + } + + [Fact] + public void LoadGraph_ByString_EmptyGraph_WhenGraphNotFound() + { + using var store = NewStore(); + store.Append("http://example.org/S", "http://example.org/P", "http://example.org/O", "http://example.org/g1"); + + var provider = new QuadStoreStorageProvider(store); + var g = new Graph(); + provider.LoadGraph(g, "http://example.org/nonexistent"); + + g.Triples.Should().BeEmpty(); + } + + // ── LoadGraph (IRdfHandler) ────────────────────────────────────────────── + + [Fact] + public void LoadGraph_ByUri_Null_Handler_Throws() + { + var provider = NewProvider(); + provider.Invoking(p => p.LoadGraph((IRdfHandler)null!, U("http://example.org/g"))) + .Should().Throw().WithParameterName("handler"); + } + + [Fact] + public void LoadGraph_ByString_Null_Handler_Throws() + { + var provider = NewProvider(); + provider.Invoking(p => p.LoadGraph((IRdfHandler)null!, "http://example.org/g")) + .Should().Throw().WithParameterName("handler"); + } + + [Fact] + public void LoadGraph_HandlerOverload_DeliversTriples() + { + using var store = NewStore(); + store.Append("http://example.org/Ada", "http://example.org/knows", "http://example.org/Bob", "http://example.org/g1"); + + var provider = new QuadStoreStorageProvider(store); + var g = new Graph(); + var handler = new GraphHandler(g); + provider.LoadGraph(handler, "http://example.org/g1"); + + g.Triples.Should().HaveCount(1); + } + + // ── UpdateGraph ────────────────────────────────────────────────────────── + + [Fact] + public void UpdateGraph_ByUri_Additions_AppendsTriples() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + var graphUri = U("http://example.org/g"); + + var factory = new NodeFactory(); + var triple = new Triple( + factory.CreateUriNode(U("http://example.org/S")), + factory.CreateUriNode(U("http://example.org/P")), + factory.CreateUriNode(U("http://example.org/O"))); + + provider.UpdateGraph(graphUri, new[] { triple }, null); + + store.Query(graph: "http://example.org/g").Should().ContainSingle(); + } + + [Fact] + public void UpdateGraph_ByString_Additions_AppendsTriples() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + + var factory = new NodeFactory(); + var triple = new Triple( + factory.CreateUriNode(U("http://example.org/S")), + factory.CreateUriNode(U("http://example.org/P")), + factory.CreateUriNode(U("http://example.org/O"))); + + provider.UpdateGraph("http://example.org/g", new[] { triple }, null); + + store.Query(graph: "http://example.org/g").Should().ContainSingle(); + } + + [Fact] + public void UpdateGraph_ByRefNode_Additions_AppendsTriples() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + + var factory = new NodeFactory(); + var graphNode = factory.CreateUriNode(U("http://example.org/g")); + var triple = new Triple( + factory.CreateUriNode(U("http://example.org/S")), + factory.CreateUriNode(U("http://example.org/P")), + factory.CreateUriNode(U("http://example.org/O"))); + + provider.UpdateGraph(graphNode, new[] { triple }, null); + + store.Query(graph: "http://example.org/g").Should().ContainSingle(); + } + + [Fact] + public void UpdateGraph_WithRemovals_Throws() + { + var provider = NewProvider(); + var factory = new NodeFactory(); + var triple = new Triple( + factory.CreateUriNode(U("http://example.org/S")), + factory.CreateUriNode(U("http://example.org/P")), + factory.CreateUriNode(U("http://example.org/O"))); + + provider.Invoking(p => p.UpdateGraph("http://example.org/g", null, new[] { triple })) + .Should().Throw() + .WithMessage("*append-only*"); + } + + [Fact] + public void UpdateGraph_EmptyRemovals_DoesNotThrow() + { + var provider = NewProvider(); + provider.Invoking(p => p.UpdateGraph("http://example.org/g", null, Array.Empty())) + .Should().NotThrow(); + } + + // ── DeleteGraph ────────────────────────────────────────────────────────── + + [Fact] + public void DeleteGraph_ByUri_AlwaysThrows() + { + var provider = NewProvider(); + provider.Invoking(p => p.DeleteGraph(U("http://example.org/g"))) + .Should().Throw() + .WithMessage("*append-only*"); + } + + [Fact] + public void DeleteGraph_ByString_AlwaysThrows() + { + var provider = NewProvider(); + provider.Invoking(p => p.DeleteGraph("http://example.org/g")) + .Should().Throw() + .WithMessage("*append-only*"); + } + + // ── ListGraphs / ListGraphNames ────────────────────────────────────────── + + [Fact] + public void ListGraphs_ReturnsDistinctUris() + { + using var store = NewStore(); + store.Append("http://example.org/S1", "http://example.org/P", "http://example.org/O", "http://example.org/g1"); + store.Append("http://example.org/S2", "http://example.org/P", "http://example.org/O", "http://example.org/g2"); + store.Append("http://example.org/S3", "http://example.org/P", "http://example.org/O", "http://example.org/g1"); + + var provider = new QuadStoreStorageProvider(store); + var graphs = provider.ListGraphs().ToList(); + + graphs.Should().HaveCount(2); + graphs.Select(g => g.AbsoluteUri).Should().Contain("http://example.org/g1"); + graphs.Select(g => g.AbsoluteUri).Should().Contain("http://example.org/g2"); + } + + [Fact] + public void ListGraphNames_ReturnsDistinctStrings() + { + using var store = NewStore(); + store.Append("http://example.org/S", "http://example.org/P", "http://example.org/O", "http://example.org/g1"); + store.Append("http://example.org/S", "http://example.org/P", "http://example.org/O", "http://example.org/g2"); + + var provider = new QuadStoreStorageProvider(store); + var names = provider.ListGraphNames().ToList(); + + names.Should().HaveCount(2); + names.Should().Contain("http://example.org/g1"); + names.Should().Contain("http://example.org/g2"); + } + + [Fact] + public void ListGraphs_OnEmptyStore_ReturnsEmpty() + { + NewProvider().ListGraphs().Should().BeEmpty(); + } + + [Fact] + public void ListGraphNames_OnEmptyStore_ReturnsEmpty() + { + NewProvider().ListGraphNames().Should().BeEmpty(); + } + + // ── IQueryableStorage ──────────────────────────────────────────────────── + + [Fact] + public void Query_NullSparql_Throws() + { + var provider = NewProvider(); + provider.Invoking(p => ((IQueryableStorage)p).Query(null!)) + .Should().Throw().WithParameterName("sparqlQuery"); + } + + [Fact] + public void Query_SelectReturnsResultSet() + { + using var store = NewStore(); + store.Append("http://example.org/Ada", "http://example.org/knows", "http://example.org/Bob", "http://example.org/g"); + var provider = new QuadStoreStorageProvider(store); + + var result = ((IQueryableStorage)provider).Query("SELECT ?s ?o WHERE { ?s ?o }"); + + result.Should().BeOfType(); + var rs = (SparqlResultSet)result; + rs.Should().HaveCount(1); + rs.First()["s"].ToString().Should().Contain("Ada"); + rs.First()["o"].ToString().Should().Contain("Bob"); + } + + [Fact] + public void Query_AskReturnsTrueWhenMatchFound() + { + using var store = NewStore(); + store.Append("http://example.org/S", "http://example.org/P", "http://example.org/O", "http://example.org/g"); + var provider = new QuadStoreStorageProvider(store); + + var result = ((IQueryableStorage)provider).Query("ASK { }"); + + result.Should().BeOfType(); + ((SparqlResultSet)result).Result.Should().BeTrue(); + } + + [Fact] + public void Query_AskReturnsFalseWhenNoMatch() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + + var result = ((IQueryableStorage)provider).Query("ASK { }"); + + result.Should().BeOfType(); + ((SparqlResultSet)result).Result.Should().BeFalse(); + } + + [Fact] + public void Query_ConstructReturnsGraph() + { + using var store = NewStore(); + store.Append("http://example.org/Ada", "http://example.org/knows", "http://example.org/Bob", "http://example.org/g"); + var provider = new QuadStoreStorageProvider(store); + + var result = ((IQueryableStorage)provider).Query( + "CONSTRUCT { ?s ?o } WHERE { ?s ?o }"); + + result.Should().BeAssignableTo(); + var g = (IGraph)result; + g.Triples.Should().ContainSingle(); + } + + [Fact] + public void Query_HandlerOverload_NullSparql_Throws() + { + var provider = NewProvider(); + provider.Invoking(p => ((IQueryableStorage)p).Query(null!, null!, null!)) + .Should().Throw().WithParameterName("sparqlQuery"); + } + + [Fact] + public void Query_HandlerOverload_DeliversResultsToHandler() + { + using var store = NewStore(); + store.Append("http://example.org/Ada", "http://example.org/knows", "http://example.org/Bob", "http://example.org/g"); + var provider = new QuadStoreStorageProvider(store); + + var resultSet = new SparqlResultSet(); + var handler = new ResultSetHandler(resultSet); + ((IQueryableStorage)provider).Query(null, handler, "SELECT ?s WHERE { ?s ?o }"); + + resultSet.Should().HaveCount(1); + } + + // ── IUpdateableStorage ─────────────────────────────────────────────────── + + [Fact] + public void Update_AlwaysThrows() + { + var provider = NewProvider(); + provider.Invoking(p => ((IUpdateableStorage)p).Update("INSERT DATA { }")) + .Should().Throw() + .WithMessage("*SPARQL Update*"); + } + + // ── NodeToString / StringToNode round-trip ─────────────────────────────── + + [Fact] + public void RoundTrip_UriNode() + { + var factory = new NodeFactory(); + var node = factory.CreateUriNode(U("http://example.org/Ada")); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("http://example.org/Ada"); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((IUriNode)back).Uri.Should().Be(U("http://example.org/Ada")); + } + + [Fact] + public void RoundTrip_PlainLiteral() + { + var factory = new NodeFactory(); + var node = factory.CreateLiteralNode("hello world"); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("\"hello world\""); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((ILiteralNode)back).Value.Should().Be("hello world"); + } + + [Fact] + public void RoundTrip_LanguageLiteral() + { + var factory = new NodeFactory(); + var node = factory.CreateLiteralNode("hola", "es"); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("\"hola\"@es"); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((ILiteralNode)back).Language.Should().Be("es"); + ((ILiteralNode)back).Value.Should().Be("hola"); + } + + [Fact] + public void RoundTrip_TypedLiteral() + { + var factory = new NodeFactory(); + var node = factory.CreateLiteralNode("42", U("http://www.w3.org/2001/XMLSchema#integer")); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("\"42\"^^"); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((ILiteralNode)back).DataType.Should().Be(U("http://www.w3.org/2001/XMLSchema#integer")); + ((ILiteralNode)back).Value.Should().Be("42"); + } + + [Fact] + public void RoundTrip_BlankNode() + { + var factory = new NodeFactory(); + var node = factory.CreateBlankNode("myBlank"); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("_:myBlank"); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((IBlankNode)back).InternalID.Should().Be("myBlank"); + } + + [Fact] + public void RoundTrip_LiteralWithSpecialChars() + { + var factory = new NodeFactory(); + var node = factory.CreateLiteralNode("line1\nline2"); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("\"line1\\nline2\""); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((ILiteralNode)back).Value.Should().Be("line1\nline2"); + } + + // ── StringToNode edge cases ─────────────────────────────────────────────── + + [Fact] + public void StringToNode_NullOrEmpty_ReturnsBlankNode() + { + var factory = new NodeFactory(); + QuadStoreStorageProvider.StringToNode(null!, factory).Should().BeAssignableTo(); + QuadStoreStorageProvider.StringToNode(string.Empty, factory).Should().BeAssignableTo(); + } + + [Fact] + public void StringToNode_AngledBracketUri_ReturnsUriNode() + { + var factory = new NodeFactory(); + var node = QuadStoreStorageProvider.StringToNode("", factory); + node.Should().BeAssignableTo(); + ((IUriNode)node).Uri.Should().Be(U("http://example.org/test")); + } + + [Fact] + public void StringToNode_FallbackNonUri_ReturnsLiteralNode() + { + var factory = new NodeFactory(); + // Something that looks like neither a URI nor a quoted literal + var node = QuadStoreStorageProvider.StringToNode("just-some-text", factory); + node.Should().BeAssignableTo(); + } + + // ── Integration: SaveGraph → LoadGraph round-trip ──────────────────────── + + [Fact] + public void SaveAndLoad_RoundTrip_PreservesTriples() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + var graphUri = U("http://example.org/rt"); + + var original = new Graph(); + original.BaseUri = graphUri; + var factory = new NodeFactory(); + original.Assert(new Triple( + factory.CreateUriNode(U("http://example.org/Ada")), + factory.CreateUriNode(U("http://example.org/knows")), + factory.CreateUriNode(U("http://example.org/Bob")))); + original.Assert(new Triple( + factory.CreateUriNode(U("http://example.org/Ada")), + factory.CreateUriNode(U("http://example.org/name")), + factory.CreateLiteralNode("Ada Lovelace", "en"))); + + provider.SaveGraph(original); + + var loaded = new Graph(); + provider.LoadGraph(loaded, graphUri); + + loaded.Triples.Should().HaveCount(2); + loaded.BaseUri.Should().Be(graphUri); + } + + // ── Integration: UpdateGraph → LoadGraph ───────────────────────────────── + + [Fact] + public void UpdateGraph_ThenLoad_ReflectsAdditions() + { + using var store = NewStore(); + var provider = new QuadStoreStorageProvider(store); + var graphUri = U("http://example.org/ug"); + var factory = new NodeFactory(); + + var t1 = new Triple( + factory.CreateUriNode(U("http://example.org/S1")), + factory.CreateUriNode(U("http://example.org/P")), + factory.CreateUriNode(U("http://example.org/O1"))); + var t2 = new Triple( + factory.CreateUriNode(U("http://example.org/S2")), + factory.CreateUriNode(U("http://example.org/P")), + factory.CreateUriNode(U("http://example.org/O2"))); + + provider.UpdateGraph(graphUri, new[] { t1 }, null); + provider.UpdateGraph(graphUri, new[] { t2 }, null); + + var loaded = new Graph(); + provider.LoadGraph(loaded, graphUri); + loaded.Triples.Should().HaveCount(2); + } +} From 6184004f1bb25b560c9dfa1697910d3ba89ef2b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:10:45 +0000 Subject: [PATCH 3/5] Apply code review: use range operators instead of Substring in QuadStoreStorageProvider Co-authored-by: aabs <157775+aabs@users.noreply.github.com> --- src/TripleStore.Core/QuadStoreStorageProvider.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/TripleStore.Core/QuadStoreStorageProvider.cs b/src/TripleStore.Core/QuadStoreStorageProvider.cs index 8e3cfeb..cfaaa4a 100644 --- a/src/TripleStore.Core/QuadStoreStorageProvider.cs +++ b/src/TripleStore.Core/QuadStoreStorageProvider.cs @@ -329,7 +329,7 @@ private void UpdateGraphInternal(string graphUri, IEnumerable additions, // Normalise: derive both the plain and angle-bracketed form of the URI. string plain = graphUri.StartsWith("<") && graphUri.EndsWith(">") - ? graphUri.Substring(1, graphUri.Length - 2) + ? graphUri[1..^1] : graphUri; string bracketed = $"<{plain}>"; @@ -404,12 +404,12 @@ public static INode StringToNode(string value, INodeFactory factory) // Blank node: _:id if (value.StartsWith("_:")) - return factory.CreateBlankNode(value.Substring(2)); + return factory.CreateBlankNode(value[2..]); // Angle-bracketed URI: if (value.StartsWith("<") && value.EndsWith(">")) { - var uriStr = value.Substring(1, value.Length - 2); + var uriStr = value[1..^1]; if (Uri.TryCreate(uriStr, UriKind.Absolute, out var uri)) return factory.CreateUriNode(uri); } @@ -425,8 +425,8 @@ public static INode StringToNode(string value, INodeFactory factory) int dtIdx = value.LastIndexOf("\"^^<"); if (dtIdx > 0) { - var litVal = UnescapeLiteral(value.Substring(1, dtIdx - 1)); - var dtStr = value.Substring(dtIdx + 4, value.Length - dtIdx - 5); + var litVal = UnescapeLiteral(value[1..dtIdx]); + var dtStr = value[(dtIdx + 4)..^1]; if (Uri.TryCreate(dtStr, UriKind.Absolute, out var dtUri)) return factory.CreateLiteralNode(litVal, dtUri); } @@ -435,14 +435,14 @@ public static INode StringToNode(string value, INodeFactory factory) int langIdx = value.LastIndexOf("\"@"); if (langIdx > 0) { - var litVal = UnescapeLiteral(value.Substring(1, langIdx - 1)); - var lang = value.Substring(langIdx + 2); + var litVal = UnescapeLiteral(value[1..langIdx]); + var lang = value[(langIdx + 2)..]; return factory.CreateLiteralNode(litVal, lang); } // Plain literal: "value" if (value.EndsWith("\"") && value.Length >= 2) - return factory.CreateLiteralNode(UnescapeLiteral(value.Substring(1, value.Length - 2))); + return factory.CreateLiteralNode(UnescapeLiteral(value[1..^1])); } // Fallback: treat the raw string as a plain literal From 278d9ec0d8e8f6cf5a5a9c87bba96f2f195fdf5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:55:35 +0000 Subject: [PATCH 4/5] Fix UnescapeLiteral scanner, angle-bracket normalisation, and add tests for review feedback Co-authored-by: aabs <157775+aabs@users.noreply.github.com> --- .../QuadStoreStorageProvider.cs | 66 +++++++++--- .../QuadStoreStorageProviderTests.cs | 102 ++++++++++++++++++ 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/src/TripleStore.Core/QuadStoreStorageProvider.cs b/src/TripleStore.Core/QuadStoreStorageProvider.cs index cfaaa4a..589b026 100644 --- a/src/TripleStore.Core/QuadStoreStorageProvider.cs +++ b/src/TripleStore.Core/QuadStoreStorageProvider.cs @@ -106,7 +106,8 @@ public void LoadGraph(IGraph g, string graphUri) { if (g == null) throw new ArgumentNullException(nameof(g)); LoadGraphInternal(g, graphUri); - if (graphUri != null && Uri.TryCreate(graphUri, UriKind.Absolute, out var uri)) + var normalised = graphUri != null ? NormaliseGraphUri(graphUri) : null; + if (normalised != null && Uri.TryCreate(normalised, UriKind.Absolute, out var uri)) g.BaseUri = uri; } @@ -187,7 +188,7 @@ public void DeleteGraph(string graphUri) public IEnumerable ListGraphs() { return _store.Query() - .Select(q => q.graph) + .Select(q => NormaliseGraphUri(q.graph)) .Distinct() .Where(g => Uri.TryCreate(g, UriKind.Absolute, out _)) .Select(g => new Uri(g)); @@ -197,7 +198,7 @@ public IEnumerable ListGraphs() public IEnumerable ListGraphNames() { return _store.Query() - .Select(q => q.graph) + .Select(q => NormaliseGraphUri(q.graph)) .Distinct(); } @@ -328,9 +329,7 @@ private void UpdateGraphInternal(string graphUri, IEnumerable additions, return _store.Query(); // Normalise: derive both the plain and angle-bracketed form of the URI. - string plain = graphUri.StartsWith("<") && graphUri.EndsWith(">") - ? graphUri[1..^1] - : graphUri; + string plain = NormaliseGraphUri(graphUri); string bracketed = $"<{plain}>"; var seen = new HashSet<(string, string, string, string)>(); @@ -344,6 +343,16 @@ private void UpdateGraphInternal(string graphUri, IEnumerable additions, return Merge(); } + /// + /// Builds a snapshot by loading every named graph + /// from the QuadStore into a fresh in-memory dataset. + /// + /// + /// Performance note: this method creates a full in-memory copy of the store on + /// every call. For large stores or high query rates, consider using a purpose-built + /// in-memory RDF store (e.g. populated once) and + /// reloading only on writes. + /// private LeviathanQueryProcessor CreateQueryProcessor() { var tripleStore = new VDS.RDF.TripleStore(); @@ -359,6 +368,13 @@ private LeviathanQueryProcessor CreateQueryProcessor() return new LeviathanQueryProcessor(new InMemoryDataset(tripleStore, unionDefaultGraph: true)); } + /// + /// Strips surrounding angle brackets from a stored graph URI string, returning the + /// plain absolute URI. If the value is not angle-bracketed it is returned unchanged. + /// + private static string NormaliseGraphUri(string graphUri) => + graphUri.StartsWith("<") && graphUri.EndsWith(">") ? graphUri[1..^1] : graphUri; + private const string XsdStringUri = "http://www.w3.org/2001/XMLSchema#string"; /// @@ -456,10 +472,36 @@ private static string EscapeLiteral(string value) => .Replace("\n", "\\n") .Replace("\r", "\\r"); - private static string UnescapeLiteral(string value) => - value - .Replace("\\\"", "\"") - .Replace("\\n", "\n") - .Replace("\\r", "\r") - .Replace("\\\\", "\\"); + private static string UnescapeLiteral(string value) + { + // Process escape sequences token-by-token to avoid chained Replace corruption. + // e.g. "\\n" must become backslash+n, not a newline. + var sb = new System.Text.StringBuilder(value.Length); + int i = 0; + while (i < value.Length) + { + if (value[i] == '\\' && i + 1 < value.Length) + { + switch (value[i + 1]) + { + case '\\': sb.Append('\\'); i += 2; break; + case '"': sb.Append('"'); i += 2; break; + case 'n': sb.Append('\n'); i += 2; break; + case 'r': sb.Append('\r'); i += 2; break; + default: + // Unknown escape sequence: pass through the backslash and let the + // next iteration handle the following character (e.g. \x → \x). + sb.Append(value[i]); + i++; + break; + } + } + else + { + sb.Append(value[i]); + i++; + } + } + return sb.ToString(); + } } diff --git a/test/TripleStore.Tests/QuadStoreStorageProviderTests.cs b/test/TripleStore.Tests/QuadStoreStorageProviderTests.cs index ef1f26a..4436dc7 100644 --- a/test/TripleStore.Tests/QuadStoreStorageProviderTests.cs +++ b/test/TripleStore.Tests/QuadStoreStorageProviderTests.cs @@ -394,6 +394,21 @@ public void ListGraphs_ReturnsDistinctUris() graphs.Select(g => g.AbsoluteUri).Should().Contain("http://example.org/g2"); } + [Fact] + public void ListGraphs_WithAngledBracketUris_NormalisesAndReturnsUris() + { + // Data stored with angle-bracketed graph URIs (as produced by the TriG loader) + using var store = NewStore(); + store.Append("", "", "", ""); + + var provider = new QuadStoreStorageProvider(store); + var graphs = provider.ListGraphs().ToList(); + + // Should return a proper Uri, not a string like "" + graphs.Should().ContainSingle(); + graphs[0].AbsoluteUri.Should().Be("http://example.org/g"); + } + [Fact] public void ListGraphNames_ReturnsDistinctStrings() { @@ -409,6 +424,41 @@ public void ListGraphNames_ReturnsDistinctStrings() names.Should().Contain("http://example.org/g2"); } + [Fact] + public void ListGraphNames_WithAngledBracketUris_StripsAngleBrackets() + { + // Data stored with angle-bracketed graph URIs (as produced by the TriG loader) + using var store = NewStore(); + store.Append("", "", "", ""); + store.Append("", "", "", ""); + + var provider = new QuadStoreStorageProvider(store); + var names = provider.ListGraphNames().ToList(); + + names.Should().HaveCount(2); + names.Should().Contain("http://example.org/g1"); + names.Should().Contain("http://example.org/g2"); + names.Should().NotContain(""); + names.Should().NotContain(""); + } + + [Fact] + public void ListGraphNames_WithMixedUriFormats_NormalisesAll() + { + // One graph stored with angle brackets, one without — both should appear as plain URIs. + using var store = NewStore(); + store.Append("", "", "", ""); + store.Append("http://example.org/S", "http://example.org/P", "http://example.org/O", "http://example.org/plain"); + + var provider = new QuadStoreStorageProvider(store); + var names = provider.ListGraphNames().ToList(); + + names.Should().HaveCount(2); + names.Should().Contain("http://example.org/bracketed"); + names.Should().Contain("http://example.org/plain"); + names.Should().NotContain(""); + } + [Fact] public void ListGraphs_OnEmptyStore_ReturnsEmpty() { @@ -596,6 +646,58 @@ public void RoundTrip_LiteralWithSpecialChars() ((ILiteralNode)back).Value.Should().Be("line1\nline2"); } + [Fact] + public void RoundTrip_LiteralWithBackslashN() + { + // Literal is the two-char string backslash + 'n', NOT a newline character. + var factory = new NodeFactory(); + var node = factory.CreateLiteralNode("value\\ntext"); + var str = QuadStoreStorageProvider.NodeToString(node); + // EscapeLiteral turns \ into \\ and then the n is untouched → \\n in the stored string. + str.Should().Be("\"value\\\\ntext\""); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((ILiteralNode)back).Value.Should().Be("value\\ntext"); + } + + [Fact] + public void RoundTrip_LiteralWithBackslashR() + { + // Literal is backslash + 'r', NOT a carriage-return. + var factory = new NodeFactory(); + var node = factory.CreateLiteralNode("value\\rtext"); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("\"value\\\\rtext\""); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((ILiteralNode)back).Value.Should().Be("value\\rtext"); + } + + [Fact] + public void RoundTrip_LiteralWithBackslashQuote() + { + // Literal is backslash + '"'. + var factory = new NodeFactory(); + var node = factory.CreateLiteralNode("say \\\"hello\\\""); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("\"say \\\\\\\"hello\\\\\\\"\""); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((ILiteralNode)back).Value.Should().Be("say \\\"hello\\\""); + } + + [Fact] + public void RoundTrip_LiteralWithCarriageReturn() + { + var factory = new NodeFactory(); + var node = factory.CreateLiteralNode("line1\rline2"); + var str = QuadStoreStorageProvider.NodeToString(node); + str.Should().Be("\"line1\\rline2\""); + var back = QuadStoreStorageProvider.StringToNode(str, factory); + back.Should().BeAssignableTo(); + ((ILiteralNode)back).Value.Should().Be("line1\rline2"); + } + // ── StringToNode edge cases ─────────────────────────────────────────────── [Fact] From 994f3ea4da59c00ccbaaf7a326d67ee3d03ffbf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:02:57 +0000 Subject: [PATCH 5/5] Rewrite README for Leviathan SPARQL endpoint usage and limitations Co-authored-by: aabs <157775+aabs@users.noreply.github.com> --- README.md | 113 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7b463e4..6a8617e 100644 --- a/README.md +++ b/README.md @@ -22,39 +22,116 @@ ## 🚀 Quick Start -### Load and query RDF data with SPARQL +### 1) Use QuadStore through dotNetRDF + Leviathan + +`QuadStore` is the core in-memory quad store. +`QuadStoreStorageProvider` adapts it to the standard dotNetRDF storage interfaces. +That means Leviathan can run SPARQL queries against your QuadStore data. ```csharp using TripleStore.Core; +using VDS.RDF; +using VDS.RDF.Query; +using VDS.RDF.Storage; -// Create a quad store using var store = new QuadStore("./store-data"); - -// Load TriG data in a single pass var loader = new SinglePassTrigLoader(store); loader.LoadFromFile("data.trig"); -// Query with SPARQL -var engine = new MinimalSparqlEngine(store); -var sparql = @" - SELECT ?person ?name ?friend +IStorageProvider provider = new QuadStoreStorageProvider(store); +var queryable = (IQueryableStorage)provider; + +// Standards-based SPARQL query execution via Leviathan +var query = @" + SELECT ?person ?name WHERE { ?person ?name . - ?person ?friend . - } -"; -var results = engine.ExecuteQuery(sparql); + }"; -// Iterate through results -foreach (var binding in results) +var results = (SparqlResultSet)queryable.Query(query); + +foreach (var row in results) { - var person = binding["?person"]; - var name = binding["?name"]; - var friend = binding["?friend"]; - Console.WriteLine($"{name} ({person}) knows {friend}"); + Console.WriteLine($"{row["person"]} -> {row["name"]}"); } ``` +### 2) Minimal ASP.NET endpoint (SPARQL Protocol style) + +This example exposes a simple `/sparql` endpoint that accepts a SPARQL query and returns: +- SPARQL JSON results for `SELECT`/`ASK` +- Turtle for `CONSTRUCT`/`DESCRIBE` + +```csharp +using TripleStore.Core; +using VDS.RDF; +using VDS.RDF.Query; +using VDS.RDF.Storage; +using VDS.RDF.Writing; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +var quadStore = new QuadStore("./store-data"); +var provider = new QuadStoreStorageProvider(quadStore); +var queryable = (IQueryableStorage)provider; +const int maxSparqlQueryLength = 100_000; // tune for your deployment + +app.MapGet("/sparql", (HttpContext http) => +{ + var sparql = http.Request.Query["query"].ToString(); + if (string.IsNullOrWhiteSpace(sparql)) + { + return Results.BadRequest("Missing 'query' parameter."); + } + if (sparql.Length > maxSparqlQueryLength) + { + return Results.BadRequest("Query too large."); + } + + object result; + try + { + result = queryable.Query(sparql); + } + catch (RdfQueryException ex) + { + return Results.BadRequest($"Invalid SPARQL query: {ex.Message}"); + } + catch (Exception ex) + { + return Results.Problem($"SPARQL execution failed: {ex.Message}"); + } + + if (result is SparqlResultSet set) + { + var sw = new StringWriter(); + new SparqlJsonWriter().Save(set, sw); + return Results.Text(sw.ToString(), "application/sparql-results+json"); + } + + if (result is IGraph graph) + { + var sw = new StringWriter(); + new CompressingTurtleWriter().Save(graph, sw); + return Results.Text(sw.ToString(), "text/turtle"); + } + + return Results.Problem("Unsupported SPARQL result type."); +}); + +app.Run(); +``` + +## ⚠️ Current Limitations + +QuadStore + `QuadStoreStorageProvider` is intentionally append-only currently: + +- ❌ **No graph deletion** (`DeleteGraph` is not supported) +- ❌ **No triple removal in `UpdateGraph`** (additions only) +- ❌ **No SPARQL Update** (`IUpdateableStorage.Update` throws) +- ℹ️ **Per-query in-memory snapshot for Leviathan queries** (simple and correct, but can be slower on very large datasets) + ## 📊 Performance at a Glance