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