fix(triplet-store): coerce non-string IDs and guard known vocabulary prefixes in _resolve_iri

Bug 1 — non-string IDs crash store():
_resolve_iri() called .startswith() directly on local, causing AttributeError
when upstream graph builders emit integer entity/relationship IDs. Fixed by
coercing local to str() at entry; None/empty returns a safe urn: sentinel.

Bug 2 — prefixed W3C terms mis-resolved under base_uri:
Values like 'owl:Thing' and 'xsd:date' were not recognised as absolute IRIs
and with base_uri set were rewritten to e.g. https://example.com/owl:Thing,
corrupting standard OWL/XSD IRIs in stored triples. Fixed by adding a known-
prefix expansion table (xsd/rdf/rdfs/owl/skos/semantica) that is checked before
base_uri is applied, matching the same prefix map already used in blazegraph_store.

Added 5 regression tests covering both bugs: integer IDs with/without base_uri,
owl:Thing domain/range, xsd:date range, and rdfs:/skos: parent class expansion.
This commit is contained in:
KaifAhmad1
2026-04-11 16:51:39 +05:30
parent 0b52b715dc
commit 9d0744e20e
2 changed files with 105 additions and 5 deletions
+30 -5
View File
@@ -181,15 +181,40 @@ class TripletStore:
if base_uri and not base_uri.endswith(("/", "#")):
base_uri = base_uri + "/"
def _resolve_iri(local: str, kind: str) -> str:
# Known W3C vocabulary prefixes — expanded before base_uri is applied so
# values like "owl:Thing" or "xsd:date" are never re-namespaced under
# the ontology's own base URI.
_KNOWN_PREFIXES = {
"xsd": "http://www.w3.org/2001/XMLSchema#",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"owl": "http://www.w3.org/2002/07/owl#",
"skos": "http://www.w3.org/2004/02/skos/core#",
"semantica": "https://semantica.dev/ontology/",
}
def _resolve_iri(local: object, kind: str) -> str:
"""Expand a bare local name to a full IRI.
If *base_uri* is available the local name is appended to it so the
resulting IRI sits in the same namespace as the ontology classes.
Falls back to ``urn:<kind>:<local>`` only when no base URI is known.
Accepts any type for *local* — non-strings are coerced via str()
so integer/numeric IDs passed from graph builders do not crash.
Resolution order:
1. Already an absolute IRI (http / https / urn:) → return as-is.
2. Known vocabulary prefix (xsd:, rdf:, rdfs:, owl:, skos:,
semantica:) → expand to the canonical W3C IRI.
3. base_uri is set → append local name to base_uri.
4. Fallback → ``urn:<kind>:<local>``.
"""
if local.startswith("http") or local.startswith("urn:"):
local = str(local) if local is not None else ""
if not local:
return f"urn:{kind}:unknown"
if local.startswith(("http://", "https://", "urn:")):
return local
if ":" in local:
prefix, name = local.split(":", 1)
if prefix in _KNOWN_PREFIXES:
return f"{_KNOWN_PREFIXES[prefix]}{name}"
if base_uri:
return f"{base_uri}{local}"
return f"urn:{kind}:{local}"
+75
View File
@@ -601,3 +601,78 @@ class TestTripletStoreOntologyNamespace(unittest.TestCase):
subjects = {t.subject for t in captured}
self.assertIn("https://example.com/alice", subjects)
self.assertNotIn("https://example.com//alice", subjects)
# --- Bug: non-string IDs crash store ---
def test_integer_entity_id_does_not_crash(self, mock_bg):
"""Integer entity IDs must be coerced to str, not crash with AttributeError."""
store, captured = self._make_store(mock_bg)
kg = {
"entities": [
{"id": 1, "type": "Person"},
{"id": 2, "type": "Person"},
],
"relationships": [{"source": 1, "target": 2, "type": "knows"}],
}
store.store(kg, self._ontology())
subjects = {t.subject for t in captured}
self.assertIn(f"{self.BASE}1", subjects)
self.assertIn(f"{self.BASE}2", subjects)
predicates = {t.predicate for t in captured}
self.assertIn(f"{self.BASE}knows", predicates)
def test_integer_entity_id_fallback_to_urn(self, mock_bg):
"""Integer IDs fall back to urn: when no base_uri is set."""
store, captured = self._make_store(mock_bg)
kg = {"entities": [{"id": 42, "type": "Person"}], "relationships": []}
ontology = {"classes": [], "properties": []}
store.store(kg, ontology)
subjects = {t.subject for t in captured}
self.assertIn("urn:entity:42", subjects)
# --- Bug: prefixed W3C terms mis-resolved under base_uri ---
def test_owl_thing_domain_not_rewritten_under_base_uri(self, mock_bg):
"""owl:Thing in domain/range must expand to the W3C OWL IRI, not base_uri + 'owl:Thing'."""
store, captured = self._make_store(mock_bg)
ontology = {
"namespace": {"base_uri": self.BASE},
"classes": [],
"properties": [{"name": "hasThing", "domain": ["owl:Thing"], "range": ["owl:Thing"]}],
}
store.store({"entities": [], "relationships": []}, ontology)
RDFS_DOMAIN = "http://www.w3.org/2000/01/rdf-schema#domain"
RDFS_RANGE = "http://www.w3.org/2000/01/rdf-schema#range"
domain_objects = {t.object for t in captured if t.predicate == RDFS_DOMAIN}
range_objects = {t.object for t in captured if t.predicate == RDFS_RANGE}
self.assertIn("http://www.w3.org/2002/07/owl#Thing", domain_objects)
self.assertNotIn(f"{self.BASE}owl:Thing", domain_objects)
self.assertIn("http://www.w3.org/2002/07/owl#Thing", range_objects)
def test_xsd_date_range_not_rewritten_under_base_uri(self, mock_bg):
"""xsd:date in range must expand to the W3C XSD IRI, not base_uri + 'xsd:date'."""
store, captured = self._make_store(mock_bg)
ontology = {
"namespace": {"base_uri": self.BASE},
"classes": [],
"properties": [{"name": "birthDate", "range": ["xsd:date"]}],
}
store.store({"entities": [], "relationships": []}, ontology)
RDFS_RANGE = "http://www.w3.org/2000/01/rdf-schema#range"
range_objects = {t.object for t in captured if t.predicate == RDFS_RANGE}
self.assertIn("http://www.w3.org/2001/XMLSchema#date", range_objects)
self.assertNotIn(f"{self.BASE}xsd:date", range_objects)
def test_rdfs_and_skos_prefixes_expanded_correctly(self, mock_bg):
"""rdfs: and skos: prefixes in class URIs and parent links expand to W3C IRIs."""
store, captured = self._make_store(mock_bg)
ontology = {
"namespace": {"base_uri": self.BASE},
"classes": [{"name": "Concept", "parent": "skos:Concept"}],
"properties": [],
}
store.store({"entities": [], "relationships": []}, ontology)
RDFS_SUBCLASS = "http://www.w3.org/2000/01/rdf-schema#subClassOf"
parent_objects = {t.object for t in captured if t.predicate == RDFS_SUBCLASS}
self.assertIn("http://www.w3.org/2004/02/skos/core#Concept", parent_objects)
self.assertNotIn(f"{self.BASE}skos:Concept", parent_objects)