import logging
import re
from typing import List, Union, Optional, Any, Dict
from rdflib import Literal, XSD, Dataset, BNode, URIRef
from rdflib.plugins.sparql.processor import prepareQuery
from kapps_triplestore_interface.exceptions import (
InvalidInputError,
InvalidQueryError,
)
from kapps_triplestore_interface.utils.iri import IRI
from kapps_triplestore_interface.utils.types import (
BNodeLike,
IRILike,
Triple,
PartialTriple,
TripleLike,
PartialTripleLike,
Subject,
Predicate,
Object,
SubjectLike,
PredicateLike,
ObjectLike,
)
from kapps_triplestore_interface.utils.xsd_typemap import XSDToPythonMapper, XSDToPythonTypes
LOGGER = logging.getLogger(__name__)
BNODE_PATTERN = re.compile(r"^_:[a-zA-Z][a-zA-Z0-9_\-]*$")
[docs]
def sanitize_triple(
triple: Union[TripleLike, PartialTripleLike],
allow_partial: Optional[bool] = False,
) -> Union[Triple, PartialTriple]:
"""
Validates and converts the components of a triple to their appropriate types.
- A valid triple must have three entries (subject, predicate, object) defined.
- If allow_partial is false, no entry may be None. Otherweise, at least one entry
must be not None.
- Subject and predicate must be valid IRIs.
- The object can be either an IRI or a Literal
- If the object is of type IRI, URIRef, or str, it will be converted to IRI.
- If an object of type str should be treated as a Literal, it must be
explicitly converted to Literal before passing.
- If the object is of any other type, it will be converted to a Literal.
Args:
triple (Union[TripleLike, PartialTripleLike]): The triple to validate and convert.
allow_partial (Optional[bool]): Whether to allow partial triples. Defaults to False.
Returns:
Union[Triple, PartialTriple]: The validated and converted triple.
Raises:
InvalidInputError: If the triple does not have three or too many None entries.
TypeError: If subject or predicate are not of type str, URIRef, or IRI.
InvalidIRIError: If object is of type str or URIRef (except Literal) and not in a valid IRI format.
"""
if allow_partial:
if len(triple) != 3 or all(e is None for e in triple):
error_message = f"Triple requires three components, at least one of which is not None: {triple}"
LOGGER.error(error_message)
raise InvalidInputError(error_message)
else:
if len(triple) != 3 or any(e is None for e in triple):
error_message = (
f"Triple requires three components, neither of which is None: {triple}"
)
LOGGER.error(error_message)
raise InvalidInputError(error_message)
sub, pred, obj = triple
if sub is not None and not isinstance(sub, Subject):
sub = _to_subject(sub)
if pred is not None and not isinstance(pred, Predicate):
pred = _to_predicate(pred)
if obj is not None and not isinstance(obj, Object):
obj = _to_object(obj)
return sub, pred, obj
def _to_iri_or_bnode(value: Union[IRILike, BNodeLike]) -> Union[IRI, BNode]:
"""Try converting a IRILike or BNodeLike."""
if isinstance(value, Union[IRI, BNode]):
return value
if BNODE_PATTERN.match(str(value)):
return BNode(value[2:]) # Remove '_:' prefix for BNode id
return IRI(value)
def _to_subject(sub: SubjectLike) -> Subject:
"""Convert a SubjectLike to a Subject."""
return _to_iri_or_bnode(sub)
def _to_predicate(pred: PredicateLike) -> Predicate:
"""Convert a PredicateLike to a Predicate."""
return IRI(pred)
def _to_object(obj: ObjectLike) -> Object:
"""Convert an ObjectLike to an Object."""
if isinstance(obj, Object):
return obj
if isinstance(obj, str):
try:
return _to_iri_or_bnode(str(obj))
except Exception as e:
error_message = f"Object is of type str but cannot be converted to IRI or BNode. If object is a <Literal>, explicitly convert before passing: {obj} ({e})"
LOGGER.error(error_message)
raise type(e)(error_message) from e
else:
return Literal(obj)
[docs]
def triple_to_string(
triple: TripleLike,
line_end: Optional[str] = None,
) -> str:
"""Convert a triple to its string representation suitable for SPARQL queries.
Args:
triple (TripleLike): The triple to convert.
Returns:
str: The string representation of the triple.
"""
sub, pred, obj = sanitize_triple(triple)
return f"{sub.n3()} {pred.n3()} {obj.n3()}" + (f" {line_end}" if line_end else "")
[docs]
def validate_query(query: str):
"""
Validate a SPARQL SELECT/ASK query string by parsing it.
Args:
query (str): The SPARQL query to validate.
Returns:
bool: True if parsing succeeds.
Raises:
InvalidQueryError: If parsing fails.
"""
try:
# Attempt to prepare the query
prepareQuery(query)
return True
except Exception as e:
error_message = f"SPAQRQL query validation failed: {e}"
LOGGER.error(error_message)
raise InvalidQueryError(error_message)
[docs]
def validate_update_query(
query: str,
):
"""
Validate a SPARQL UPDATE string by applying it to a temporary dataset.
Args:
query (str): The SPARQL UPDATE string to validate.
Returns:
bool: True if validation succeeds.
Raises:
InvalidQueryError: If validation fails.
"""
try:
g = Dataset()
g.update(query)
return True
except Exception as e:
error_message = f"SPAQRQL update query validation failed: {e}"
LOGGER.error(error_message)
raise InvalidQueryError(error_message)
[docs]
def to_literal(
value: Any,
datatype: Optional[str] = None,
as_string: Optional[bool] = False,
) -> Union[Literal, str]:
"""
Convert a Python value to an XSD literal.
Args:
value (Any): The Python value to convert.
datatype: (Optional[str]) Optional XSD datatype to use; inferred as `XSD.string` for strings.
as_string (Optional[bool]): If True, return the N3 string form of the literal. Defaults to False.
Returns:
Union[Literal, str]: The `rdflib.Literal` or its N3 string form when `as_string=True`.
"""
if isinstance(value, str) and datatype is None:
datatype = XSD.string
literal = Literal(value, datatype=datatype)
# literal = escape_string_literal(literal)
if as_string:
return literal.n3()
return literal
[docs]
def from_xsd_literal(
value: str,
datatype: str,
):
"""
Convert an XSD-typed string value to a Python value.
Args:
value (str): The lexical form of the literal.
datatype (str): The XSD datatype IRI.
Returns:
Any: The converted Python value.
"""
return XSDToPythonMapper[URIRef(datatype)](value)
[docs]
def convert_multi_bindings_to_python_type(
bindings: list[dict],
) -> list[dict]:
"""
Convert SPARQL query result bindings to their corresponding Python types.
Args:
bindings (list[dict]): List of SPARQL query result bindings.
Returns:
list[Any]: List of converted Python values.
Notes:
Bindings are expected in the format::
[
{ # binding 1
'var1': { 'type': _, 'value': _, 'datatype': _ },
'var2': { 'type': _, 'value': _, 'datatype': _ },
},
{ # binding 2
'var1': { 'type': _, 'value': _, 'datatype': _ },
'var2': { 'type': _, 'value': _, 'datatype': _ },
}
]
and returned in the format::
[
{ # binding 1
'var1': _,
'var2': _,
},
{ # binding 2
'var1': _,
'var2': _,
}
]
"""
converted_bindings = []
for binding in bindings:
converted_binding = {}
for name, entry in binding.items():
if isinstance(entry, dict) and "type" in entry and "value" in entry:
converted_binding[name] = convert_binding_to_python_type(entry)
else:
converted_binding[name] = entry
converted_bindings.append(converted_binding)
return converted_bindings
[docs]
def convert_binding_to_python_type(
result_binding: dict,
) -> Any:
"""
Convert a SPARQL binding entry to a Python value.
Args:
result_binding (dict): A single binding dict (e.g., `{ 'type': 'literal', ... }`).
Returns:
Any: A Python value converted from the binding, or the raw string when not typed.
"""
type = result_binding.get("type")
if type == "literal" and "datatype" in result_binding:
return from_xsd_literal(result_binding["value"], result_binding["datatype"])
elif type == "bnode":
return BNode(result_binding["value"])
elif type == "uri":
# Convert to IRI. if it is a recognized datatype, map to Python type
iri = IRI(result_binding["value"])
return XSDToPythonTypes.get(iri, iri)
else:
# If no datatype is provided, return the value as is
return result_binding["value"]
[docs]
def get_local_name(
iri: str,
) -> str:
"""
Extract the local name from an IRI.
Prefers the fragment after `#` when present; otherwise returns the last path
segment after `/`.
Args:
iri (str): The input IRI (full or shorthand acceptable).
Returns:
str: The local name component.
"""
iri = IRI(iri)
# If there's a fragment (i.e., the part after '#')
if iri.fragment:
return iri.fragment
# Otherwise, split by '/' and return the last segment
return iri.split("/")[-1]
[docs]
def encapsulate_named_graph(
named_graph: IRI,
content: str,
) -> str:
"""
Encapsulates the given content within a named graph block if a named graph is provided.
Args:
named_graph (IRI): The IRI of the named graph. If None, the content is returned as is.
content (str): The SPARQL content to encapsulate.
Returns:
str: The encapsulated content or the original content if no named graph is provided.
"""
if named_graph:
return f"""GRAPH {named_graph.n3()} {{
{content}
}}"""
return content
[docs]
def group_triples_by_bnode(triples: List[Triple]) -> List[List[Triple]]:
"""
Groups triples sharing blank nodes into connected components to ensure
blank node semantics are preserved within query scope.
Args:
triples (List[Triple]): Triples to check.
Returns:
List[List[Triple]]: A list of lists of related triples
Example:
::
ASK {
{ <a> <p> <b> . } # Independent triple
UNION
{ <x> <q> _:b1 .
_:b1 <r> <y> . } # Connected via blank node
UNION
{ <m> <n> <o> . } # Another independent triple
}
"""
triple_groups: Dict[BNode, List[Triple]] = {}
for triple in triples:
# Extract blank nodes in the triple
bnodes = set()
for elem in triple:
if isinstance(elem, BNode):
bnodes.add(elem)
if not bnodes:
# No blank nodes - independent triple
# Use triple itself as hashable key
triple_groups[triple] = [triple]
continue
containing_sets = [
triple_groups[bnode] for bnode in bnodes if bnode in triple_groups
]
if not containing_sets:
# Create new group
containing_set = []
else:
# Existing groups - merge if multiple
containing_set = containing_sets[0]
for set_to_merge in containing_sets[1:]:
containing_set |= set_to_merge
# Add triple to the containing group
containing_set.append(triple)
# Update bnode to group mapping - overwrite to merged group if needed
for bnode in bnodes:
triple_groups[bnode] = containing_set
return list(triple_groups.values())