Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved the handling of different return types from the query() method #219

Merged
merged 16 commits into from
Jun 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ line_length = 79 # PEP8
line-length = 79

[tool.mypy]
python_version = "3.7"
python_version = "3.11"
ignore_missing_imports = true
scripts_are_modules = true
warn_unused_configs = true
Expand Down
9 changes: 8 additions & 1 deletion tests/test_literals.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

def test_untyped() -> None:
"""Test creating a untyped literal."""
import pytest

from tripper.literal import RDF, XSD, Literal

literal = Literal("Hello world!")
Expand All @@ -19,9 +21,14 @@ def test_untyped() -> None:
assert literal == Literal("Hello world!", datatype=XSD.string)
assert literal == Literal("Hello world!", datatype=XSD.token)
assert literal == Literal("Hello world!", datatype=RDF.JSON)
assert literal != Literal("Hello world!", datatype=XSD.ENTITY)
assert literal == Literal("Hello world!", lang="en")

# Check two things here:
# 1) that a plain literal compares false to a non-string literal
# 2) that we get a warning about unknown XSD.ENTITY datatype
with pytest.warns(UserWarning):
assert literal != Literal("Hello world!", datatype=XSD.ENTITY)


def test_string() -> None:
"""Test creating a string literal."""
Expand Down
179 changes: 176 additions & 3 deletions tests/test_sparql.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@


# if True:
def test_sparql():
"""Test SPARQL query."""
def test_sparql_select():
"""Test SPARQL SELECT query."""
pytest.importorskip("rdflib")
from tripper import Triplestore

Expand Down Expand Up @@ -76,7 +76,7 @@ def test_sparql_construct():
ts.parse(data=data)
VCARD = ts.bind("vcard", "http://www.w3.org/2001/vcard-rdf/3.0#")

r = ts.query(query)
r = list(ts.query(query))

assert len(r) == 6
assert len([s for s, p, o in r if p == VCARD.givenName]) == 2
Expand All @@ -86,3 +86,176 @@ def test_sparql_construct():
assert (
len([s for s, p, o in r if p == VCARD.givenName and o == "Cyril"]) == 0
)


# if True:
def test_sparql_select2():
"""Test SPARQL SELECT query."""
# From https://www.w3.org/TR/rdf-sparql-query/#select
pytest.importorskip("rdflib")
from textwrap import dedent

from tripper import Triplestore

data = dedent(
"""
@prefix : <http://persons.com#> .
@prefix foaf: <http://xmlns.com/foaf/0.1/> .

:allice foaf:name "Alice" .
:allice foaf:knows :bob .
:allice foaf:knows :clare .

:bob foaf:name "Bob" .

:clare foaf:name "Clare" .
:clare foaf:nick "CT" .
"""
)
query = dedent(
"""
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
SELECT ?nameX ?nameY ?nickY
WHERE
{ ?x foaf:knows ?y ;
foaf:name ?nameX .
?y foaf:name ?nameY .
OPTIONAL { ?y foaf:nick ?nickY }
}
"""
)
ts = Triplestore("rdflib")
ts.parse(data=data)
r = ts.query(query)

assert set(r) == {("Alice", "Bob", "None"), ("Alice", "Clare", "CT")}


# if True:
def test_sparql_construct2():
"""Test SPARQL CONSTRUCT query."""
# From https://www.w3.org/TR/rdf-sparql-query/#construct
pytest.importorskip("rdflib")
from textwrap import dedent

from tripper import Literal, Triplestore

# Load pre-inferred EMMO
ts = Triplestore("rdflib")

data = dedent(
"""
@prefix foaf: <http://xmlns.com/foaf/0.1/> .

_:a foaf:name "Alice" .
_:a foaf:mbox <mailto:alice@example.org> .
"""
)
query = dedent(
"""
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
PREFIX vcard: <http://www.w3.org/2001/vcard-rdf/3.0#>
CONSTRUCT { <http://example.org/person#Alice> vcard:FN ?name }
WHERE { ?x foaf:name ?name }
"""
)
ts = Triplestore("rdflib")
ts.parse(data=data)
r = ts.query(query)

assert set(r) == {
(
"http://example.org/person#Alice",
"http://www.w3.org/2001/vcard-rdf/3.0#FN",
Literal("Alice"),
)
}


# if True:
def test_sparql_ask():
"""Test SPARQL ASK query."""
# From https://www.w3.org/TR/rdf-sparql-query/#ask
pytest.importorskip("rdflib")
from textwrap import dedent

from tripper import Triplestore

# Load pre-inferred EMMO
ts = Triplestore("rdflib")

data = dedent(
"""
@prefix foaf: <http://xmlns.com/foaf/0.1/> .

_:a foaf:name "Alice" .
_:a foaf:homepage <http://work.example.org/alice/> .

_:b foaf:name "Bob" .
_:b foaf:mbox <mailto:bob@work.example> .
"""
)
query = dedent(
"""
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
ASK { ?x foaf:name "Alice" }
"""
)
ts = Triplestore("rdflib")
ts.parse(data=data)
r = ts.query(query)
assert r is True


# if True:
def test_sparql_describe():
"""Test SPARQL DESCRIBE query."""
# From https://www.w3.org/TR/rdf-sparql-query/#describe
pytest.importorskip("rdflib")
from textwrap import dedent

from tripper import Literal, Triplestore

# Load pre-inferred EMMO
ts = Triplestore("rdflib")

data = dedent(
"""
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
@prefix vcard: <http://www.w3.org/2001/vcard-rdf/3.0> .
@prefix exOrg: <http://org.example.com/employees#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .

exOrg:Allice
exOrg:employeeId "1234" ;
foaf:mbox_sha1sum "ABCD1234" .

foaf:mbox_sha1sum rdf:type owl:InverseFunctionalProperty .
"""
)
query = dedent(
"""
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
DESCRIBE ?x
WHERE { ?x foaf:mbox_sha1sum "ABCD1234" }
"""
)
ts = Triplestore("rdflib")
ts.parse(data=data)
r = ts.query(query)

assert set(r) == set(
[
(
"http://org.example.com/employees#Allice",
"http://xmlns.com/foaf/0.1/mbox_sha1sum",
Literal("ABCD1234"),
),
(
"http://org.example.com/employees#Allice",
"http://org.example.com/employees#employeeId",
Literal("1234"),
),
]
)
3 changes: 1 addition & 2 deletions tests/test_triplestore.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,12 +415,11 @@ def test_find_literal_triples() -> None:
ts.triples(predicate=FAM.hasName, object=Literal("Per"))
) == set(
[
(FAM.Per, FAM.hasName, Literal("Per", datatype=XSD.string)),
(FAM.Per, FAM.hasName, Literal("Per")),
]
)


# if True:
def test_bind_errors():
"""Test for errors in Triplestore.bind()."""
pytest.importorskip("rdflib")
Expand Down
90 changes: 63 additions & 27 deletions tripper/backends/rdflib.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

# pylint: disable=line-too-long
import warnings
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Generator

try:
import rdflib # pylint: disable=unused-import
Expand All @@ -26,7 +26,7 @@

if TYPE_CHECKING: # pragma: no cover
from collections.abc import Sequence
from typing import Generator, List, Optional, Tuple, Union
from typing import List, Optional, Tuple, Union

from tripper.triplestore import Triple

Expand Down Expand Up @@ -71,7 +71,7 @@
database: "Optional[str]" = None,
triplestore_url: "Optional[str]" = None,
format: "Optional[str]" = None, # pylint: disable=redefined-builtin
graph: "Graph" = None,
graph: "Optional[Graph]" = None,
) -> None:
# Note that although `base_iri` is unused in this backend, it may
# still be used by calling Triplestore object.
Expand All @@ -88,26 +88,9 @@

def triples(self, triple: "Triple") -> "Generator[Triple, None, None]":
"""Returns a generator over matching triples."""
for s, p, o in self.graph.triples( # pylint: disable=not-an-iterable
astriple(triple)
):
yield (
(
f"_:{s}"
if isinstance(s, BNode) and not s.startswith("_:")
else str(s)
),
str(p),
(
parse_literal(o.n3())
if isinstance(o, rdflibLiteral)
else (
f"_:{o}"
if isinstance(o, BNode) and not o.startswith("_:")
else str(o)
)
),
)
return _convert_triples_to_tripper(
self.graph.triples(astriple(triple))
)

def add_triples(self, triples: "Sequence[Triple]"):
"""Add a sequence of triples."""
Expand Down Expand Up @@ -182,19 +165,49 @@
return result if isinstance(result, str) else result.decode()
return None

def query(self, query_object, **kwargs) -> "List[Tuple[str, ...]]":
def query(
self, query_object, **kwargs
) -> "Union[List[Tuple[str, ...]], bool, Generator[Triple, None, None]]":
"""SPARQL query.

Parameters:
query_object: String with the SPARQL query.
kwargs: Keyword arguments passed to rdflib.Graph.query().

Returns:
List of tuples of IRIs for each matching row.
The return type depends on type of query:
- SELECT: list of tuples of IRIs for each matching row
- ASK: bool
- CONSTRUCT, DESCRIBE: generator over triples

For more info, see
https://rdflib.readthedocs.io/en/stable/apidocs/rdflib.html#rdflib.query.Result
"""
rows = self.graph.query(query_object=query_object, **kwargs)
return [tuple(str(v) for v in row) for row in rows]
result = self.graph.query(query_object=query_object, **kwargs)

# The type of the result object depends not only on the type of query,
# but also on the version of rdflib... We try to be general here.
if hasattr(result, "type"):
resulttype = result.type
elif result.__class__.__name__ == "ResultRow":
resulttype = "SELECT"
elif isinstance(result, bool):
resulttype = "ASK"
elif isinstance(result, Generator):
resulttype = "CONSTRUCT" # also DESCRIBE

Check warning on line 197 in tripper/backends/rdflib.py

View check run for this annotation

Codecov / codecov/patch

tripper/backends/rdflib.py#L192-L197

Added lines #L192 - L197 were not covered by tests
else:
warnings.warn(

Check warning on line 199 in tripper/backends/rdflib.py

View check run for this annotation

Codecov / codecov/patch

tripper/backends/rdflib.py#L199

Added line #L199 was not covered by tests
"Unknown return type from rdflib.query(). Return it unprocessed."
)
return result # type: ignore

Check warning on line 202 in tripper/backends/rdflib.py

View check run for this annotation

Codecov / codecov/patch

tripper/backends/rdflib.py#L202

Added line #L202 was not covered by tests

if resulttype == "SELECT":
return [tuple(str(v) for v in row) for row in result] # type: ignore
if resulttype == "ASK":
return bool(result)
if resulttype in ("CONSTRUCT", "DESCRIBE"):
return _convert_triples_to_tripper(result)
assert False, "should never be reached" # nosec

Check warning on line 210 in tripper/backends/rdflib.py

View check run for this annotation

Codecov / codecov/patch

tripper/backends/rdflib.py#L210

Added line #L210 was not covered by tests

def update(self, update_object, **kwargs) -> None:
"""Update triplestore with SPARQL.
Expand Down Expand Up @@ -234,3 +247,26 @@
prefix: str(namespace)
for prefix, namespace in self.graph.namespaces()
}


def _convert_triples_to_tripper(triples) -> "Generator[Triple, None, None]":
"""Help function that converts a iterator/generator of rdflib triples
to tripper triples."""
for s, p, o in triples: ### p ylint: disable=not-an-iterable
yield (
(
f"_:{s}"
if isinstance(s, BNode) and not s.startswith("_:")
else str(s)
),
str(p),
(
parse_literal(o)
if isinstance(o, rdflibLiteral)
else (
f"_:{o}"
if isinstance(o, BNode) and not o.startswith("_:")
else str(o)
)
),
)
5 changes: 4 additions & 1 deletion tripper/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ def query(self, query_object: str, **kwargs) -> List[Tuple[str, ...]]:
kwargs: Additional backend-specific keyword arguments.

Returns:
List of tuples of IRIs for each matching row.
The return type depends on type of query:
- SELECT: list of tuples of IRIs for each matching row
- ASK: bool
- CONSTRUCT, DESCRIBE: generator over triples
"""

def update(self, update_object: str, **kwargs):
Expand Down
Loading