mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-04-25 08:26:21 +02:00
SPARQL query service (#754)
SPARQL 1.1 query service wrapping pub/sub triples interface Add a backend-agnostic SPARQL query service that parses SPARQL queries using rdflib, decomposes them into triple pattern lookups via the existing TriplesClient pub/sub interface, and performs in-memory joins, filters, and projections. Includes: - SPARQL parser, algebra evaluator, expression evaluator, solution sequence operations (BGP, JOIN, OPTIONAL, UNION, FILTER, BIND, VALUES, GROUP BY, ORDER BY, LIMIT/OFFSET, DISTINCT, aggregates) - FlowProcessor service with TriplesClientSpec - Gateway dispatcher, request/response translators, API spec - Python SDK method (FlowInstance.sparql_query) - CLI command (tg-invoke-sparql-query) - Tech spec (docs/tech-specs/sparql-query.md) New unit tests for SPARQL query
This commit is contained in:
parent
62c30a3a50
commit
d9dc4cbab5
23 changed files with 3498 additions and 3 deletions
424
tests/unit/test_query/test_sparql_expressions.py
Normal file
424
tests/unit/test_query/test_sparql_expressions.py
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
"""
|
||||
Tests for SPARQL FILTER expression evaluator.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from trustgraph.schema import Term, IRI, LITERAL, BLANK
|
||||
from trustgraph.query.sparql.expressions import (
|
||||
evaluate_expression, _effective_boolean, _to_string, _to_numeric,
|
||||
_comparable_value,
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def iri(v):
|
||||
return Term(type=IRI, iri=v)
|
||||
|
||||
def lit(v, datatype="", language=""):
|
||||
return Term(type=LITERAL, value=v, datatype=datatype, language=language)
|
||||
|
||||
def blank(v):
|
||||
return Term(type=BLANK, id=v)
|
||||
|
||||
XSD = "http://www.w3.org/2001/XMLSchema#"
|
||||
|
||||
|
||||
class TestEvaluateExpression:
|
||||
"""Test expression evaluation with rdflib algebra nodes."""
|
||||
|
||||
def test_variable_bound(self):
|
||||
from rdflib.term import Variable
|
||||
result = evaluate_expression(Variable("x"), {"x": lit("hello")})
|
||||
assert result.value == "hello"
|
||||
|
||||
def test_variable_unbound(self):
|
||||
from rdflib.term import Variable
|
||||
result = evaluate_expression(Variable("x"), {})
|
||||
assert result is None
|
||||
|
||||
def test_uriref_constant(self):
|
||||
from rdflib import URIRef
|
||||
result = evaluate_expression(
|
||||
URIRef("http://example.com/a"), {}
|
||||
)
|
||||
assert result.type == IRI
|
||||
assert result.iri == "http://example.com/a"
|
||||
|
||||
def test_literal_constant(self):
|
||||
from rdflib import Literal
|
||||
result = evaluate_expression(Literal("hello"), {})
|
||||
assert result.type == LITERAL
|
||||
assert result.value == "hello"
|
||||
|
||||
def test_boolean_constant(self):
|
||||
assert evaluate_expression(True, {}) is True
|
||||
assert evaluate_expression(False, {}) is False
|
||||
|
||||
def test_numeric_constant(self):
|
||||
assert evaluate_expression(42, {}) == 42
|
||||
assert evaluate_expression(3.14, {}) == 3.14
|
||||
|
||||
def test_none_returns_true(self):
|
||||
assert evaluate_expression(None, {}) is True
|
||||
|
||||
|
||||
class TestRelationalExpressions:
|
||||
"""Test comparison operators via CompValue nodes."""
|
||||
|
||||
def _make_relational(self, left, op, right):
|
||||
from rdflib.plugins.sparql.parserutils import CompValue
|
||||
return CompValue("RelationalExpression",
|
||||
expr=left, op=op, other=right)
|
||||
|
||||
def test_equal_literals(self):
|
||||
from rdflib import Literal
|
||||
expr = self._make_relational(Literal("a"), "=", Literal("a"))
|
||||
assert evaluate_expression(expr, {}) is True
|
||||
|
||||
def test_not_equal_literals(self):
|
||||
from rdflib import Literal
|
||||
expr = self._make_relational(Literal("a"), "!=", Literal("b"))
|
||||
assert evaluate_expression(expr, {}) is True
|
||||
|
||||
def test_less_than(self):
|
||||
from rdflib import Literal
|
||||
expr = self._make_relational(Literal("a"), "<", Literal("b"))
|
||||
assert evaluate_expression(expr, {}) is True
|
||||
|
||||
def test_greater_than(self):
|
||||
from rdflib import Literal
|
||||
expr = self._make_relational(Literal("b"), ">", Literal("a"))
|
||||
assert evaluate_expression(expr, {}) is True
|
||||
|
||||
def test_equal_with_variables(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_relational(Variable("x"), "=", Variable("y"))
|
||||
sol = {"x": lit("same"), "y": lit("same")}
|
||||
assert evaluate_expression(expr, sol) is True
|
||||
|
||||
def test_unequal_with_variables(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_relational(Variable("x"), "=", Variable("y"))
|
||||
sol = {"x": lit("one"), "y": lit("two")}
|
||||
assert evaluate_expression(expr, sol) is False
|
||||
|
||||
def test_none_operand_returns_false(self):
|
||||
from rdflib.term import Variable
|
||||
from rdflib import Literal
|
||||
expr = self._make_relational(Variable("x"), "=", Literal("a"))
|
||||
assert evaluate_expression(expr, {}) is False
|
||||
|
||||
|
||||
class TestLogicalExpressions:
|
||||
|
||||
def _make_and(self, exprs):
|
||||
from rdflib.plugins.sparql.parserutils import CompValue
|
||||
return CompValue("ConditionalAndExpression",
|
||||
expr=exprs[0], other=exprs[1:])
|
||||
|
||||
def _make_or(self, exprs):
|
||||
from rdflib.plugins.sparql.parserutils import CompValue
|
||||
return CompValue("ConditionalOrExpression",
|
||||
expr=exprs[0], other=exprs[1:])
|
||||
|
||||
def _make_not(self, expr):
|
||||
from rdflib.plugins.sparql.parserutils import CompValue
|
||||
return CompValue("UnaryNot", expr=expr)
|
||||
|
||||
def test_and_true_true(self):
|
||||
result = evaluate_expression(self._make_and([True, True]), {})
|
||||
assert result is True
|
||||
|
||||
def test_and_true_false(self):
|
||||
result = evaluate_expression(self._make_and([True, False]), {})
|
||||
assert result is False
|
||||
|
||||
def test_or_false_true(self):
|
||||
result = evaluate_expression(self._make_or([False, True]), {})
|
||||
assert result is True
|
||||
|
||||
def test_or_false_false(self):
|
||||
result = evaluate_expression(self._make_or([False, False]), {})
|
||||
assert result is False
|
||||
|
||||
def test_not_true(self):
|
||||
result = evaluate_expression(self._make_not(True), {})
|
||||
assert result is False
|
||||
|
||||
def test_not_false(self):
|
||||
result = evaluate_expression(self._make_not(False), {})
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestBuiltinFunctions:
|
||||
|
||||
def _make_builtin(self, name, **kwargs):
|
||||
from rdflib.plugins.sparql.parserutils import CompValue
|
||||
return CompValue(f"Builtin_{name}", **kwargs)
|
||||
|
||||
def test_bound_true(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("BOUND", arg=Variable("x"))
|
||||
assert evaluate_expression(expr, {"x": lit("hi")}) is True
|
||||
|
||||
def test_bound_false(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("BOUND", arg=Variable("x"))
|
||||
assert evaluate_expression(expr, {}) is False
|
||||
|
||||
def test_isiri_true(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("isIRI", arg=Variable("x"))
|
||||
assert evaluate_expression(expr, {"x": iri("http://x")}) is True
|
||||
|
||||
def test_isiri_false(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("isIRI", arg=Variable("x"))
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is False
|
||||
|
||||
def test_isliteral_true(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("isLITERAL", arg=Variable("x"))
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is True
|
||||
|
||||
def test_isliteral_false(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("isLITERAL", arg=Variable("x"))
|
||||
assert evaluate_expression(expr, {"x": iri("http://x")}) is False
|
||||
|
||||
def test_isblank_true(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("isBLANK", arg=Variable("x"))
|
||||
assert evaluate_expression(expr, {"x": blank("b1")}) is True
|
||||
|
||||
def test_isblank_false(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("isBLANK", arg=Variable("x"))
|
||||
assert evaluate_expression(expr, {"x": iri("http://x")}) is False
|
||||
|
||||
def test_str(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("STR", arg=Variable("x"))
|
||||
result = evaluate_expression(expr, {"x": iri("http://example.com/a")})
|
||||
assert result.type == LITERAL
|
||||
assert result.value == "http://example.com/a"
|
||||
|
||||
def test_lang(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("LANG", arg=Variable("x"))
|
||||
result = evaluate_expression(
|
||||
expr, {"x": lit("hello", language="en")}
|
||||
)
|
||||
assert result.value == "en"
|
||||
|
||||
def test_lang_no_tag(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("LANG", arg=Variable("x"))
|
||||
result = evaluate_expression(expr, {"x": lit("hello")})
|
||||
assert result.value == ""
|
||||
|
||||
def test_datatype(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("DATATYPE", arg=Variable("x"))
|
||||
result = evaluate_expression(
|
||||
expr, {"x": lit("42", datatype=XSD + "integer")}
|
||||
)
|
||||
assert result.type == IRI
|
||||
assert result.iri == XSD + "integer"
|
||||
|
||||
def test_strlen(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("STRLEN", arg=Variable("x"))
|
||||
result = evaluate_expression(expr, {"x": lit("hello")})
|
||||
assert result == 5
|
||||
|
||||
def test_ucase(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("UCASE", arg=Variable("x"))
|
||||
result = evaluate_expression(expr, {"x": lit("hello")})
|
||||
assert result.value == "HELLO"
|
||||
|
||||
def test_lcase(self):
|
||||
from rdflib.term import Variable
|
||||
expr = self._make_builtin("LCASE", arg=Variable("x"))
|
||||
result = evaluate_expression(expr, {"x": lit("HELLO")})
|
||||
assert result.value == "hello"
|
||||
|
||||
def test_contains_true(self):
|
||||
from rdflib.term import Variable
|
||||
from rdflib import Literal
|
||||
expr = self._make_builtin("CONTAINS",
|
||||
arg1=Variable("x"), arg2=Literal("ell"))
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is True
|
||||
|
||||
def test_contains_false(self):
|
||||
from rdflib.term import Variable
|
||||
from rdflib import Literal
|
||||
expr = self._make_builtin("CONTAINS",
|
||||
arg1=Variable("x"), arg2=Literal("xyz"))
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is False
|
||||
|
||||
def test_strstarts_true(self):
|
||||
from rdflib.term import Variable
|
||||
from rdflib import Literal
|
||||
expr = self._make_builtin("STRSTARTS",
|
||||
arg1=Variable("x"), arg2=Literal("hel"))
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is True
|
||||
|
||||
def test_strends_true(self):
|
||||
from rdflib.term import Variable
|
||||
from rdflib import Literal
|
||||
expr = self._make_builtin("STRENDS",
|
||||
arg1=Variable("x"), arg2=Literal("llo"))
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is True
|
||||
|
||||
def test_regex_match(self):
|
||||
from rdflib.term import Variable
|
||||
from rdflib import Literal
|
||||
expr = self._make_builtin("REGEX",
|
||||
text=Variable("x"),
|
||||
pattern=Literal("^hel"),
|
||||
flags=None)
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is True
|
||||
|
||||
def test_regex_case_insensitive(self):
|
||||
from rdflib.term import Variable
|
||||
from rdflib import Literal
|
||||
expr = self._make_builtin("REGEX",
|
||||
text=Variable("x"),
|
||||
pattern=Literal("HELLO"),
|
||||
flags=Literal("i"))
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is True
|
||||
|
||||
def test_regex_no_match(self):
|
||||
from rdflib.term import Variable
|
||||
from rdflib import Literal
|
||||
expr = self._make_builtin("REGEX",
|
||||
text=Variable("x"),
|
||||
pattern=Literal("^world"),
|
||||
flags=None)
|
||||
assert evaluate_expression(expr, {"x": lit("hello")}) is False
|
||||
|
||||
|
||||
class TestEffectiveBoolean:
|
||||
|
||||
def test_true(self):
|
||||
assert _effective_boolean(True) is True
|
||||
|
||||
def test_false(self):
|
||||
assert _effective_boolean(False) is False
|
||||
|
||||
def test_none(self):
|
||||
assert _effective_boolean(None) is False
|
||||
|
||||
def test_nonzero_int(self):
|
||||
assert _effective_boolean(42) is True
|
||||
|
||||
def test_zero_int(self):
|
||||
assert _effective_boolean(0) is False
|
||||
|
||||
def test_nonempty_string(self):
|
||||
assert _effective_boolean("hello") is True
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _effective_boolean("") is False
|
||||
|
||||
def test_iri_term(self):
|
||||
assert _effective_boolean(iri("http://x")) is True
|
||||
|
||||
def test_nonempty_literal(self):
|
||||
assert _effective_boolean(lit("hello")) is True
|
||||
|
||||
def test_empty_literal(self):
|
||||
assert _effective_boolean(lit("")) is False
|
||||
|
||||
def test_boolean_literal_true(self):
|
||||
assert _effective_boolean(
|
||||
lit("true", datatype=XSD + "boolean")
|
||||
) is True
|
||||
|
||||
def test_boolean_literal_false(self):
|
||||
assert _effective_boolean(
|
||||
lit("false", datatype=XSD + "boolean")
|
||||
) is False
|
||||
|
||||
def test_numeric_literal_nonzero(self):
|
||||
assert _effective_boolean(
|
||||
lit("42", datatype=XSD + "integer")
|
||||
) is True
|
||||
|
||||
def test_numeric_literal_zero(self):
|
||||
assert _effective_boolean(
|
||||
lit("0", datatype=XSD + "integer")
|
||||
) is False
|
||||
|
||||
|
||||
class TestToString:
|
||||
|
||||
def test_none(self):
|
||||
assert _to_string(None) == ""
|
||||
|
||||
def test_string(self):
|
||||
assert _to_string("hello") == "hello"
|
||||
|
||||
def test_iri_term(self):
|
||||
assert _to_string(iri("http://example.com")) == "http://example.com"
|
||||
|
||||
def test_literal_term(self):
|
||||
assert _to_string(lit("hello")) == "hello"
|
||||
|
||||
def test_blank_term(self):
|
||||
assert _to_string(blank("b1")) == "b1"
|
||||
|
||||
|
||||
class TestToNumeric:
|
||||
|
||||
def test_none(self):
|
||||
assert _to_numeric(None) is None
|
||||
|
||||
def test_int(self):
|
||||
assert _to_numeric(42) == 42
|
||||
|
||||
def test_float(self):
|
||||
assert _to_numeric(3.14) == 3.14
|
||||
|
||||
def test_integer_literal(self):
|
||||
assert _to_numeric(lit("42")) == 42
|
||||
|
||||
def test_decimal_literal(self):
|
||||
assert _to_numeric(lit("3.14")) == 3.14
|
||||
|
||||
def test_non_numeric_literal(self):
|
||||
assert _to_numeric(lit("hello")) is None
|
||||
|
||||
def test_numeric_string(self):
|
||||
assert _to_numeric("42") == 42
|
||||
|
||||
def test_non_numeric_string(self):
|
||||
assert _to_numeric("abc") is None
|
||||
|
||||
|
||||
class TestComparableValue:
|
||||
|
||||
def test_none(self):
|
||||
assert _comparable_value(None) == (0, "")
|
||||
|
||||
def test_int(self):
|
||||
assert _comparable_value(42) == (2, 42)
|
||||
|
||||
def test_iri(self):
|
||||
assert _comparable_value(iri("http://x")) == (4, "http://x")
|
||||
|
||||
def test_literal(self):
|
||||
assert _comparable_value(lit("hello")) == (3, "hello")
|
||||
|
||||
def test_numeric_literal(self):
|
||||
assert _comparable_value(lit("42")) == (2, 42)
|
||||
|
||||
def test_ordering(self):
|
||||
vals = [lit("b"), lit("a"), lit("c")]
|
||||
sorted_vals = sorted(vals, key=_comparable_value)
|
||||
assert sorted_vals[0].value == "a"
|
||||
assert sorted_vals[1].value == "b"
|
||||
assert sorted_vals[2].value == "c"
|
||||
205
tests/unit/test_query/test_sparql_parser.py
Normal file
205
tests/unit/test_query/test_sparql_parser.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""
|
||||
Tests for the SPARQL parser module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from trustgraph.query.sparql.parser import (
|
||||
parse_sparql, ParseError, rdflib_term_to_term, term_to_rdflib,
|
||||
)
|
||||
from trustgraph.schema import Term, IRI, LITERAL, BLANK
|
||||
|
||||
|
||||
class TestParseSparql:
|
||||
"""Tests for parse_sparql function."""
|
||||
|
||||
def test_select_query_type(self):
|
||||
parsed = parse_sparql("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
|
||||
assert parsed.query_type == "select"
|
||||
|
||||
def test_select_variables(self):
|
||||
parsed = parse_sparql("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
|
||||
assert parsed.variables == ["s", "p", "o"]
|
||||
|
||||
def test_select_subset_variables(self):
|
||||
parsed = parse_sparql("SELECT ?s ?o WHERE { ?s ?p ?o }")
|
||||
assert parsed.variables == ["s", "o"]
|
||||
|
||||
def test_ask_query_type(self):
|
||||
parsed = parse_sparql(
|
||||
"ASK { <http://example.com/a> ?p ?o }"
|
||||
)
|
||||
assert parsed.query_type == "ask"
|
||||
|
||||
def test_ask_no_variables(self):
|
||||
parsed = parse_sparql(
|
||||
"ASK { <http://example.com/a> ?p ?o }"
|
||||
)
|
||||
assert parsed.variables == []
|
||||
|
||||
def test_construct_query_type(self):
|
||||
parsed = parse_sparql(
|
||||
"CONSTRUCT { ?s <http://example.com/knows> ?o } "
|
||||
"WHERE { ?s <http://example.com/friendOf> ?o }"
|
||||
)
|
||||
assert parsed.query_type == "construct"
|
||||
|
||||
def test_describe_query_type(self):
|
||||
parsed = parse_sparql(
|
||||
"DESCRIBE <http://example.com/alice>"
|
||||
)
|
||||
assert parsed.query_type == "describe"
|
||||
|
||||
def test_select_with_limit(self):
|
||||
parsed = parse_sparql(
|
||||
"SELECT ?s WHERE { ?s ?p ?o } LIMIT 10"
|
||||
)
|
||||
assert parsed.query_type == "select"
|
||||
assert parsed.variables == ["s"]
|
||||
|
||||
def test_select_with_distinct(self):
|
||||
parsed = parse_sparql(
|
||||
"SELECT DISTINCT ?s WHERE { ?s ?p ?o }"
|
||||
)
|
||||
assert parsed.query_type == "select"
|
||||
assert parsed.variables == ["s"]
|
||||
|
||||
def test_select_with_filter(self):
|
||||
parsed = parse_sparql(
|
||||
'SELECT ?s ?label WHERE { '
|
||||
' ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label . '
|
||||
' FILTER(CONTAINS(STR(?label), "test")) '
|
||||
'}'
|
||||
)
|
||||
assert parsed.query_type == "select"
|
||||
assert parsed.variables == ["s", "label"]
|
||||
|
||||
def test_select_with_optional(self):
|
||||
parsed = parse_sparql(
|
||||
"SELECT ?s ?p ?o ?label WHERE { "
|
||||
" ?s ?p ?o . "
|
||||
" OPTIONAL { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label } "
|
||||
"}"
|
||||
)
|
||||
assert parsed.query_type == "select"
|
||||
assert set(parsed.variables) == {"s", "p", "o", "label"}
|
||||
|
||||
def test_select_with_union(self):
|
||||
parsed = parse_sparql(
|
||||
"SELECT ?s ?label WHERE { "
|
||||
" { ?s <http://example.com/name> ?label } "
|
||||
" UNION "
|
||||
" { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label } "
|
||||
"}"
|
||||
)
|
||||
assert parsed.query_type == "select"
|
||||
|
||||
def test_select_with_order_by(self):
|
||||
parsed = parse_sparql(
|
||||
"SELECT ?s ?label WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label } "
|
||||
"ORDER BY ?label"
|
||||
)
|
||||
assert parsed.query_type == "select"
|
||||
|
||||
def test_select_with_group_by(self):
|
||||
parsed = parse_sparql(
|
||||
"SELECT ?p (COUNT(?o) AS ?count) WHERE { ?s ?p ?o } "
|
||||
"GROUP BY ?p ORDER BY DESC(?count)"
|
||||
)
|
||||
assert parsed.query_type == "select"
|
||||
|
||||
def test_select_with_prefixes(self):
|
||||
parsed = parse_sparql(
|
||||
"PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> "
|
||||
"SELECT ?s ?label WHERE { ?s rdfs:label ?label }"
|
||||
)
|
||||
assert parsed.query_type == "select"
|
||||
assert parsed.variables == ["s", "label"]
|
||||
|
||||
def test_algebra_not_none(self):
|
||||
parsed = parse_sparql("SELECT ?s WHERE { ?s ?p ?o }")
|
||||
assert parsed.algebra is not None
|
||||
|
||||
def test_parse_error_invalid_sparql(self):
|
||||
with pytest.raises(ParseError):
|
||||
parse_sparql("NOT VALID SPARQL AT ALL")
|
||||
|
||||
def test_parse_error_incomplete_query(self):
|
||||
with pytest.raises(ParseError):
|
||||
parse_sparql("SELECT ?s WHERE {")
|
||||
|
||||
def test_parse_error_message(self):
|
||||
with pytest.raises(ParseError, match="SPARQL parse error"):
|
||||
parse_sparql("GIBBERISH")
|
||||
|
||||
|
||||
class TestRdflibTermToTerm:
|
||||
"""Tests for rdflib-to-Term conversion."""
|
||||
|
||||
def test_uriref_to_term(self):
|
||||
from rdflib import URIRef
|
||||
term = rdflib_term_to_term(URIRef("http://example.com/alice"))
|
||||
assert term.type == IRI
|
||||
assert term.iri == "http://example.com/alice"
|
||||
|
||||
def test_literal_to_term(self):
|
||||
from rdflib import Literal
|
||||
term = rdflib_term_to_term(Literal("hello"))
|
||||
assert term.type == LITERAL
|
||||
assert term.value == "hello"
|
||||
|
||||
def test_typed_literal_to_term(self):
|
||||
from rdflib import Literal, URIRef
|
||||
term = rdflib_term_to_term(
|
||||
Literal("42", datatype=URIRef("http://www.w3.org/2001/XMLSchema#integer"))
|
||||
)
|
||||
assert term.type == LITERAL
|
||||
assert term.value == "42"
|
||||
assert term.datatype == "http://www.w3.org/2001/XMLSchema#integer"
|
||||
|
||||
def test_lang_literal_to_term(self):
|
||||
from rdflib import Literal
|
||||
term = rdflib_term_to_term(Literal("hello", lang="en"))
|
||||
assert term.type == LITERAL
|
||||
assert term.value == "hello"
|
||||
assert term.language == "en"
|
||||
|
||||
def test_bnode_to_term(self):
|
||||
from rdflib import BNode
|
||||
term = rdflib_term_to_term(BNode("b1"))
|
||||
assert term.type == BLANK
|
||||
assert term.id == "b1"
|
||||
|
||||
|
||||
class TestTermToRdflib:
|
||||
"""Tests for Term-to-rdflib conversion."""
|
||||
|
||||
def test_iri_term_to_uriref(self):
|
||||
from rdflib import URIRef
|
||||
result = term_to_rdflib(Term(type=IRI, iri="http://example.com/x"))
|
||||
assert isinstance(result, URIRef)
|
||||
assert str(result) == "http://example.com/x"
|
||||
|
||||
def test_literal_term_to_literal(self):
|
||||
from rdflib import Literal
|
||||
result = term_to_rdflib(Term(type=LITERAL, value="hello"))
|
||||
assert isinstance(result, Literal)
|
||||
assert str(result) == "hello"
|
||||
|
||||
def test_typed_literal_roundtrip(self):
|
||||
from rdflib import URIRef
|
||||
original = Term(
|
||||
type=LITERAL, value="42",
|
||||
datatype="http://www.w3.org/2001/XMLSchema#integer"
|
||||
)
|
||||
rdflib_term = term_to_rdflib(original)
|
||||
assert rdflib_term.datatype == URIRef("http://www.w3.org/2001/XMLSchema#integer")
|
||||
|
||||
def test_lang_literal_roundtrip(self):
|
||||
original = Term(type=LITERAL, value="bonjour", language="fr")
|
||||
rdflib_term = term_to_rdflib(original)
|
||||
assert rdflib_term.language == "fr"
|
||||
|
||||
def test_blank_term_to_bnode(self):
|
||||
from rdflib import BNode
|
||||
result = term_to_rdflib(Term(type=BLANK, id="b1"))
|
||||
assert isinstance(result, BNode)
|
||||
345
tests/unit/test_query/test_sparql_solutions.py
Normal file
345
tests/unit/test_query/test_sparql_solutions.py
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
"""
|
||||
Tests for SPARQL solution sequence operations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from trustgraph.schema import Term, IRI, LITERAL
|
||||
from trustgraph.query.sparql.solutions import (
|
||||
hash_join, left_join, union, project, distinct,
|
||||
order_by, slice_solutions, _terms_equal, _compatible,
|
||||
)
|
||||
|
||||
|
||||
# --- Test helpers ---
|
||||
|
||||
def iri(v):
|
||||
return Term(type=IRI, iri=v)
|
||||
|
||||
def lit(v):
|
||||
return Term(type=LITERAL, value=v)
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
@pytest.fixture
|
||||
def alice():
|
||||
return iri("http://example.com/alice")
|
||||
|
||||
@pytest.fixture
|
||||
def bob():
|
||||
return iri("http://example.com/bob")
|
||||
|
||||
@pytest.fixture
|
||||
def carol():
|
||||
return iri("http://example.com/carol")
|
||||
|
||||
@pytest.fixture
|
||||
def knows():
|
||||
return iri("http://example.com/knows")
|
||||
|
||||
@pytest.fixture
|
||||
def name_alice():
|
||||
return lit("Alice")
|
||||
|
||||
@pytest.fixture
|
||||
def name_bob():
|
||||
return lit("Bob")
|
||||
|
||||
|
||||
class TestTermsEqual:
|
||||
|
||||
def test_equal_iris(self):
|
||||
assert _terms_equal(iri("http://x.com/a"), iri("http://x.com/a"))
|
||||
|
||||
def test_unequal_iris(self):
|
||||
assert not _terms_equal(iri("http://x.com/a"), iri("http://x.com/b"))
|
||||
|
||||
def test_equal_literals(self):
|
||||
assert _terms_equal(lit("hello"), lit("hello"))
|
||||
|
||||
def test_unequal_literals(self):
|
||||
assert not _terms_equal(lit("hello"), lit("world"))
|
||||
|
||||
def test_iri_vs_literal(self):
|
||||
assert not _terms_equal(iri("hello"), lit("hello"))
|
||||
|
||||
def test_none_none(self):
|
||||
assert _terms_equal(None, None)
|
||||
|
||||
def test_none_vs_term(self):
|
||||
assert not _terms_equal(None, iri("http://x.com/a"))
|
||||
|
||||
|
||||
class TestCompatible:
|
||||
|
||||
def test_no_shared_variables(self):
|
||||
assert _compatible({"a": iri("http://x")}, {"b": iri("http://y")})
|
||||
|
||||
def test_shared_variable_same_value(self, alice):
|
||||
assert _compatible({"s": alice, "x": lit("1")}, {"s": alice, "y": lit("2")})
|
||||
|
||||
def test_shared_variable_different_value(self, alice, bob):
|
||||
assert not _compatible({"s": alice}, {"s": bob})
|
||||
|
||||
def test_empty_solutions(self):
|
||||
assert _compatible({}, {})
|
||||
|
||||
def test_empty_vs_nonempty(self, alice):
|
||||
assert _compatible({}, {"s": alice})
|
||||
|
||||
|
||||
class TestHashJoin:
|
||||
|
||||
def test_join_on_shared_variable(self, alice, bob, name_alice, name_bob):
|
||||
left = [
|
||||
{"s": alice, "p": iri("http://example.com/knows"), "o": bob},
|
||||
{"s": bob, "p": iri("http://example.com/knows"), "o": alice},
|
||||
]
|
||||
right = [
|
||||
{"s": alice, "label": name_alice},
|
||||
{"s": bob, "label": name_bob},
|
||||
]
|
||||
result = hash_join(left, right)
|
||||
assert len(result) == 2
|
||||
# Check that joined solutions have all variables
|
||||
for sol in result:
|
||||
assert "s" in sol
|
||||
assert "p" in sol
|
||||
assert "o" in sol
|
||||
assert "label" in sol
|
||||
|
||||
def test_join_no_shared_variables_cross_product(self, alice, bob):
|
||||
left = [{"a": alice}]
|
||||
right = [{"b": bob}, {"b": alice}]
|
||||
result = hash_join(left, right)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_join_no_matches(self, alice, bob):
|
||||
left = [{"s": alice}]
|
||||
right = [{"s": bob}]
|
||||
result = hash_join(left, right)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_join_empty_left(self, alice):
|
||||
result = hash_join([], [{"s": alice}])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_join_empty_right(self, alice):
|
||||
result = hash_join([{"s": alice}], [])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_join_multiple_matches(self, alice, name_alice):
|
||||
left = [
|
||||
{"s": alice, "p": iri("http://e.com/a")},
|
||||
{"s": alice, "p": iri("http://e.com/b")},
|
||||
]
|
||||
right = [{"s": alice, "label": name_alice}]
|
||||
result = hash_join(left, right)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_join_preserves_values(self, alice, name_alice):
|
||||
left = [{"s": alice, "x": lit("1")}]
|
||||
right = [{"s": alice, "y": lit("2")}]
|
||||
result = hash_join(left, right)
|
||||
assert len(result) == 1
|
||||
assert result[0]["x"].value == "1"
|
||||
assert result[0]["y"].value == "2"
|
||||
|
||||
|
||||
class TestLeftJoin:
|
||||
|
||||
def test_left_join_with_matches(self, alice, bob, name_alice):
|
||||
left = [{"s": alice}, {"s": bob}]
|
||||
right = [{"s": alice, "label": name_alice}]
|
||||
result = left_join(left, right)
|
||||
assert len(result) == 2
|
||||
# Alice has label
|
||||
alice_sols = [s for s in result if s["s"].iri == "http://example.com/alice"]
|
||||
assert len(alice_sols) == 1
|
||||
assert "label" in alice_sols[0]
|
||||
# Bob preserved without label
|
||||
bob_sols = [s for s in result if s["s"].iri == "http://example.com/bob"]
|
||||
assert len(bob_sols) == 1
|
||||
assert "label" not in bob_sols[0]
|
||||
|
||||
def test_left_join_no_matches(self, alice, bob):
|
||||
left = [{"s": alice}]
|
||||
right = [{"s": bob, "label": lit("Bob")}]
|
||||
result = left_join(left, right)
|
||||
assert len(result) == 1
|
||||
assert result[0]["s"].iri == "http://example.com/alice"
|
||||
assert "label" not in result[0]
|
||||
|
||||
def test_left_join_empty_right(self, alice):
|
||||
left = [{"s": alice}]
|
||||
result = left_join(left, [])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_left_join_empty_left(self):
|
||||
result = left_join([], [{"s": iri("http://x")}])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_left_join_with_filter(self, alice, bob):
|
||||
left = [{"s": alice}, {"s": bob}]
|
||||
right = [
|
||||
{"s": alice, "val": lit("yes")},
|
||||
{"s": bob, "val": lit("no")},
|
||||
]
|
||||
# Filter: only keep joins where val == "yes"
|
||||
result = left_join(
|
||||
left, right,
|
||||
filter_fn=lambda sol: sol.get("val") and sol["val"].value == "yes"
|
||||
)
|
||||
assert len(result) == 2
|
||||
# Alice matches filter
|
||||
alice_sols = [s for s in result if s["s"].iri == "http://example.com/alice"]
|
||||
assert "val" in alice_sols[0]
|
||||
assert alice_sols[0]["val"].value == "yes"
|
||||
# Bob doesn't match filter, preserved without val
|
||||
bob_sols = [s for s in result if s["s"].iri == "http://example.com/bob"]
|
||||
assert "val" not in bob_sols[0]
|
||||
|
||||
|
||||
class TestUnion:
|
||||
|
||||
def test_union_concatenates(self, alice, bob):
|
||||
left = [{"s": alice}]
|
||||
right = [{"s": bob}]
|
||||
result = union(left, right)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_union_preserves_order(self, alice, bob):
|
||||
left = [{"s": alice}]
|
||||
right = [{"s": bob}]
|
||||
result = union(left, right)
|
||||
assert result[0]["s"].iri == "http://example.com/alice"
|
||||
assert result[1]["s"].iri == "http://example.com/bob"
|
||||
|
||||
def test_union_empty_left(self, alice):
|
||||
result = union([], [{"s": alice}])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_union_both_empty(self):
|
||||
result = union([], [])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_union_allows_duplicates(self, alice):
|
||||
result = union([{"s": alice}], [{"s": alice}])
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestProject:
|
||||
|
||||
def test_project_keeps_selected(self, alice, name_alice):
|
||||
solutions = [{"s": alice, "label": name_alice, "extra": lit("x")}]
|
||||
result = project(solutions, ["s", "label"])
|
||||
assert len(result) == 1
|
||||
assert "s" in result[0]
|
||||
assert "label" in result[0]
|
||||
assert "extra" not in result[0]
|
||||
|
||||
def test_project_missing_variable(self, alice):
|
||||
solutions = [{"s": alice}]
|
||||
result = project(solutions, ["s", "missing"])
|
||||
assert len(result) == 1
|
||||
assert "s" in result[0]
|
||||
assert "missing" not in result[0]
|
||||
|
||||
def test_project_empty(self):
|
||||
result = project([], ["s"])
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestDistinct:
|
||||
|
||||
def test_removes_duplicates(self, alice):
|
||||
solutions = [{"s": alice}, {"s": alice}, {"s": alice}]
|
||||
result = distinct(solutions)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_keeps_different(self, alice, bob):
|
||||
solutions = [{"s": alice}, {"s": bob}]
|
||||
result = distinct(solutions)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_empty(self):
|
||||
result = distinct([])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_multi_variable_distinct(self, alice, bob):
|
||||
solutions = [
|
||||
{"s": alice, "o": bob},
|
||||
{"s": alice, "o": bob},
|
||||
{"s": alice, "o": alice},
|
||||
]
|
||||
result = distinct(solutions)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestOrderBy:
|
||||
|
||||
def test_order_by_ascending(self):
|
||||
solutions = [
|
||||
{"label": lit("Charlie")},
|
||||
{"label": lit("Alice")},
|
||||
{"label": lit("Bob")},
|
||||
]
|
||||
key_fns = [(lambda sol: sol.get("label"), True)]
|
||||
result = order_by(solutions, key_fns)
|
||||
assert result[0]["label"].value == "Alice"
|
||||
assert result[1]["label"].value == "Bob"
|
||||
assert result[2]["label"].value == "Charlie"
|
||||
|
||||
def test_order_by_descending(self):
|
||||
solutions = [
|
||||
{"label": lit("Alice")},
|
||||
{"label": lit("Charlie")},
|
||||
{"label": lit("Bob")},
|
||||
]
|
||||
key_fns = [(lambda sol: sol.get("label"), False)]
|
||||
result = order_by(solutions, key_fns)
|
||||
assert result[0]["label"].value == "Charlie"
|
||||
assert result[1]["label"].value == "Bob"
|
||||
assert result[2]["label"].value == "Alice"
|
||||
|
||||
def test_order_by_empty(self):
|
||||
result = order_by([], [(lambda sol: sol.get("x"), True)])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_order_by_no_keys(self, alice):
|
||||
solutions = [{"s": alice}]
|
||||
result = order_by(solutions, [])
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestSlice:
|
||||
|
||||
def test_limit(self, alice, bob, carol):
|
||||
solutions = [{"s": alice}, {"s": bob}, {"s": carol}]
|
||||
result = slice_solutions(solutions, limit=2)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_offset(self, alice, bob, carol):
|
||||
solutions = [{"s": alice}, {"s": bob}, {"s": carol}]
|
||||
result = slice_solutions(solutions, offset=1)
|
||||
assert len(result) == 2
|
||||
assert result[0]["s"].iri == "http://example.com/bob"
|
||||
|
||||
def test_offset_and_limit(self, alice, bob, carol):
|
||||
solutions = [{"s": alice}, {"s": bob}, {"s": carol}]
|
||||
result = slice_solutions(solutions, offset=1, limit=1)
|
||||
assert len(result) == 1
|
||||
assert result[0]["s"].iri == "http://example.com/bob"
|
||||
|
||||
def test_limit_zero(self, alice):
|
||||
result = slice_solutions([{"s": alice}], limit=0)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_offset_beyond_length(self, alice):
|
||||
result = slice_solutions([{"s": alice}], offset=10)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_no_slice(self, alice, bob):
|
||||
solutions = [{"s": alice}, {"s": bob}]
|
||||
result = slice_solutions(solutions)
|
||||
assert len(result) == 2
|
||||
Loading…
Add table
Add a link
Reference in a new issue