diff --git a/pyproject.toml b/pyproject.toml index 8122d004..eb03c021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/test_literals.py b/tests/test_literals.py index a2e13bac..1c9ae63b 100644 --- a/tests/test_literals.py +++ b/tests/test_literals.py @@ -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!") @@ -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.""" diff --git a/tests/test_sparql.py b/tests/test_sparql.py index 465904b9..f64c25e7 100644 --- a/tests/test_sparql.py +++ b/tests/test_sparql.py @@ -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 @@ -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 @@ -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 : . + @prefix foaf: . + + :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: + 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: . + + _:a foaf:name "Alice" . + _:a foaf:mbox . + """ + ) + query = dedent( + """ + PREFIX foaf: + PREFIX vcard: + CONSTRUCT { 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: . + + _:a foaf:name "Alice" . + _:a foaf:homepage . + + _:b foaf:name "Bob" . + _:b foaf:mbox . + """ + ) + query = dedent( + """ + PREFIX foaf: + 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: . + @prefix vcard: . + @prefix exOrg: . + @prefix rdf: . + @prefix owl: . + + exOrg:Allice + exOrg:employeeId "1234" ; + foaf:mbox_sha1sum "ABCD1234" . + + foaf:mbox_sha1sum rdf:type owl:InverseFunctionalProperty . + """ + ) + query = dedent( + """ + PREFIX foaf: + 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"), + ), + ] + ) diff --git a/tests/test_triplestore.py b/tests/test_triplestore.py index 33910e60..5a51c88f 100644 --- a/tests/test_triplestore.py +++ b/tests/test_triplestore.py @@ -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") diff --git a/tripper/backends/rdflib.py b/tripper/backends/rdflib.py index 118ac9a2..a9234509 100644 --- a/tripper/backends/rdflib.py +++ b/tripper/backends/rdflib.py @@ -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 @@ -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 @@ -71,7 +71,7 @@ def __init__( 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. @@ -88,26 +88,9 @@ def __init__( 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.""" @@ -182,7 +165,9 @@ def serialize( 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: @@ -190,11 +175,39 @@ def query(self, query_object, **kwargs) -> "List[Tuple[str, ...]]": 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 + else: + warnings.warn( + "Unknown return type from rdflib.query(). Return it unprocessed." + ) + return result # type: ignore + + 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 def update(self, update_object, **kwargs) -> None: """Update triplestore with SPARQL. @@ -234,3 +247,26 @@ def namespaces(self) -> dict: 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) + ) + ), + ) diff --git a/tripper/interface.py b/tripper/interface.py index 6e15cc81..7c1da86e 100644 --- a/tripper/interface.py +++ b/tripper/interface.py @@ -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): diff --git a/tripper/triplestore.py b/tripper/triplestore.py index c1407c8e..c7907051 100644 --- a/tripper/triplestore.py +++ b/tripper/triplestore.py @@ -386,7 +386,9 @@ def serialize( ts.bind(prefix, iri) return ts.serialize(destination=destination, format=format, **kwargs) - 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: @@ -394,11 +396,17 @@ def query(self, query_object, **kwargs) -> "List[Tuple[str, ...]]": kwargs: Keyword arguments passed to the backend query() method. 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 Note: - This method is intended for SELECT queries. Use - the update() method for INSERT and DELETE queries. + This method is intended for SELECT, ASK, CONSTRUCT and + DESCRIBE queries. Use the update() method for INSERT and + DELETE queries. + + Not all backends may support all types of queries. """ self._check_method("query") @@ -413,7 +421,7 @@ def update(self, update_object, **kwargs) -> None: Note: This method is intended for INSERT and DELETE queries. Use - the query() method for SELECT queries. + the query() method for SELECT, ASK, CONSTRUCT and DESCRIBE queries. """ self._check_method("update")