mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Add SPARQL CONSTRUCT support to Jena backend (#754)
Extends CONSTRUCT support to JenaStore, which uses rdflib.Graph natively rather than an HTTP protocol - CONSTRUCT results come as native 3-tuples with no Accept-header/parsing dance needed, unlike Blazegraph/RDF4J. - CONSTRUCT-aware execute_sparql: reuses shared sparql_escaping.CONSTRUCT_QUERY_RE, extracts datatype/language from rdflib Literal objects into the same 4-tuple metadata contract used by Blazegraph/RDF4J - Non-CONSTRUCT path (SELECT/ASK) confirmed byte-for-byte unchanged (Property 9) - execute_construct_template confirmed backend-agnostic against JenaStore, zero changes needed - Named-graph support explicitly out of scope - JenaStore wraps a single rdflib.Graph with no named-graph concept; add_triplets continues to silently ignore graph= exactly as before. Tracked separately as a follow-up issue requiring a Graph -> ConjunctiveGraph/Dataset migration.
This commit is contained in:
@@ -32,6 +32,7 @@ from ..semantic_extract.triplet_extractor import Triplet
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from . import sparql_escaping
|
||||
|
||||
# Optional Jena imports
|
||||
try:
|
||||
@@ -75,6 +76,10 @@ class JenaStore:
|
||||
self.graph: Optional[Graph] = None
|
||||
self._initialize_graph()
|
||||
|
||||
def _is_construct_query(self, query: str) -> bool:
|
||||
"""Check if query is a CONSTRUCT query."""
|
||||
return bool(sparql_escaping.CONSTRUCT_QUERY_RE.search(query))
|
||||
|
||||
def _initialize_graph(self) -> None:
|
||||
"""Initialize RDF graph."""
|
||||
if HAS_JENA_RDFLIB:
|
||||
@@ -277,8 +282,43 @@ class JenaStore:
|
||||
raise ProcessingError("Graph not initialized")
|
||||
|
||||
try:
|
||||
result_format = options.get("result_format")
|
||||
if result_format is None:
|
||||
result_format = "construct" if self._is_construct_query(query) else "bindings"
|
||||
elif result_format not in ("construct", "bindings"):
|
||||
raise ValidationError(f"Invalid result_format: {result_format!r}")
|
||||
|
||||
results = self.graph.query(query)
|
||||
|
||||
if result_format == "construct":
|
||||
triples = []
|
||||
try:
|
||||
for s, p, o in results:
|
||||
obj_metadata: Dict[str, Any] = {}
|
||||
if isinstance(o, Literal):
|
||||
if o.datatype is not None:
|
||||
obj_metadata["datatype"] = str(o.datatype)
|
||||
if o.language is not None:
|
||||
obj_metadata["language"] = str(o.language)
|
||||
triples.append((str(s), str(p), str(o), obj_metadata))
|
||||
except ValueError as e:
|
||||
raise ValidationError(
|
||||
"result_format='construct' was explicitly requested, but "
|
||||
"the query does not appear to be a CONSTRUCT query (results "
|
||||
"cannot be unpacked into 3-tuples)."
|
||||
) from e
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"bindings": [],
|
||||
"variables": [],
|
||||
"triples": triples,
|
||||
"metadata": {
|
||||
"query": query,
|
||||
"result_format": "construct",
|
||||
},
|
||||
}
|
||||
|
||||
bindings = []
|
||||
variables = []
|
||||
|
||||
@@ -304,6 +344,8 @@ class JenaStore:
|
||||
"variables": variables,
|
||||
"metadata": {"query": query},
|
||||
}
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(f"SPARQL query failed: {e}")
|
||||
raise ProcessingError(f"SPARQL query failed: {e}")
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from rdflib import Graph, URIRef, Literal, Namespace
|
||||
from semantica.triplet_store.jena_store import JenaStore
|
||||
from semantica.semantic_extract.triplet_extractor import Triplet
|
||||
from semantica.utils.exceptions import ValidationError
|
||||
from semantica.triplet_store.construct_templates import execute_construct_template, ConstructTemplate
|
||||
|
||||
class TestJenaStoreExecuteSparqlConstructPath(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.store = JenaStore()
|
||||
# Ensure we use an in-memory graph
|
||||
self.store.graph = Graph()
|
||||
|
||||
def test_construct_parses_triples_from_rdflib_natively(self):
|
||||
# Insert some test data
|
||||
self.store.graph.parse(data='<http://ex.org/s> <http://ex.org/p> <http://ex.org/o> .', format='nt')
|
||||
|
||||
query = "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"
|
||||
result = self.store.execute_sparql(query)
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(result['bindings'], [])
|
||||
self.assertEqual(result['variables'], [])
|
||||
self.assertIn('triples', result)
|
||||
self.assertEqual(result['metadata']['result_format'], 'construct')
|
||||
|
||||
triples = result['triples']
|
||||
self.assertEqual(len(triples), 1)
|
||||
s, p, o, meta = triples[0]
|
||||
self.assertEqual(s, 'http://ex.org/s')
|
||||
self.assertEqual(p, 'http://ex.org/p')
|
||||
self.assertEqual(o, 'http://ex.org/o')
|
||||
self.assertEqual(meta, {})
|
||||
|
||||
def test_typed_literal_datatype_preserved_in_metadata(self):
|
||||
data = '<http://ex.org/s> <http://ex.org/age> "42"^^<http://www.w3.org/2001/XMLSchema#integer> .'
|
||||
self.store.graph.parse(data=data, format='nt')
|
||||
|
||||
query = "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"
|
||||
result = self.store.execute_sparql(query)
|
||||
|
||||
triples = result['triples']
|
||||
self.assertEqual(len(triples), 1)
|
||||
s, p, o, meta = triples[0]
|
||||
self.assertEqual(o, '42')
|
||||
self.assertEqual(meta['datatype'], 'http://www.w3.org/2001/XMLSchema#integer')
|
||||
self.assertNotIn('language', meta)
|
||||
|
||||
def test_language_tagged_literal_preserved_in_metadata(self):
|
||||
data = '<http://ex.org/s> <http://ex.org/label> "hello"@en .'
|
||||
self.store.graph.parse(data=data, format='nt')
|
||||
|
||||
query = "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"
|
||||
result = self.store.execute_sparql(query)
|
||||
|
||||
triples = result['triples']
|
||||
self.assertEqual(len(triples), 1)
|
||||
s, p, o, meta = triples[0]
|
||||
self.assertEqual(o, 'hello')
|
||||
self.assertEqual(meta['language'], 'en')
|
||||
self.assertNotIn('datatype', meta)
|
||||
|
||||
|
||||
class TestJenaStoreProperty9NonConstructUnchanged(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.store = JenaStore()
|
||||
self.store.graph = Graph()
|
||||
self.store.graph.parse(data='<http://ex.org/s> <http://ex.org/p> <http://ex.org/o> .', format='nt')
|
||||
|
||||
def test_select_response_shape_unchanged(self):
|
||||
query = "SELECT ?s ?p ?o WHERE { ?s ?p ?o }"
|
||||
result = self.store.execute_sparql(query)
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
self.assertIn('bindings', result)
|
||||
self.assertEqual(len(result['bindings']), 1)
|
||||
self.assertNotIn('triples', result)
|
||||
self.assertEqual(result['metadata']['query'], query)
|
||||
|
||||
binding = result['bindings'][0]
|
||||
self.assertEqual(binding['s']['value'], 'http://ex.org/s')
|
||||
self.assertEqual(binding['s']['type'], 'uri')
|
||||
|
||||
def test_ask_uses_json_not_turtle_path(self):
|
||||
query = "ASK WHERE { ?s ?p ?o }"
|
||||
result = self.store.execute_sparql(query)
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
# For ASK in rdflib, results is a bool wrapped in SPARQLResult.
|
||||
# results.vars is None, so it returns empty bindings. This matches the byte-for-byte behavior.
|
||||
self.assertEqual(result['bindings'], [])
|
||||
self.assertNotIn('triples', result)
|
||||
|
||||
class TestExecuteConstructTemplateWithJenaBackend(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.store = JenaStore()
|
||||
self.store.graph = Graph()
|
||||
self.store.graph.parse(data='<http://ex.org/s> <http://ex.org/name> "Alice" .', format='nt')
|
||||
|
||||
def test_end_to_end_with_jena_backend(self):
|
||||
template = ConstructTemplate(
|
||||
name="test",
|
||||
description="test",
|
||||
construct_query="""
|
||||
CONSTRUCT {
|
||||
?s <http://ex.org/isPerson> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
|
||||
} WHERE {
|
||||
?s <http://ex.org/name> ?name .
|
||||
}
|
||||
""",
|
||||
parameters=[]
|
||||
)
|
||||
|
||||
results = execute_construct_template(template, {}, self.store)
|
||||
self.assertEqual(len(results), 1)
|
||||
triplet = results[0]
|
||||
self.assertEqual(triplet.subject, 'http://ex.org/s')
|
||||
self.assertEqual(triplet.predicate, 'http://ex.org/isPerson')
|
||||
self.assertEqual(triplet.object, 'true')
|
||||
self.assertEqual(triplet.metadata.get('datatype'), 'http://www.w3.org/2001/XMLSchema#boolean')
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user