diff --git a/lib/query_processing/src/expressions.rs b/lib/query_processing/src/expressions.rs index 14dc0ea4..a39839f1 100644 --- a/lib/query_processing/src/expressions.rs +++ b/lib/query_processing/src/expressions.rs @@ -190,10 +190,19 @@ pub fn variable( context: &Context, ) -> Result { if !solution_mappings.rdf_node_types.contains_key(v.as_str()) { - return Err(QueryProcessingError::VariableNotFound( - v.as_str().to_string(), + // A variable that does not occur in the pattern is unbound in every solution + // (SPARQL 1.1, section 18.6), e.g. ORDER BY ?x or COALESCE(?x, ...) where ?x is + // never bound. Evaluate it as unbound instead of failing the query. + solution_mappings.mappings = solution_mappings.mappings.with_column( + lit(LiteralValue::untyped_null()) + .cast(BaseRDFNodeType::None.default_input_polars_data_type()) + .alias(context.as_str()), + ); + solution_mappings.rdf_node_types.insert( context.as_str().to_string(), - )); + BaseRDFNodeType::None.into_default_input_rdf_node_state(), + ); + return Ok(solution_mappings); } solution_mappings.mappings = solution_mappings .mappings @@ -427,10 +436,15 @@ pub fn bound( outer_context: &Context, ) -> Result { if !solution_mappings.rdf_node_types.contains_key(v.as_str()) { - return Err(QueryProcessingError::VariableNotFound( - v.as_str().to_string(), + // A variable that does not occur in the pattern is never bound. + solution_mappings.mappings = solution_mappings + .mappings + .with_column(lit(false).alias(outer_context.as_str())); + solution_mappings.rdf_node_types.insert( outer_context.as_str().to_string(), - )); + BaseRDFNodeType::Literal(xsd::BOOLEAN.into_owned()).into_default_input_rdf_node_state(), + ); + return Ok(solution_mappings); } let expr = expr_is_null_workaround( col(v.as_str()), diff --git a/py_maplib/tests/test_unbound_variables.py b/py_maplib/tests/test_unbound_variables.py new file mode 100644 index 00000000..8e7fe95d --- /dev/null +++ b/py_maplib/tests/test_unbound_variables.py @@ -0,0 +1,57 @@ +import pytest + +from maplib import Model + +# SPARQL 1.1 18.6: a variable that does not occur in the pattern is unbound in every +# solution. Expressions that use it must evaluate as with any other unbound variable +# instead of failing the query. + + +@pytest.fixture +def model(): + m = Model() + m.reads( + ' "b" .\n' + ' "a" .\n', + format="ntriples", + ) + return m + + +def test_order_by_variable_not_in_pattern(model): + df = model.query( + "SELECT ?s WHERE { ?s ?o } ORDER BY ?nope ?o" + ) + assert df["s"].to_list() == ["", ""] + + +def test_distinct_order_by_variable_not_in_pattern(model): + df = model.query( + "SELECT DISTINCT ?s WHERE { ?s ?o } ORDER BY ?nope" + ) + assert sorted(df["s"].to_list()) == [ + "", + "", + ] + + +def test_bound_variable_not_in_pattern(model): + df = model.query( + "SELECT ?s WHERE { ?s ?o FILTER(!BOUND(?nope)) }" + ) + assert df.height == 2 + + +def test_coalesce_variable_not_in_pattern(model): + df = model.query( + "SELECT ?s ?x WHERE { ?s ?o " + "BIND(COALESCE(?nope, ?o) AS ?x) } ORDER BY ?x" + ) + assert df["x"].to_list() == ["a", "b"] + + +def test_filter_on_variable_not_in_pattern(model): + df = model.query( + 'SELECT ?s WHERE { ?s ?o FILTER(?nope = "a") }' + ) + assert df.height == 0