mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d680d4369 | ||
|
|
9d0744e20e | ||
|
|
0b52b715dc | ||
|
|
af401c8566 | ||
|
|
2e2dae558f | ||
|
|
3a1a798107 | ||
|
|
a4b17dd72b |
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user