Compare commits

...
Author SHA1 Message Date
KaifAhmad1 9d680d4369 docs(changelog): add TripletStore namespace IRI resolution and regression fix entries for PR #447 2026-04-11 17:04:08 +05:30
KaifAhmad1 9d0744e20e 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.
2026-04-11 16:51:39 +05:30
KaifAhmad1 0b52b715dc fix(triplet-store): resolve entity/class/property IRIs against ontology namespace base_uri (Fixes #447)
store() was minting urn:entity:, urn:class:, and urn:property: URIs for every
bare local name, even when the ontology carried a namespace.base_uri. This made
instance data and ontology class data irreconcilable in SPARQL joins.

- Extract base_uri from ontology.namespace.base_uri (or ontology.uri as fallback)
- Introduce _resolve_iri(local, kind) closure that appends the local name to
  base_uri when present, keeping urn: fallback only when no base URI is known
- Apply _resolve_iri consistently for entity URIs, entity types, relationship
  predicates, ontology class URIs, parent class URIs, property URIs, and
  property domain/range URIs
- Explicit entity.uri values are never overridden
- Added 9 regression tests in TestTripletStoreOntologyNamespace covering all
  IRI expansion paths, urn: fallback, explicit URI passthrough, top-level uri
  key fallback, and trailing-slash safety
2026-04-11 15:47:42 +05:30
KaifAhmad1 af401c8566 docs(changelog): add Blazegraph literal serialization and SPARQL injection fix entries for PR #448 2026-04-11 15:21:42 +05:30
KaifAhmad1 2e2dae558f fix(blazegraph): expand prefixed datatypes and validate lang/datatype metadata
- Added _resolve_datatype_iri() to expand known prefixes (xsd/rdf/rdfs/owl/skos)
  to full IRIs instead of blindly wrapping in <...>, fixing invalid SPARQL like
  <xsd:integer>
- Validated language tags against RFC 5646 regex to prevent SPARQL injection
  via metadata["lang"] values containing whitespace or punctuation
- Validated datatype IRIs for whitespace/special characters before interpolation
- Extended test suite from 7 to 15 cases covering prefix expansion, injection
  rejection, and all accepted input forms
2026-04-11 15:16:15 +05:30
KaifAhmad1 3a1a798107 Fix Blazegraph literal serialization in bulk loader (Fixes #448) 2026-04-11 14:58:51 +05:30
Mohd Kaif a4b17dd72b Merge pull request #449 from Hawksight-AI/ontology
fix(ontology): preserve user-facing schema fields in OWL generation\n…
2026-04-11 14:09:09 +05:30
5 changed files with 614 additions and 37 deletions
+21
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Fix: TripletStore.store() IRI resolution regressions** (PR #447 follow-up by @KaifAhmad1):
- Fixed `AttributeError` crash when entity or relationship IDs are non-string types (e.g. integers emitted by `GraphBuilder`). `_resolve_iri()` previously called `.startswith()` directly on the raw ID; it now coerces any value to `str()` at entry, restoring the implicit stringification that the old f-string URN minting provided.
- Fixed W3C vocabulary prefixes (`owl:Thing`, `xsd:date`, `rdfs:Literal`, `skos:Concept`, etc.) being incorrectly re-namespaced under the ontology `base_uri` (e.g. `https://example.com/owl:Thing`) when `base_uri` was present. `_resolve_iri()` now consults a known-prefix expansion table (`xsd`, `rdf`, `rdfs`, `owl`, `skos`, `semantica`) before applying `base_uri`, matching the same prefix map already used in `BlazegraphStore`. Standard vocabulary IRIs are always expanded to their canonical W3C forms regardless of what `base_uri` is set to.
- Added 5 regression tests: integer IDs with and without `base_uri`, `owl:Thing` domain/range, `xsd:date` range, and `skos:Concept` parent class expansion. Total tests in `TestTripletStoreOntologyNamespace`: 14.
- **Fix: TripletStore.store() ignores ontology namespace base_uri** (PR #447 by @KaifAhmad1):
- `store(knowledge_graph, ontology)` was minting `urn:entity:{id}`, `urn:class:{type}`, and `urn:property:{name}` URIs for all bare local names, even when `ontology.namespace.base_uri` was present. This made instance data and ontology class data irreconcilable in SPARQL joins.
- Extracts `base_uri` once from `ontology["namespace"]["base_uri"]` (with `ontology["uri"]` as fallback) and ensures a trailing separator so concatenation is always a valid IRI path.
- Introduced `_resolve_iri(local, kind)` closure applied to all 7 IRI-minting sites: entity URIs, entity types, relationship predicates, ontology class URIs, parent class URIs, property URIs, and domain/range URIs. Explicit `entity["uri"]` values are never overridden. Falls back to `urn:` only when no `base_uri` is available.
- Added 9 tests in `TestTripletStoreOntologyNamespace` covering all expansion paths, `urn:` fallback, explicit URI passthrough, top-level `uri` key fallback, and trailing-slash safety.
- **Fix: Blazegraph literal serialization and SPARQL injection hardening** (PR #448 by @KaifAhmad1):
- Fixed `_build_ntriples()`, `_build_insert_data()`, `find_triplets()`, and `delete_triplet()` in `BlazegraphStore` — all four methods previously unconditionally wrapped every triplet object in `<...>` as an IRI, causing Blazegraph to reject or misparse any triple whose object was a plain string, typed literal, or language-tagged literal.
- Added `_format_object_for_sparql(triplet)` — central formatter that selects the correct SPARQL/N-Triples token: IRI (`<uri>`), typed literal (`"value"^^<datatype>`), language-tagged literal (`"value"@lang`), or plain literal (`"value"`).
- Added `_resolve_datatype_iri(datatype)` — expands prefixed datatype names (`xsd:integer`, `rdf:langString`, `rdfs:Literal`, `owl:real`, `skos:notation`) to their full IRIs instead of producing invalid `<xsd:integer>` tokens. Accepts full `http/https/urn` IRIs and already-bracketed IRIs after whitespace validation. Rejects unknown prefixes and bare local names with a clear `ValueError`.
- Added language-tag validation against RFC 5646 (`^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$`) — values containing whitespace, dots, or other punctuation (e.g. `"en . CLEAR ALL #"`) raise `ValueError` before interpolation, closing a SPARQL injection vector in `metadata["lang"]` / `metadata["language"]`.
- Added datatype-string validation — whitespace and SPARQL-delimiting characters inside `metadata["datatype"]` / `metadata["literal_datatype"]` raise `ValueError`, closing the parallel injection vector for typed literals.
- Added `_is_uri_value(value)` — URI detection using `urlparse`; rejects strings that only start with a URI scheme but contain whitespace (e.g. `"http not a uri"` is serialised as a literal, not an IRI).
- Added `_escape_literal(value)` — escapes `\`, `"`, `\n`, `\r`, `\t` inside literal strings before SPARQL interpolation.
- New test file `tests/triplet_store/test_blazegraph_store.py` — 15 offline unit tests covering URI serialization, plain/typed/language-tagged/escaped literals, prefix expansion, IRI passthrough, injection rejection, and `_build_insert_data` delegation; all run without a live Blazegraph instance.
- **OWLGenerator user-facing schema compatibility fixes** (Issue #446):
- Fixed OWL class/property IRI identifier fallback order to prefer `label` and then `name`.
- Fixed datatype property handling to accept scalar and list `range` values in rdflib path (including `xsd:*`, full IRIs, and local names), preventing list-based `.startswith()` crashes.
+114 -5
View File
@@ -27,8 +27,9 @@ Author: Semantica Contributors
License: MIT
"""
import re
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
import requests
@@ -238,7 +239,7 @@ class BlazegraphStore:
lines = []
for triplet in triplets:
lines.append(
f"<{triplet.subject}> <{triplet.predicate}> <{triplet.object}> ."
f"<{triplet.subject}> <{triplet.predicate}> {self._format_object_for_sparql(triplet)} ."
)
return "\n".join(lines)
else:
@@ -249,9 +250,112 @@ class BlazegraphStore:
"""Build SPARQL INSERT DATA clause."""
lines = []
for triplet in triplets:
lines.append(f"<{triplet.subject}> <{triplet.predicate}> <{triplet.object}> .")
lines.append(
f"<{triplet.subject}> <{triplet.predicate}> {self._format_object_for_sparql(triplet)} ."
)
return " ".join(lines)
# Known prefix expansions for XSD and common RDF vocabularies
_KNOWN_PREFIXES: Dict[str, str] = {
"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#",
}
# RFC 5646 language tag: primary subtag optionally followed by '-' + subtags
_LANG_TAG_RE = re.compile(r"^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$")
def _format_object_for_sparql(self, triplet: Triplet) -> str:
"""Format triplet object as IRI or literal for SPARQL/N-Triples style syntax."""
obj = triplet.object
metadata = triplet.metadata or {}
if self._is_uri_value(obj):
if obj.startswith("<") and obj.endswith(">"):
inner = obj[1:-1]
if " " in inner or ">" in inner:
raise ValueError(f"IRI contains invalid characters: {obj!r}")
return obj
return f"<{obj}>"
escaped = self._escape_literal(obj)
datatype = metadata.get("datatype") or metadata.get("literal_datatype")
language = metadata.get("lang") or metadata.get("language")
if datatype:
datatype_iri = self._resolve_datatype_iri(datatype)
return f"\"{escaped}\"^^{datatype_iri}"
if language:
if not self._LANG_TAG_RE.match(str(language)):
raise ValueError(
f"Invalid language tag {language!r}: must match RFC 5646 "
f"(letters/digits and hyphens only, e.g. 'en', 'en-US')"
)
return f"\"{escaped}\"@{language}"
return f"\"{escaped}\""
def _resolve_datatype_iri(self, datatype: str) -> str:
"""Expand a datatype string to a validated SPARQL IRI token.
Accepts:
- Already-wrapped IRIs: ``<http://...>``
- Full IRIs: ``http://...`` / ``https://...`` / ``urn:...``
- Known prefixed names: ``xsd:integer``, ``rdf:langString``, etc.
Raises ValueError for anything else.
"""
datatype = str(datatype)
# Already angle-bracketed — validate the inner IRI contains no whitespace
if datatype.startswith("<") and datatype.endswith(">"):
inner = datatype[1:-1]
if not inner or re.search(r"[\s<>\"{}|\\^`]", inner):
raise ValueError(f"Invalid datatype IRI: {datatype!r}")
return datatype
# Full absolute IRI without brackets
parsed = urlparse(datatype)
if parsed.scheme in {"http", "https", "urn"} and not re.search(r"[\s<>\"{}|\\^`]", datatype):
return f"<{datatype}>"
# Prefixed form — expand known prefixes only
if ":" in datatype:
prefix, local = datatype.split(":", 1)
if prefix in self._KNOWN_PREFIXES and re.match(r"^[A-Za-z0-9_\-\.]+$", local):
return f"<{self._KNOWN_PREFIXES[prefix]}{local}>"
raise ValueError(
f"Unsupported datatype {datatype!r}: use a full IRI (http/https/urn), "
f"an angle-bracketed IRI, or a known prefix (xsd/rdf/rdfs/owl/skos)."
)
def _is_uri_value(self, value: str) -> bool:
"""Detect if a value should be serialized as an IRI."""
if not isinstance(value, str) or not value:
return False
if value.startswith("<") and value.endswith(">"):
return True
parsed = urlparse(value)
if parsed.scheme not in {"http", "https", "urn"}:
return False
# Reject strings that only look like URIs (e.g. "http not a uri")
return not re.search(r"\s", value)
def _escape_literal(self, value: str) -> str:
"""Escape string literal for SPARQL."""
return (
str(value)
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
def add_triplet(self, triplet: Triplet, **options) -> Dict[str, Any]:
"""Add single triplet."""
return self.bulk_load([triplet], **options)
@@ -275,7 +379,9 @@ class BlazegraphStore:
if predicate:
where_clauses.append(f"?p = <{predicate}>")
if object:
where_clauses.append(f"?o = <{object}>")
where_clauses.append(
f"?o = {self._format_object_for_sparql(Triplet(subject='', predicate='', object=object))}"
)
where_clause = " ".join(where_clauses) if where_clauses else ""
query = f"SELECT ?s ?p ?o WHERE {{ ?s ?p ?o {where_clause} }}"
@@ -303,7 +409,10 @@ class BlazegraphStore:
update_endpoint = self._get_update_endpoint()
query = f"DELETE DATA {{ <{triplet.subject}> <{triplet.predicate}> <{triplet.object}> }}"
query = (
f"DELETE DATA {{ <{triplet.subject}> <{triplet.predicate}> "
f"{self._format_object_for_sparql(triplet)} }}"
)
try:
response = requests.post(
+60 -32
View File
@@ -170,6 +170,55 @@ class TripletStore:
RDFS_DOMAIN = "http://www.w3.org/2000/01/rdf-schema#domain"
RDFS_RANGE = "http://www.w3.org/2000/01/rdf-schema#range"
# Resolve base URI from ontology — used to mint entity/class/property IRIs
# that are reconcilable with the ontology's own namespace.
ns = ontology.get("namespace") or {}
if isinstance(ns, dict):
base_uri = ns.get("base_uri") or ontology.get("uri") or ""
else:
base_uri = ontology.get("uri") or ""
# Ensure trailing separator so "base_uri + local" is always a valid IRI path.
if base_uri and not base_uri.endswith(("/", "#")):
base_uri = base_uri + "/"
# 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.
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>``.
"""
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}"
# 1. Process Ontology
classes = ontology.get("classes", [])
properties = ontology.get("properties", [])
@@ -180,18 +229,13 @@ class TripletStore:
if not cls_uri:
continue
if not cls_uri.startswith("http") and not cls_uri.startswith("urn:"):
# Fallback if no URI provided
cls_uri = f"urn:class:{cls_uri}"
cls_uri = _resolve_iri(cls_uri, "class")
triplets.append(Triplet(cls_uri, RDF_TYPE, OWL_CLASS))
# Hierarchy
parent = cls.get("parent") or cls.get("subClassOf")
if parent:
parent_uri = parent
if not parent.startswith("http") and not parent.startswith("urn:"):
parent_uri = f"urn:class:{parent}"
parent_uri = _resolve_iri(parent, "class")
triplets.append(Triplet(cls_uri, RDFS_SUBCLASS, parent_uri))
for prop in properties:
@@ -199,8 +243,7 @@ class TripletStore:
if not prop_uri:
continue
if not prop_uri.startswith("http") and not prop_uri.startswith("urn:"):
prop_uri = f"urn:property:{prop_uri}"
prop_uri = _resolve_iri(prop_uri, "property")
# Determine property type (Object or Datatype)
# Default to ObjectProperty if not specified
@@ -218,9 +261,7 @@ class TripletStore:
domains = [domains]
for domain in domains:
domain_uri = domain
if not domain.startswith("http") and not domain.startswith("urn:"):
domain_uri = f"urn:class:{domain}"
domain_uri = _resolve_iri(domain, "class")
triplets.append(Triplet(prop_uri, RDFS_DOMAIN, domain_uri))
if "range" in prop:
@@ -229,9 +270,7 @@ class TripletStore:
ranges = [ranges]
for range_ in ranges:
range_uri = range_
if not range_.startswith("http") and not range_.startswith("urn:"):
range_uri = f"urn:class:{range_}"
range_uri = _resolve_iri(range_, "class")
triplets.append(Triplet(prop_uri, RDFS_RANGE, range_uri))
# 2. Process Knowledge Graph
@@ -245,28 +284,19 @@ class TripletStore:
if not entity_id:
continue
entity_uri = entity.get("uri")
if not entity_uri:
entity_uri = f"urn:entity:{entity_id}"
entity_uri = entity.get("uri") or _resolve_iri(entity_id, "entity")
entity_map[entity_id] = entity_uri
# Entity Type
entity_type = entity.get("type")
if entity_type:
type_uri = entity_type
if not entity_type.startswith("http") and not entity_type.startswith(
"urn:"
):
type_uri = f"urn:class:{entity_type}"
type_uri = _resolve_iri(entity_type, "class")
triplets.append(Triplet(entity_uri, RDF_TYPE, type_uri))
# Entity Properties
props = entity.get("properties", {})
for k, v in props.items():
prop_uri = k
if not k.startswith("http") and not k.startswith("urn:"):
prop_uri = f"urn:property:{k}"
prop_uri = _resolve_iri(k, "property")
triplets.append(Triplet(entity_uri, prop_uri, str(v)))
for rel in relationships:
@@ -277,11 +307,9 @@ class TripletStore:
if not source_id or not target_id or not rel_type:
continue
source_uri = entity_map.get(source_id, f"urn:entity:{source_id}")
target_uri = entity_map.get(target_id, f"urn:entity:{target_id}")
rel_uri = rel_type
if not rel_type.startswith("http") and not rel_type.startswith("urn:"):
rel_uri = f"urn:property:{rel_type}"
source_uri = entity_map.get(source_id) or _resolve_iri(source_id, "entity")
target_uri = entity_map.get(target_id) or _resolve_iri(target_id, "entity")
rel_uri = _resolve_iri(rel_type, "property")
triplets.append(Triplet(source_uri, rel_uri, target_uri))
@@ -0,0 +1,225 @@
import unittest
import os
import sys
from unittest.mock import patch
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from semantica.semantic_extract.triplet_extractor import Triplet
from semantica.triplet_store.blazegraph_store import BlazegraphStore
class TestBlazegraphStoreSerialization(unittest.TestCase):
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_serializes_uri_object(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:knows",
object="urn:entity:person:2",
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(
obj,
"<urn:entity:person:2>",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_serializes_literal_object(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:name",
object="Jane Doe",
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(
obj,
"\"Jane Doe\"",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_escapes_literal_object(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:note",
object='line "one"\\line2',
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(
obj,
"\"line \\\"one\\\"\\\\line2\"",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_serializes_typed_literal(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:age",
object="42",
metadata={"datatype": "http://www.w3.org/2001/XMLSchema#integer"},
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(
obj,
"\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_build_insert_data_uses_formatter(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:name",
object="Jane Doe",
)
with patch.object(store, "_format_object_for_sparql", return_value="\"Jane Doe\"") as mock_fmt:
insert_data = store._build_insert_data([triplet])
mock_fmt.assert_called_once_with(triplet)
self.assertEqual(
insert_data,
"<urn:entity:person:1> <urn:property:name> \"Jane Doe\" .",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_serializes_language_literal(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:label",
object="Color",
metadata={"lang": "en"},
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(obj, "\"Color\"@en")
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_does_not_treat_invalid_uri_like_text_as_uri(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:note",
object="http not a uri",
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(obj, "\"http not a uri\"")
# --- Bug 1: prefixed datatype expansion ---
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_expands_xsd_prefix_to_full_iri(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:age",
object="42",
metadata={"datatype": "xsd:integer"},
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(
obj,
"\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_expands_rdf_prefix_to_full_iri(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:value",
object="hello",
metadata={"datatype": "rdf:langString"},
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(
obj,
"\"hello\"^^<http://www.w3.org/1999/02/22-rdf-syntax-ns#langString>",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_rejects_unknown_prefix(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:value",
object="hello",
metadata={"datatype": "myns:customType"},
)
with self.assertRaises(ValueError):
store._format_object_for_sparql(triplet)
# --- Bug 2: metadata injection validation ---
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_rejects_injected_lang_tag(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:label",
object="Color",
metadata={"lang": "en . CLEAR ALL #"},
)
with self.assertRaises(ValueError):
store._format_object_for_sparql(triplet)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_rejects_datatype_with_whitespace(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:age",
object="42",
metadata={"datatype": "http://example.org/type CLEAR ALL"},
)
with self.assertRaises(ValueError):
store._format_object_for_sparql(triplet)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_accepts_full_iri_datatype_no_brackets(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:age",
object="42",
metadata={"datatype": "http://www.w3.org/2001/XMLSchema#integer"},
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(
obj,
"\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_accepts_bracketed_iri_datatype(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:age",
object="42",
metadata={"datatype": "<http://www.w3.org/2001/XMLSchema#integer>"},
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(
obj,
"\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>",
)
@patch.object(BlazegraphStore, "_connect", autospec=True)
def test_format_object_accepts_hyphenated_lang_tag(self, _mock_connect):
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
triplet = Triplet(
subject="urn:entity:person:1",
predicate="urn:property:label",
object="Colour",
metadata={"lang": "en-GB"},
)
obj = store._format_object_for_sparql(triplet)
self.assertEqual(obj, "\"Colour\"@en-GB")
if __name__ == "__main__":
unittest.main()
+194
View File
@@ -482,3 +482,197 @@ class TestSKOSTripletStore(unittest.TestCase):
return_value=QueryResult(bindings=[], variables=[])
)
self.assertEqual(store.get_skos_concepts(), [])
@patch("semantica.triplet_store.blazegraph_store.BlazegraphStore")
class TestTripletStoreOntologyNamespace(unittest.TestCase):
"""Regression tests for Issue #447 — store() must use ontology namespace base_uri."""
BASE = "https://example.com/"
def _make_store(self, mock_bg):
"""Return (store, captured_triplets_list).
add_triplets is patched so store() never touches the bulk_loader or
backend; instead every Triplet passed to it is appended to the list.
"""
captured = []
def _capture(triplets, **_kw):
captured.extend(triplets)
return {"success": True}
with (
patch("semantica.triplet_store.triplet_store.get_logger", return_value=MagicMock()),
patch("semantica.triplet_store.triplet_store.get_progress_tracker", return_value=MagicMock()),
):
store = TripletStore(backend="blazegraph")
store.add_triplets = _capture
return store, captured
def _ontology(self):
return {
"namespace": {"base_uri": self.BASE},
"classes": [{"name": "Person"}],
"properties": [{"name": "knows", "domain": ["Person"], "range": ["Person"]}],
}
def _kg(self):
return {
"entities": [
{"id": "alice", "type": "Person"},
{"id": "bob", "type": "Person"},
],
"relationships": [{"source": "alice", "target": "bob", "type": "knows"}],
}
def test_entity_uri_uses_base_uri(self, mock_bg):
store, captured = self._make_store(mock_bg)
store.store(self._kg(), self._ontology())
subjects = {t.subject for t in captured}
self.assertIn(f"{self.BASE}alice", subjects)
self.assertIn(f"{self.BASE}bob", subjects)
self.assertNotIn("urn:entity:alice", subjects)
def test_entity_type_uses_base_uri(self, mock_bg):
store, captured = self._make_store(mock_bg)
store.store(self._kg(), self._ontology())
RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
type_objects = {t.object for t in captured if t.predicate == RDF_TYPE}
self.assertIn(f"{self.BASE}Person", type_objects)
self.assertNotIn("urn:class:Person", type_objects)
def test_relationship_type_uses_base_uri(self, mock_bg):
store, captured = self._make_store(mock_bg)
store.store(self._kg(), self._ontology())
predicates = {t.predicate for t in captured}
self.assertIn(f"{self.BASE}knows", predicates)
self.assertNotIn("urn:property:knows", predicates)
def test_ontology_class_uri_uses_base_uri(self, mock_bg):
store, captured = self._make_store(mock_bg)
store.store(self._kg(), self._ontology())
OWL_CLASS = "http://www.w3.org/2002/07/owl#Class"
class_subjects = {t.subject for t in captured if t.object == OWL_CLASS}
self.assertIn(f"{self.BASE}Person", class_subjects)
def test_ontology_property_domain_range_use_base_uri(self, mock_bg):
store, captured = self._make_store(mock_bg)
store.store(self._kg(), self._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(f"{self.BASE}Person", domain_objects)
self.assertIn(f"{self.BASE}Person", range_objects)
def test_explicit_entity_uri_not_overridden(self, mock_bg):
store, captured = self._make_store(mock_bg)
kg = {"entities": [{"id": "alice", "uri": "https://other.org/Alice", "type": "Person"}], "relationships": []}
store.store(kg, self._ontology())
subjects = {t.subject for t in captured}
self.assertIn("https://other.org/Alice", subjects)
self.assertNotIn(f"{self.BASE}alice", subjects)
def test_no_base_uri_falls_back_to_urn(self, mock_bg):
store, captured = self._make_store(mock_bg)
kg = {"entities": [{"id": "alice", "type": "Person"}], "relationships": []}
ontology = {"classes": [{"name": "Person"}], "properties": []}
store.store(kg, ontology)
subjects = {t.subject for t in captured}
self.assertIn("urn:entity:alice", subjects)
def test_base_uri_via_top_level_uri_key(self, mock_bg):
"""ontology['uri'] should work as a fallback when namespace dict is absent."""
store, captured = self._make_store(mock_bg)
ontology = {"uri": self.BASE, "classes": [{"name": "Person"}], "properties": []}
kg = {"entities": [{"id": "alice", "type": "Person"}], "relationships": []}
store.store(kg, ontology)
subjects = {t.subject for t in captured}
self.assertIn(f"{self.BASE}alice", subjects)
def test_trailing_slash_not_doubled(self, mock_bg):
"""base_uri already ending with '/' must not produce 'base//local'."""
store, captured = self._make_store(mock_bg)
ontology = {"namespace": {"base_uri": "https://example.com/"}, "classes": [], "properties": []}
# Give alice a type so a triplet is emitted with alice as subject
kg = {"entities": [{"id": "alice", "type": "Person"}], "relationships": []}
store.store(kg, ontology)
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)