Skip to content

Commit c834a76

Browse files
committed
fix: ROUND, ENCODE_FOR_URI and SECONDS SPARQL functions
`ROUND` was not correctly rounding negative numbers towards positive infinity, `ENCODE_FOR_URI` incorrectly treated `/` as safe, and `SECONDS` did not include fractional seconds. This change corrects these issues. - Closes <#2151>.
1 parent 4da67f9 commit c834a76

File tree

2 files changed

+182
-5
lines changed

2 files changed

+182
-5
lines changed

rdflib/plugins/sparql/operators.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import re
1717
import uuid
1818
import warnings
19-
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
19+
from decimal import ROUND_HALF_DOWN, ROUND_HALF_UP, Decimal, InvalidOperation
2020
from functools import reduce
2121
from typing import Any, Callable, Dict, NoReturn, Optional, Tuple, Union, overload
2222
from urllib.parse import quote
@@ -205,7 +205,7 @@ def Builtin_ROUND(expr: Expr, ctx) -> Literal:
205205
# this is an ugly work-around
206206
l_ = expr.arg
207207
v = numeric(l_)
208-
v = int(Decimal(v).quantize(1, ROUND_HALF_UP))
208+
v = int(Decimal(v).quantize(1, ROUND_HALF_UP if v > 0 else ROUND_HALF_DOWN))
209209
return Literal(v, datatype=l_.datatype)
210210

211211

@@ -381,7 +381,7 @@ def Builtin_CONTAINS(expr: Expr, ctx) -> Literal:
381381

382382

383383
def Builtin_ENCODE_FOR_URI(expr: Expr, ctx) -> Literal:
384-
return Literal(quote(string(expr.arg).encode("utf-8")))
384+
return Literal(quote(string(expr.arg).encode("utf-8"), safe=""))
385385

386386

387387
def Builtin_SUBSTR(expr: Expr, ctx) -> Literal:
@@ -471,7 +471,10 @@ def Builtin_SECONDS(e: Expr, ctx) -> Literal:
471471
http://www.w3.org/TR/sparql11-query/#func-seconds
472472
"""
473473
d = datetime(e.arg)
474-
return Literal(d.second, datatype=XSD.decimal)
474+
result_value = Decimal(d.second)
475+
if d.microsecond:
476+
result_value += Decimal(d.microsecond) / Decimal(1000000)
477+
return Literal(result_value, datatype=XSD.decimal)
475478

476479

477480
def Builtin_TIMEZONE(e: Expr, ctx) -> Literal:
@@ -1248,7 +1251,10 @@ def _match(r: str, l_: str) -> bool:
12481251
return r == "*" or r == l_
12491252

12501253
rangeList = range.strip().lower().split("-")
1251-
langList = lang.strip().lower().split("-")
1254+
lang_value = lang.language
1255+
if lang_value is None or lang_value == "":
1256+
return False
1257+
langList = lang_value.strip().lower().split("-")
12521258
if not _match(rangeList[0], langList[0]):
12531259
return False
12541260
if len(rangeList) > len(langList):

test/test_sparql/test_functions.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import logging
2+
from decimal import Decimal
3+
4+
import pytest
5+
6+
from rdflib.graph import Graph
7+
from rdflib.namespace import XSD, Namespace
8+
from rdflib.plugins.sparql.operators import _lang_range_check
9+
from rdflib.term import BNode, Identifier, Literal, URIRef
10+
11+
EG = Namespace("https://example.com/")
12+
13+
14+
@pytest.mark.parametrize(
15+
["expression", "expected_result"],
16+
[
17+
(r"isIRI('eg:IRI')", Literal(False)),
18+
(r"isIRI(eg:IRI)", Literal(True)),
19+
(r"isURI('eg:IRI')", Literal(False)),
20+
(r"isURI(eg:IRI)", Literal(True)),
21+
(r"isBLANK(eg:IRI)", Literal(False)),
22+
(r"isBLANK(BNODE())", Literal(True)),
23+
(r"isLITERAL(eg:IRI)", Literal(False)),
24+
(r"isLITERAL('eg:IRI')", Literal(True)),
25+
(r"isNumeric(eg:IRI)", Literal(False)),
26+
(r"isNumeric(1)", Literal(True)),
27+
(r"STR(eg:IRI)", Literal("https://example.com/IRI")),
28+
(r"STR(1)", Literal("1")),
29+
(r'LANG("Robert"@en)', Literal("en")),
30+
(r'LANG("Robert")', Literal("")),
31+
(r'DATATYPE("Robert")', XSD.string),
32+
(r'DATATYPE("42"^^xsd:integer)', XSD.integer),
33+
(r'IRI("http://example/")', URIRef("http://example/")),
34+
(r'BNODE("example")', BNode),
35+
(r'STRDT("123", xsd:integer)', Literal("123", datatype=XSD.integer)),
36+
(r'STRLANG("cats and dogs", "en")', Literal("cats and dogs", lang="en")),
37+
(r"UUID()", URIRef),
38+
(r"STRUUID()", Literal),
39+
(r'STRLEN("chat")', Literal(4)),
40+
(r'SUBSTR("foobar", 4)', Literal("bar")),
41+
(r'UCASE("foo")', Literal("FOO")),
42+
(r'LCASE("BAR")', Literal("bar")),
43+
(r'strStarts("foobar", "foo")', Literal(True)),
44+
(r'strStarts("foobar", "bar")', Literal(False)),
45+
(r'strEnds("foobar", "bar")', Literal(True)),
46+
(r'strEnds("foobar", "foo")', Literal(False)),
47+
(r'contains("foobar", "bar")', Literal(True)),
48+
(r'contains("foobar", "barfoo")', Literal(False)),
49+
(r'strbefore("abc","b")', Literal("a")),
50+
(r'strbefore("abc","xyz")', Literal("")),
51+
(r'strafter("abc","b")', Literal("c")),
52+
(r'strafter("abc","xyz")', Literal("")),
53+
(r"ENCODE_FOR_URI('this/is/a/test')", Literal("this%2Fis%2Fa%2Ftest")),
54+
(r"ENCODE_FOR_URI('this is a test')", Literal("this%20is%20a%20test")),
55+
(
56+
r"ENCODE_FOR_URI('AAA~~0123456789~~---~~___~~...~~ZZZ')",
57+
Literal("AAA~~0123456789~~---~~___~~...~~ZZZ"),
58+
),
59+
(r'CONCAT("foo", "bar")', Literal("foobar")),
60+
(r'langMatches("That Seventies Show"@en, "en")', Literal(True)),
61+
(
62+
r'langMatches("Cette Série des Années Soixante-dix"@fr, "en")',
63+
Literal(False),
64+
),
65+
(r'langMatches("Cette Série des Années Septante"@fr-BE, "en")', Literal(False)),
66+
(r'langMatches("Il Buono, il Bruto, il Cattivo", "en")', Literal(False)),
67+
(r'langMatches("That Seventies Show"@en, "FR")', Literal(False)),
68+
(r'langMatches("Cette Série des Années Soixante-dix"@fr, "FR")', Literal(True)),
69+
(r'langMatches("Cette Série des Années Septante"@fr-BE, "FR")', Literal(True)),
70+
(r'langMatches("Il Buono, il Bruto, il Cattivo", "FR")', Literal(False)),
71+
(r'langMatches("That Seventies Show"@en, "*")', Literal(True)),
72+
(r'langMatches("Cette Série des Années Soixante-dix"@fr, "*")', Literal(True)),
73+
(r'langMatches("Cette Série des Années Septante"@fr-BE, "*")', Literal(True)),
74+
(r'langMatches("Il Buono, il Bruto, il Cattivo", "*")', Literal(False)),
75+
(r'regex("Alice", "^ali", "i")', Literal(True)),
76+
(r'regex("Bob", "^ali", "i")', Literal(False)),
77+
(r'replace("abcd", "b", "Z")', Literal("aZcd")),
78+
(r"abs(-1.5)", Literal("1.5", datatype=XSD.decimal)),
79+
(r"round(2.4999)", Literal("2", datatype=XSD.decimal)),
80+
(r"round(2.5)", Literal("3", datatype=XSD.decimal)),
81+
(r"round(-2.5)", Literal("-2", datatype=XSD.decimal)),
82+
(r"round(0.1)", Literal("0", datatype=XSD.decimal)),
83+
(r"round(-0.1)", Literal("0", datatype=XSD.decimal)),
84+
(r"RAND()", Literal),
85+
(r"now()", Literal),
86+
(r'month("2011-01-10T14:45:13.815-05:00"^^xsd:dateTime)', Literal(1)),
87+
(r'day("2011-01-10T14:45:13.815-05:00"^^xsd:dateTime)', Literal(10)),
88+
(r'hours("2011-01-10T14:45:13.815-05:00"^^xsd:dateTime)', Literal(14)),
89+
(r'minutes("2011-01-10T14:45:13.815-05:00"^^xsd:dateTime)', Literal(45)),
90+
(
91+
r'seconds("2011-01-10T14:45:13.815-05:00"^^xsd:dateTime)',
92+
Literal(Decimal("13.815")),
93+
),
94+
(
95+
r'timezone("2011-01-10T14:45:13.815-05:00"^^xsd:dateTime)',
96+
Literal("-PT5H", datatype=XSD.dayTimeDuration),
97+
),
98+
(
99+
r'timezone("2011-01-10T14:45:13.815Z"^^xsd:dateTime)',
100+
Literal("PT0S", datatype=XSD.dayTimeDuration),
101+
),
102+
(
103+
r'tz("2011-01-10T14:45:13.815-05:00"^^xsd:dateTime) ',
104+
Literal("-05:00"),
105+
),
106+
(
107+
r'tz("2011-01-10T14:45:13.815Z"^^xsd:dateTime) ',
108+
Literal("Z"),
109+
),
110+
(
111+
r'tz("2011-01-10T14:45:13.815"^^xsd:dateTime) ',
112+
Literal(""),
113+
),
114+
(r'MD5("abc")', Literal("900150983cd24fb0d6963f7d28e17f72")),
115+
(r'SHA1("abc")', Literal("a9993e364706816aba3e25717850c26c9cd0d89d")),
116+
(
117+
r'SHA256("abc")',
118+
Literal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"),
119+
),
120+
(
121+
r'SHA384("abc")',
122+
Literal(
123+
"cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7"
124+
),
125+
),
126+
(
127+
r'SHA512("abc")',
128+
Literal(
129+
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"
130+
),
131+
),
132+
],
133+
)
134+
def test_function(expression: str, expected_result: Identifier) -> None:
135+
graph = Graph()
136+
query_string = """
137+
PREFIX eg: <https://example.com/>
138+
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
139+
CONSTRUCT { eg:subject eg:predicate ?o }
140+
WHERE {
141+
BIND(???EXPRESSION_PLACEHOLDER??? AS ?o)
142+
}
143+
""".replace(
144+
"???EXPRESSION_PLACEHOLDER???", expression
145+
)
146+
result = graph.query(query_string)
147+
assert result.type == "CONSTRUCT"
148+
logging.debug("result = %s", list(result.graph.triples((None, None, None))))
149+
actual_result = result.graph.value(EG.subject, EG.predicate, any=False)
150+
if isinstance(expected_result, type):
151+
assert isinstance(actual_result, expected_result)
152+
else:
153+
assert expected_result == actual_result
154+
155+
156+
@pytest.mark.parametrize(
157+
["literal", "range", "expected_result"],
158+
[
159+
(Literal("foo", lang="en"), Literal("en"), True),
160+
(Literal("foo", lang="en"), Literal("EN"), True),
161+
(Literal("foo", lang="EN"), Literal("en"), True),
162+
(Literal("foo", lang="EN"), Literal("EN"), True),
163+
(Literal("foo", lang="en"), Literal("en-US"), False),
164+
(Literal("foo", lang="en-US"), Literal("en-US"), True),
165+
],
166+
)
167+
def test_lang_range_check(
168+
literal: Literal, range: Literal, expected_result: bool
169+
) -> None:
170+
actual_result = _lang_range_check(range, literal)
171+
assert expected_result == actual_result

0 commit comments

Comments
 (0)