From 49d774487643dd69383b6736e54d1d3d2b4cb7a8 Mon Sep 17 00:00:00 2001 From: Cyber MacGeddon Date: Thu, 2 Apr 2026 17:19:29 +0100 Subject: [PATCH] New unit tests for SPARQL query --- .../test_query/test_sparql_expressions.py | 424 ++++++++++++++++++ tests/unit/test_query/test_sparql_parser.py | 205 +++++++++ .../unit/test_query/test_sparql_solutions.py | 345 ++++++++++++++ 3 files changed, 974 insertions(+) create mode 100644 tests/unit/test_query/test_sparql_expressions.py create mode 100644 tests/unit/test_query/test_sparql_parser.py create mode 100644 tests/unit/test_query/test_sparql_solutions.py diff --git a/tests/unit/test_query/test_sparql_expressions.py b/tests/unit/test_query/test_sparql_expressions.py new file mode 100644 index 00000000..63e9188f --- /dev/null +++ b/tests/unit/test_query/test_sparql_expressions.py @@ -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" diff --git a/tests/unit/test_query/test_sparql_parser.py b/tests/unit/test_query/test_sparql_parser.py new file mode 100644 index 00000000..5ac9fad9 --- /dev/null +++ b/tests/unit/test_query/test_sparql_parser.py @@ -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 { ?p ?o }" + ) + assert parsed.query_type == "ask" + + def test_ask_no_variables(self): + parsed = parse_sparql( + "ASK { ?p ?o }" + ) + assert parsed.variables == [] + + def test_construct_query_type(self): + parsed = parse_sparql( + "CONSTRUCT { ?s ?o } " + "WHERE { ?s ?o }" + ) + assert parsed.query_type == "construct" + + def test_describe_query_type(self): + parsed = parse_sparql( + "DESCRIBE " + ) + 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 ?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 ?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 ?label } " + " UNION " + " { ?s ?label } " + "}" + ) + assert parsed.query_type == "select" + + def test_select_with_order_by(self): + parsed = parse_sparql( + "SELECT ?s ?label WHERE { ?s ?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: " + "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) diff --git a/tests/unit/test_query/test_sparql_solutions.py b/tests/unit/test_query/test_sparql_solutions.py new file mode 100644 index 00000000..5805ca84 --- /dev/null +++ b/tests/unit/test_query/test_sparql_solutions.py @@ -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