diff --git a/pyproject.toml b/pyproject.toml index 9e0bce9..b62ddca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,12 @@ dev = [ ] docs = ["furo>=2024.8.6", "sphinx>=8"] +[tool] +rye = { dev-dependencies = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", +] } + [tool.semantic_release] version_toml = ["pyproject.toml:project.version"] branch = "main" diff --git a/rdf4j_python/__init__.py b/rdf4j_python/__init__.py index 2a65acb..f0477e5 100644 --- a/rdf4j_python/__init__.py +++ b/rdf4j_python/__init__.py @@ -14,6 +14,7 @@ NamespaceException, NetworkError, QueryError, + QueryTypeMismatchError, Rdf4jError, RepositoryCreationException, RepositoryDeletionException, @@ -35,8 +36,10 @@ Predicate, Quad, QuadResultSet, + QueryBindings, RepositoryMetadata, Subject, + Term, Triple, Variable, ) @@ -61,6 +64,7 @@ "NamespaceException", "NetworkError", "QueryError", + "QueryTypeMismatchError", "TransactionError", "TransactionStateError", # Model types @@ -77,5 +81,7 @@ "Predicate", "Object", "Context", + "Term", + "QueryBindings", "QuadResultSet", ] diff --git a/rdf4j_python/_driver/_async_repository.py b/rdf4j_python/_driver/_async_repository.py index 441d30e..06b848e 100644 --- a/rdf4j_python/_driver/_async_repository.py +++ b/rdf4j_python/_driver/_async_repository.py @@ -10,6 +10,8 @@ from rdf4j_python._driver._async_transaction import AsyncTransaction, IsolationLevel from rdf4j_python.exception.repo_exception import ( NamespaceException, + QueryError, + QueryTypeMismatchError, RepositoryInternalException, RepositoryNotFoundException, RepositoryUpdateException, @@ -17,12 +19,16 @@ from rdf4j_python.model import Namespace from rdf4j_python.model.term import ( IRI, + BlankNode, Context, + Literal, Object, Predicate, Quad, QuadResultSet, + QueryBindings, Subject, + Term, Triple, ) from rdf4j_python.utils.const import Rdf4jContentType @@ -114,6 +120,64 @@ def _detect_query_type(query: str) -> str: return first_word +def _serialize_binding_value(term: Term) -> str: + """Serialize an RDF term to N-Triples format for use in query bindings. + + Args: + term: An RDF term (IRI, BlankNode, or Literal). + + Returns: + str: N-Triples serialization of the term. + + Raises: + TypeError: If the term type is not supported. + """ + if isinstance(term, og.NamedNode): + return f"<{term.value}>" + elif isinstance(term, og.BlankNode): + return f"_:{term.value}" + elif isinstance(term, og.Literal): + # Escape special characters in the value + escaped = term.value.replace("\\", "\\\\").replace('"', '\\"') + if term.language: + return f'"{escaped}"@{term.language}' + elif term.datatype and term.datatype.value != "http://www.w3.org/2001/XMLSchema#string": + return f'"{escaped}"^^<{term.datatype.value}>' + else: + return f'"{escaped}"' + else: + raise TypeError(f"Unsupported term type: {type(term)}") + + +def _build_query_params( + query: str, + infer: bool, + bindings: Optional[QueryBindings] = None, +) -> dict[str, str]: + """Build query parameters for a SPARQL query request. + + Args: + query: The SPARQL query string. + infer: Whether to include inferred statements. + bindings: Optional variable bindings. + + Returns: + dict: Query parameters for the HTTP request. + """ + params: dict[str, str] = { + "query": query, + "infer": str(infer).lower(), + } + + if bindings: + for var_name, term in bindings.items(): + # Variable names should not include the ? prefix + clean_name = var_name.lstrip("?") + params[f"${clean_name}"] = _serialize_binding_value(term) + + return params + + class AsyncRdf4JRepository: """Asynchronous interface for interacting with an RDF4J repository.""" @@ -148,26 +212,236 @@ async def get_sparql_wrapper(self) -> "SPARQLWrapper": ) return self._sparql_wrapper + async def select( + self, + query: str, + infer: bool = True, + bindings: Optional[QueryBindings] = None, + strict: bool = False, + ) -> og.QuerySolutions: + """Execute a SPARQL SELECT query. + + Args: + query: The SPARQL SELECT query string. + infer: Whether to include inferred statements. Defaults to True. + bindings: Optional variable bindings to substitute in the query. + Keys are variable names (without ?), values are RDF terms. + strict: If True, validate that query is a SELECT before execution. + + Returns: + QuerySolutions: Iterator of query solution mappings. + + Raises: + QueryTypeMismatchError: If strict=True and query is not SELECT. + QueryError: If the query is malformed or execution fails. + RepositoryNotFoundException: If the repository doesn't exist. + + Example: + >>> result = await repo.select( + ... "SELECT ?name WHERE { ?person ex:name ?name }", + ... bindings={"person": IRI("http://example.org/alice")} + ... ) + >>> for solution in result: + ... print(solution["name"].value) + """ + if strict: + detected = _detect_query_type(query) + if detected != "SELECT": + raise QueryTypeMismatchError("SELECT", detected or "UNKNOWN", query) + + path = f"/repositories/{self._repository_id}" + params = _build_query_params(query, infer, bindings) + headers = {"Accept": Rdf4jContentType.SPARQL_RESULTS_JSON} + + response = await self._client.get(path, params=params, headers=headers) + self._handle_repo_not_found_exception(response) + + if response.status_code != httpx.codes.OK: + raise QueryError(f"Query failed: {response.status_code} - {response.text}") + + result = og.parse_query_results(response.text, format=og.QueryResultsFormat.JSON) + if not isinstance(result, og.QuerySolutions): + raise QueryError(f"Expected QuerySolutions but got {type(result).__name__}") + + return result + + async def ask( + self, + query: str, + infer: bool = True, + bindings: Optional[QueryBindings] = None, + strict: bool = False, + ) -> bool: + """Execute a SPARQL ASK query. + + Args: + query: The SPARQL ASK query string. + infer: Whether to include inferred statements. Defaults to True. + bindings: Optional variable bindings to substitute in the query. + strict: If True, validate that query is an ASK before execution. + + Returns: + bool: True if the query pattern has at least one match, False otherwise. + + Raises: + QueryTypeMismatchError: If strict=True and query is not ASK. + QueryError: If the query is malformed or execution fails. + RepositoryNotFoundException: If the repository doesn't exist. + + Example: + >>> exists = await repo.ask("ASK { ?p ?o }") + >>> if exists: + ... print("Alice exists in the repository") + """ + if strict: + detected = _detect_query_type(query) + if detected != "ASK": + raise QueryTypeMismatchError("ASK", detected or "UNKNOWN", query) + + path = f"/repositories/{self._repository_id}" + params = _build_query_params(query, infer, bindings) + headers = {"Accept": Rdf4jContentType.SPARQL_RESULTS_JSON} + + response = await self._client.get(path, params=params, headers=headers) + self._handle_repo_not_found_exception(response) + + if response.status_code != httpx.codes.OK: + raise QueryError(f"Query failed: {response.status_code} - {response.text}") + + result = og.parse_query_results(response.text, format=og.QueryResultsFormat.JSON) + if not isinstance(result, og.QueryBoolean): + raise QueryError(f"Expected QueryBoolean but got {type(result).__name__}") + + return bool(result) + + async def construct( + self, + query: str, + infer: bool = True, + bindings: Optional[QueryBindings] = None, + strict: bool = False, + ) -> og.QueryTriples: + """Execute a SPARQL CONSTRUCT query. + + Args: + query: The SPARQL CONSTRUCT query string. + infer: Whether to include inferred statements. Defaults to True. + bindings: Optional variable bindings to substitute in the query. + strict: If True, validate that query is CONSTRUCT before execution. + + Returns: + QueryTriples: Iterator of constructed RDF triples. + + Raises: + QueryTypeMismatchError: If strict=True and query is not CONSTRUCT. + QueryError: If the query is malformed or execution fails. + RepositoryNotFoundException: If the repository doesn't exist. + + Example: + >>> triples = await repo.construct(''' + ... CONSTRUCT { ?s ex:newProp ?o } + ... WHERE { ?s ex:oldProp ?o } + ... ''') + >>> for triple in triples: + ... print(f"{triple.subject} {triple.predicate} {triple.object}") + """ + if strict: + detected = _detect_query_type(query) + if detected != "CONSTRUCT": + raise QueryTypeMismatchError("CONSTRUCT", detected or "UNKNOWN", query) + + path = f"/repositories/{self._repository_id}" + params = _build_query_params(query, infer, bindings) + headers = {"Accept": Rdf4jContentType.NTRIPLES} + + response = await self._client.get(path, params=params, headers=headers) + self._handle_repo_not_found_exception(response) + + if response.status_code != httpx.codes.OK: + raise QueryError(f"Query failed: {response.status_code} - {response.text}") + + # Parse N-Triples response and convert to QueryTriples + store = og.Store() + for quad in og.parse(response.text, format=og.RdfFormat.N_TRIPLES): + store.add(quad) + return store.query("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }") + + async def describe( + self, + query: str, + infer: bool = True, + bindings: Optional[QueryBindings] = None, + strict: bool = False, + ) -> og.QueryTriples: + """Execute a SPARQL DESCRIBE query. + + Args: + query: The SPARQL DESCRIBE query string. + infer: Whether to include inferred statements. Defaults to True. + bindings: Optional variable bindings to substitute in the query. + strict: If True, validate that query is DESCRIBE before execution. + + Returns: + QueryTriples: Iterator of RDF triples describing the resource(s). + + Raises: + QueryTypeMismatchError: If strict=True and query is not DESCRIBE. + QueryError: If the query is malformed or execution fails. + RepositoryNotFoundException: If the repository doesn't exist. + + Example: + >>> triples = await repo.describe("DESCRIBE ") + >>> for triple in triples: + ... print(f" {triple.predicate}: {triple.object}") + """ + if strict: + detected = _detect_query_type(query) + if detected != "DESCRIBE": + raise QueryTypeMismatchError("DESCRIBE", detected or "UNKNOWN", query) + + path = f"/repositories/{self._repository_id}" + params = _build_query_params(query, infer, bindings) + headers = {"Accept": Rdf4jContentType.NTRIPLES} + + response = await self._client.get(path, params=params, headers=headers) + self._handle_repo_not_found_exception(response) + + if response.status_code != httpx.codes.OK: + raise QueryError(f"Query failed: {response.status_code} - {response.text}") + + # Parse N-Triples response and convert to QueryTriples + store = og.Store() + for quad in og.parse(response.text, format=og.RdfFormat.N_TRIPLES): + store.add(quad) + return store.query("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }") + async def query( self, sparql_query: str, infer: bool = True, + bindings: Optional[QueryBindings] = None, ) -> og.QuerySolutions | og.QueryBoolean | og.QueryTriples: """Executes a SPARQL query (SELECT, ASK, CONSTRUCT, or DESCRIBE). + This is a generic query method that auto-detects the query type. + For better type safety, consider using the specific methods: + select(), ask(), construct(), or describe(). + Args: - sparql_query (str): The SPARQL query string. - infer (bool): Whether to include inferred statements. Defaults to True. + sparql_query: The SPARQL query string. + infer: Whether to include inferred statements. Defaults to True. + bindings: Optional variable bindings to substitute in the query. + Keys are variable names (without ?), values are RDF terms. Returns: - og.QuerySolutions | og.QueryBoolean | og.QueryTriples: Parsed query results. + QuerySolutions | QueryBoolean | QueryTriples: Parsed query results. Note: This method correctly handles queries with PREFIX declarations, BASE URIs, and comments before the query keyword. """ path = f"/repositories/{self._repository_id}" - params = {"query": sparql_query, "infer": str(infer).lower()} + params = _build_query_params(sparql_query, infer, bindings) # Detect query type (handles PREFIX, BASE, comments) query_type = _detect_query_type(sparql_query) @@ -205,23 +479,42 @@ async def query( ) async def update( - self, sparql_update_query: str, content_type: Rdf4jContentType + self, + sparql_update: str, + bindings: Optional[QueryBindings] = None, ) -> None: """Executes a SPARQL UPDATE command. Args: - sparql_update (str): The SPARQL update string. + sparql_update: The SPARQL update string (INSERT, DELETE, CLEAR, etc.). + bindings: Optional variable bindings for parameterized updates. + Keys are variable names (without ?), values are RDF terms. Raises: RepositoryNotFoundException: If the repository doesn't exist. - httpx.HTTPStatusError: If the update fails. + RepositoryUpdateException: If the update fails. + + Example: + >>> await repo.update(''' + ... PREFIX ex: + ... INSERT DATA { ex:alice ex:age 30 } + ... ''') """ # SPARQL UPDATE operations return HTTP 204 No Content on success. # No result data is returned as per SPARQL 1.1 UPDATE specification. path = f"/repositories/{self._repository_id}/statements" - headers = {"Content-Type": content_type} + headers: dict[str, str] = {"Content-Type": Rdf4jContentType.SPARQL_UPDATE} + + # Build params for bindings if provided + params: Optional[dict[str, str]] = None + if bindings: + params = {} + for var_name, term in bindings.items(): + clean_name = var_name.lstrip("?") + params[f"${clean_name}"] = _serialize_binding_value(term) + response = await self._client.post( - path, content=sparql_update_query, headers=headers + path, content=sparql_update, headers=headers, params=params ) self._handle_repo_not_found_exception(response) if response.status_code != httpx.codes.NO_CONTENT: diff --git a/rdf4j_python/exception/__init__.py b/rdf4j_python/exception/__init__.py index 432075b..74ddc29 100644 --- a/rdf4j_python/exception/__init__.py +++ b/rdf4j_python/exception/__init__.py @@ -6,6 +6,7 @@ NamespaceException, NetworkError, QueryError, + QueryTypeMismatchError, Rdf4jError, RepositoryCreationException, RepositoryDeletionException, @@ -28,6 +29,7 @@ "NamespaceException", "NetworkError", "QueryError", + "QueryTypeMismatchError", "TransactionError", "TransactionStateError", ] diff --git a/rdf4j_python/exception/repo_exception.py b/rdf4j_python/exception/repo_exception.py index 71ad43c..46f4415 100644 --- a/rdf4j_python/exception/repo_exception.py +++ b/rdf4j_python/exception/repo_exception.py @@ -49,6 +49,27 @@ class QueryError(Rdf4jError): """Exception raised when a SPARQL query is invalid or fails.""" +class QueryTypeMismatchError(QueryError): + """Exception raised when query type doesn't match the method called. + + For example, calling select() with an ASK query when strict=True. + + Attributes: + expected: The expected query type (e.g., "SELECT"). + actual: The detected query type (e.g., "ASK"). + query: The original query string. + """ + + def __init__(self, expected: str, actual: str, query: str): + self.expected = expected + self.actual = actual + self.query = query + truncated = query[:100] + "..." if len(query) > 100 else query + super().__init__( + f"Expected {expected} query but detected {actual}. Query: {truncated}" + ) + + class TransactionError(Rdf4jError): """Base exception for transaction-related errors.""" diff --git a/rdf4j_python/model/__init__.py b/rdf4j_python/model/__init__.py index f346e89..a7c06ae 100644 --- a/rdf4j_python/model/__init__.py +++ b/rdf4j_python/model/__init__.py @@ -14,7 +14,9 @@ Predicate, Quad, QuadResultSet, + QueryBindings, Subject, + Term, Triple, Variable, ) @@ -33,5 +35,7 @@ "Predicate", "Object", "Context", + "Term", + "QueryBindings", "QuadResultSet", ] diff --git a/rdf4j_python/model/term.py b/rdf4j_python/model/term.py index 31938ac..6a860dd 100644 --- a/rdf4j_python/model/term.py +++ b/rdf4j_python/model/term.py @@ -16,6 +16,10 @@ Object: TypeAlias = IRI | BlankNode | Literal Context: TypeAlias = IRI | BlankNode | DefaultGraph | None +# Type for RDF terms used in SPARQL variable bindings +Term: TypeAlias = IRI | BlankNode | Literal +# Type for SPARQL variable bindings (variable name -> RDF term) +QueryBindings: TypeAlias = dict[str, Term] QuadResultSet: TypeAlias = og.QuadParser @@ -31,5 +35,7 @@ "Predicate", "Object", "Context", + "Term", + "QueryBindings", "QuadResultSet", ] diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..100d539 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,46 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false + +-e file:. +anyio==4.12.1 + # via httpx +certifi==2026.1.4 + # via httpcore + # via httpx +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via rdf4j-python +idna==3.11 + # via anyio + # via httpx +iniconfig==2.3.0 + # via pytest +packaging==26.0 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.19.2 + # via pytest +pyoxigraph==0.5.4 + # via rdf4j-python +pyparsing==3.3.2 + # via rdflib +pytest==9.0.2 + # via pytest-asyncio +pytest-asyncio==1.3.0 +rdflib==7.5.0 + # via sparqlwrapper +sparqlwrapper==2.0.0 + # via rdf4j-python +typing-extensions==4.15.0 + # via anyio + # via pytest-asyncio diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..7b0c0f8 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,34 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: true +# with-sources: false + +-e file:. +anyio==4.12.1 + # via httpx +certifi==2026.1.4 + # via httpcore + # via httpx +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via rdf4j-python +idna==3.11 + # via anyio + # via httpx +pyoxigraph==0.5.4 + # via rdf4j-python +pyparsing==3.3.2 + # via rdflib +rdflib==7.5.0 + # via sparqlwrapper +sparqlwrapper==2.0.0 + # via rdf4j-python +typing-extensions==4.15.0 + # via anyio diff --git a/tests/test_fine_grained_queries.py b/tests/test_fine_grained_queries.py new file mode 100644 index 0000000..5965797 --- /dev/null +++ b/tests/test_fine_grained_queries.py @@ -0,0 +1,574 @@ +"""Tests for fine-grained SPARQL query interfaces.""" +import pytest +import pytest_asyncio +from pyoxigraph import QuerySolutions, QueryTriples + +from rdf4j_python import AsyncRdf4JRepository, QueryTypeMismatchError +from rdf4j_python.model.term import IRI, Literal, Quad +from rdf4j_python.model.vocabulary import EXAMPLE as ex +from rdf4j_python.model.vocabulary import RDF, XSD + + +@pytest_asyncio.fixture +async def sample_repo(mem_repo: AsyncRdf4JRepository): + """Repository with sample data for testing.""" + data = [ + Quad(ex["alice"], RDF.type, ex["Person"], ex["graph"]), + Quad(ex["alice"], ex["name"], Literal("Alice"), ex["graph"]), + Quad(ex["alice"], ex["age"], Literal("30", datatype=XSD.integer), ex["graph"]), + Quad(ex["bob"], RDF.type, ex["Person"], ex["graph"]), + Quad(ex["bob"], ex["name"], Literal("Bob"), ex["graph"]), + Quad(ex["bob"], ex["age"], Literal("25", datatype=XSD.integer), ex["graph"]), + ] + await mem_repo.add_statements(data) + return mem_repo + + +class TestSelectMethod: + """Tests for the select() method.""" + + @pytest.mark.asyncio + async def test_select_basic(self, sample_repo): + """Test basic SELECT query.""" + result = await sample_repo.select( + "SELECT ?name WHERE { ?s ?name }" + ) + assert isinstance(result, QuerySolutions) + names = {solution["name"].value for solution in result} + assert names == {"Alice", "Bob"} + + @pytest.mark.asyncio + async def test_select_with_bindings(self, sample_repo): + """Test SELECT with variable bindings.""" + result = await sample_repo.select( + "SELECT ?name WHERE { ?person ?name }", + bindings={"person": IRI("http://example.org/alice")}, + ) + assert isinstance(result, QuerySolutions) + results_list = list(result) + assert len(results_list) == 1 + assert results_list[0]["name"].value == "Alice" + + @pytest.mark.asyncio + async def test_select_strict_mode_valid(self, sample_repo): + """Test SELECT with strict=True and valid query.""" + result = await sample_repo.select("SELECT * WHERE { ?s ?p ?o }", strict=True) + assert isinstance(result, QuerySolutions) + + @pytest.mark.asyncio + async def test_select_strict_mode_invalid(self, sample_repo): + """Test SELECT with strict=True and ASK query raises error.""" + with pytest.raises(QueryTypeMismatchError) as exc_info: + await sample_repo.select("ASK { ?s ?p ?o }", strict=True) + assert exc_info.value.expected == "SELECT" + assert exc_info.value.actual == "ASK" + + @pytest.mark.asyncio + async def test_select_with_prefix(self, sample_repo): + """Test SELECT with PREFIX declarations.""" + result = await sample_repo.select( + """ + PREFIX ex: + SELECT ?name WHERE { ?s ex:name ?name } + """ + ) + assert isinstance(result, QuerySolutions) + names = {solution["name"].value for solution in result} + assert names == {"Alice", "Bob"} + + +class TestAskMethod: + """Tests for the ask() method.""" + + @pytest.mark.asyncio + async def test_ask_true(self, sample_repo): + """Test ASK query returning True.""" + result = await sample_repo.ask("ASK { ?s 'Alice' }") + assert result is True + + @pytest.mark.asyncio + async def test_ask_false(self, sample_repo): + """Test ASK query returning False.""" + result = await sample_repo.ask( + "ASK { ?s 'NonExistent' }" + ) + assert result is False + + @pytest.mark.asyncio + async def test_ask_with_bindings(self, sample_repo): + """Test ASK with variable bindings.""" + result = await sample_repo.ask( + "ASK { ?person ?name }", + bindings={"person": IRI("http://example.org/alice")}, + ) + assert result is True + + @pytest.mark.asyncio + async def test_ask_strict_mode_valid(self, sample_repo): + """Test ASK with strict=True and valid query.""" + result = await sample_repo.ask("ASK { ?s ?p ?o }", strict=True) + assert isinstance(result, bool) + + @pytest.mark.asyncio + async def test_ask_strict_mode_invalid(self, sample_repo): + """Test ASK with strict=True and SELECT query raises error.""" + with pytest.raises(QueryTypeMismatchError) as exc_info: + await sample_repo.ask("SELECT * WHERE { ?s ?p ?o }", strict=True) + assert exc_info.value.expected == "ASK" + assert exc_info.value.actual == "SELECT" + + +class TestConstructMethod: + """Tests for the construct() method.""" + + @pytest.mark.asyncio + async def test_construct_basic(self, sample_repo): + """Test basic CONSTRUCT query.""" + result = await sample_repo.construct( + """ + CONSTRUCT { ?s ?name } + WHERE { ?s ?name } + """ + ) + assert isinstance(result, QueryTriples) + triples_list = list(result) + assert len(triples_list) == 2 + + @pytest.mark.asyncio + async def test_construct_with_bindings(self, sample_repo): + """Test CONSTRUCT with variable bindings.""" + result = await sample_repo.construct( + """ + CONSTRUCT { ?person ?name } + WHERE { ?person ?name } + """, + bindings={"person": IRI("http://example.org/alice")}, + ) + triples_list = list(result) + assert len(triples_list) == 1 + assert str(triples_list[0].subject) == "" + + @pytest.mark.asyncio + async def test_construct_strict_mode_valid(self, sample_repo): + """Test CONSTRUCT with strict=True and valid query.""" + result = await sample_repo.construct( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", strict=True + ) + assert isinstance(result, QueryTriples) + + @pytest.mark.asyncio + async def test_construct_strict_mode_invalid(self, sample_repo): + """Test CONSTRUCT with strict=True and SELECT query raises error.""" + with pytest.raises(QueryTypeMismatchError) as exc_info: + await sample_repo.construct("SELECT * WHERE { ?s ?p ?o }", strict=True) + assert exc_info.value.expected == "CONSTRUCT" + assert exc_info.value.actual == "SELECT" + + +class TestDescribeMethod: + """Tests for the describe() method.""" + + @pytest.mark.asyncio + async def test_describe_resource(self, sample_repo): + """Test DESCRIBE for a specific resource.""" + result = await sample_repo.describe("DESCRIBE ") + assert isinstance(result, QueryTriples) + triples_list = list(result) + assert len(triples_list) > 0 + + @pytest.mark.asyncio + async def test_describe_with_where(self, sample_repo): + """Test DESCRIBE with WHERE clause.""" + result = await sample_repo.describe( + """ + DESCRIBE ?person WHERE { + ?person "Alice" + } + """ + ) + assert isinstance(result, QueryTriples) + + @pytest.mark.asyncio + async def test_describe_strict_mode_valid(self, sample_repo): + """Test DESCRIBE with strict=True and valid query.""" + result = await sample_repo.describe( + "DESCRIBE ", strict=True + ) + assert isinstance(result, QueryTriples) + + @pytest.mark.asyncio + async def test_describe_strict_mode_invalid(self, sample_repo): + """Test DESCRIBE with strict=True and SELECT query raises error.""" + with pytest.raises(QueryTypeMismatchError) as exc_info: + await sample_repo.describe("SELECT * WHERE { ?s ?p ?o }", strict=True) + assert exc_info.value.expected == "DESCRIBE" + assert exc_info.value.actual == "SELECT" + + +class TestQueryMethodWithBindings: + """Tests for the generic query() method with bindings support.""" + + @pytest.mark.asyncio + async def test_query_select_with_bindings(self, sample_repo): + """Test generic query() with SELECT and bindings.""" + result = await sample_repo.query( + "SELECT ?name WHERE { ?person ?name }", + bindings={"person": IRI("http://example.org/alice")}, + ) + assert isinstance(result, QuerySolutions) + results_list = list(result) + assert len(results_list) == 1 + assert results_list[0]["name"].value == "Alice" + + @pytest.mark.asyncio + async def test_query_ask_with_bindings(self, sample_repo): + """Test generic query() with ASK and bindings.""" + from pyoxigraph import QueryBoolean + + result = await sample_repo.query( + "ASK { ?person ?name }", + bindings={"person": IRI("http://example.org/alice")}, + ) + assert isinstance(result, QueryBoolean) + assert bool(result) is True + + +class TestBindingsSerialization: + """Tests for binding value serialization.""" + + @pytest.mark.asyncio + async def test_binding_with_iri(self, sample_repo): + """Test binding with IRI value.""" + result = await sample_repo.select( + "SELECT ?name WHERE { ?person ?name }", + bindings={"person": IRI("http://example.org/alice")}, + ) + results_list = list(result) + assert len(results_list) == 1 + assert results_list[0]["name"].value == "Alice" + + @pytest.mark.asyncio + async def test_binding_with_literal(self, sample_repo): + """Test binding with literal value.""" + result = await sample_repo.select( + "SELECT ?s WHERE { ?s ?name }", + bindings={"name": Literal("Alice")}, + ) + results_list = list(result) + assert len(results_list) == 1 + assert str(results_list[0]["s"]) == "" + + @pytest.mark.asyncio + async def test_binding_with_typed_literal(self, sample_repo): + """Test binding with typed literal.""" + result = await sample_repo.select( + "SELECT ?s WHERE { ?s ?age }", + bindings={"age": Literal("30", datatype=XSD.integer)}, + ) + results_list = list(result) + assert len(results_list) == 1 + assert str(results_list[0]["s"]) == "" + + @pytest.mark.asyncio + async def test_binding_variable_with_question_mark(self, sample_repo): + """Test that variable names with ? prefix are handled correctly.""" + result = await sample_repo.select( + "SELECT ?name WHERE { ?person ?name }", + bindings={"?person": IRI("http://example.org/alice")}, # with ? prefix + ) + results_list = list(result) + assert len(results_list) == 1 + assert results_list[0]["name"].value == "Alice" + + +class TestQueryTypeMismatchError: + """Tests for QueryTypeMismatchError exception.""" + + def test_error_attributes(self): + """Test that error has expected attributes.""" + error = QueryTypeMismatchError("SELECT", "ASK", "ASK { ?s ?p ?o }") + assert error.expected == "SELECT" + assert error.actual == "ASK" + assert error.query == "ASK { ?s ?p ?o }" + + def test_error_message(self): + """Test error message format.""" + error = QueryTypeMismatchError("SELECT", "ASK", "ASK { ?s ?p ?o }") + assert "Expected SELECT query but detected ASK" in str(error) + + def test_error_truncates_long_query(self): + """Test that long queries are truncated in error message.""" + long_query = "SELECT * WHERE { " + "?s ?p ?o . " * 50 + "}" + error = QueryTypeMismatchError("ASK", "SELECT", long_query) + assert "..." in str(error) + + +class TestStrictModeMismatch: + """Comprehensive tests for strict mode query type mismatch detection.""" + + # SELECT method with wrong query types + @pytest.mark.asyncio + async def test_select_with_ask_query(self, sample_repo): + """Test SELECT rejects ASK query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select("ASK { ?s ?p ?o }", strict=True) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "ASK" + + @pytest.mark.asyncio + async def test_select_with_construct_query(self, sample_repo): + """Test SELECT rejects CONSTRUCT query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", strict=True + ) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "CONSTRUCT" + + @pytest.mark.asyncio + async def test_select_with_describe_query(self, sample_repo): + """Test SELECT rejects DESCRIBE query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select( + "DESCRIBE ", strict=True + ) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "DESCRIBE" + + # ASK method with wrong query types + @pytest.mark.asyncio + async def test_ask_with_select_query(self, sample_repo): + """Test ASK rejects SELECT query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.ask("SELECT * WHERE { ?s ?p ?o }", strict=True) + assert exc.value.expected == "ASK" + assert exc.value.actual == "SELECT" + + @pytest.mark.asyncio + async def test_ask_with_construct_query(self, sample_repo): + """Test ASK rejects CONSTRUCT query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.ask( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", strict=True + ) + assert exc.value.expected == "ASK" + assert exc.value.actual == "CONSTRUCT" + + @pytest.mark.asyncio + async def test_ask_with_describe_query(self, sample_repo): + """Test ASK rejects DESCRIBE query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.ask( + "DESCRIBE ", strict=True + ) + assert exc.value.expected == "ASK" + assert exc.value.actual == "DESCRIBE" + + # CONSTRUCT method with wrong query types + @pytest.mark.asyncio + async def test_construct_with_select_query(self, sample_repo): + """Test CONSTRUCT rejects SELECT query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.construct("SELECT * WHERE { ?s ?p ?o }", strict=True) + assert exc.value.expected == "CONSTRUCT" + assert exc.value.actual == "SELECT" + + @pytest.mark.asyncio + async def test_construct_with_ask_query(self, sample_repo): + """Test CONSTRUCT rejects ASK query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.construct("ASK { ?s ?p ?o }", strict=True) + assert exc.value.expected == "CONSTRUCT" + assert exc.value.actual == "ASK" + + @pytest.mark.asyncio + async def test_construct_with_describe_query(self, sample_repo): + """Test CONSTRUCT rejects DESCRIBE query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.construct( + "DESCRIBE ", strict=True + ) + assert exc.value.expected == "CONSTRUCT" + assert exc.value.actual == "DESCRIBE" + + # DESCRIBE method with wrong query types + @pytest.mark.asyncio + async def test_describe_with_select_query(self, sample_repo): + """Test DESCRIBE rejects SELECT query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.describe("SELECT * WHERE { ?s ?p ?o }", strict=True) + assert exc.value.expected == "DESCRIBE" + assert exc.value.actual == "SELECT" + + @pytest.mark.asyncio + async def test_describe_with_ask_query(self, sample_repo): + """Test DESCRIBE rejects ASK query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.describe("ASK { ?s ?p ?o }", strict=True) + assert exc.value.expected == "DESCRIBE" + assert exc.value.actual == "ASK" + + @pytest.mark.asyncio + async def test_describe_with_construct_query(self, sample_repo): + """Test DESCRIBE rejects CONSTRUCT query in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.describe( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", strict=True + ) + assert exc.value.expected == "DESCRIBE" + assert exc.value.actual == "CONSTRUCT" + + # Test with PREFIX declarations + @pytest.mark.asyncio + async def test_mismatch_with_prefix(self, sample_repo): + """Test mismatch detection works with PREFIX declarations.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select( + "PREFIX ex: ASK { ?s ex:name ?o }", + strict=True, + ) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "ASK" + + +class TestStrictModeBlocksUpdateQueries: + """Tests that strict mode blocks INSERT/DELETE queries on read methods. + + This ensures that SPARQL UPDATE queries cannot accidentally be sent + to read query endpoints when strict validation is enabled. + """ + + # SELECT should block INSERT/DELETE + @pytest.mark.asyncio + async def test_select_blocks_insert_data(self, sample_repo): + """Test SELECT rejects INSERT DATA in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select( + "INSERT DATA { }", + strict=True, + ) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "INSERT" + + @pytest.mark.asyncio + async def test_select_blocks_delete_data(self, sample_repo): + """Test SELECT rejects DELETE DATA in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select( + "DELETE DATA { }", + strict=True, + ) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "DELETE" + + @pytest.mark.asyncio + async def test_select_blocks_delete_insert_where(self, sample_repo): + """Test SELECT rejects DELETE/INSERT WHERE in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select( + "DELETE { ?s ?p ?o } INSERT { ?s ?p 'new' } WHERE { ?s ?p ?o }", + strict=True, + ) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "DELETE" + + # ASK should block INSERT/DELETE + @pytest.mark.asyncio + async def test_ask_blocks_insert_data(self, sample_repo): + """Test ASK rejects INSERT DATA in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.ask( + "INSERT DATA { }", + strict=True, + ) + assert exc.value.expected == "ASK" + assert exc.value.actual == "INSERT" + + @pytest.mark.asyncio + async def test_ask_blocks_delete_where(self, sample_repo): + """Test ASK rejects DELETE WHERE in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.ask( + "DELETE WHERE { ?s ?o }", + strict=True, + ) + assert exc.value.expected == "ASK" + assert exc.value.actual == "DELETE" + + # CONSTRUCT should block INSERT/DELETE + @pytest.mark.asyncio + async def test_construct_blocks_insert_data(self, sample_repo): + """Test CONSTRUCT rejects INSERT DATA in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.construct( + "INSERT DATA { }", + strict=True, + ) + assert exc.value.expected == "CONSTRUCT" + assert exc.value.actual == "INSERT" + + @pytest.mark.asyncio + async def test_construct_blocks_delete_data(self, sample_repo): + """Test CONSTRUCT rejects DELETE DATA in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.construct( + "DELETE DATA { }", + strict=True, + ) + assert exc.value.expected == "CONSTRUCT" + assert exc.value.actual == "DELETE" + + # DESCRIBE should block INSERT/DELETE + @pytest.mark.asyncio + async def test_describe_blocks_insert_data(self, sample_repo): + """Test DESCRIBE rejects INSERT DATA in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.describe( + "INSERT DATA { }", + strict=True, + ) + assert exc.value.expected == "DESCRIBE" + assert exc.value.actual == "INSERT" + + @pytest.mark.asyncio + async def test_describe_blocks_delete_data(self, sample_repo): + """Test DESCRIBE rejects DELETE DATA in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.describe( + "DELETE DATA { }", + strict=True, + ) + assert exc.value.expected == "DESCRIBE" + assert exc.value.actual == "DELETE" + + # Test with PREFIX declarations + @pytest.mark.asyncio + async def test_select_blocks_prefixed_insert(self, sample_repo): + """Test SELECT rejects INSERT with PREFIX in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select( + "PREFIX ex: INSERT DATA { ex:s ex:p ex:o }", + strict=True, + ) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "INSERT" + + # Test CLEAR and DROP (other UPDATE operations) + @pytest.mark.asyncio + async def test_select_blocks_clear(self, sample_repo): + """Test SELECT rejects CLEAR in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select("CLEAR ALL", strict=True) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "CLEAR" + + @pytest.mark.asyncio + async def test_select_blocks_drop(self, sample_repo): + """Test SELECT rejects DROP in strict mode.""" + with pytest.raises(QueryTypeMismatchError) as exc: + await sample_repo.select( + "DROP GRAPH ", + strict=True, + ) + assert exc.value.expected == "SELECT" + assert exc.value.actual == "DROP"