Add built-in functions - #781
Conversation
mmarx
left a comment
There was a problem hiding this comment.
I've not yet reviewed everything, but I don't think this is ready to merge without a major overhaul. There's several issues both with respect to code quality and with respect to correctness:
- we're pulling in
chronofor handling timestamps, but then re-implement the timestamp parsing ourselves – why? - I'm not impressed with the code quality – there's functions that are defined identically in multiple files (and then inlined in other places as well), one-line functions that are only called once, redundant function calls like
Some(value.to_plain_string_unchecked())which could be a simplevalue.to_plain_string()instead, … - the hash functions are implemented for plain strings and language tagged strings, yet the spec says that they take plain strings and simple literals
- the nondeterministic functions take an arbitrary number of arguments, even though they really should not take any arguments at all
|
Since "simple literals" came up in the discussion: These should not be implemented as a separate type but identified with xsd:string. This is a relic from the depth of RDF history that is vanishing as specs get updated. |
| /// An optional third parameter may provide regex flags (e.g. `"i"` for case-insensitive), | ||
| /// corresponding to the SPARQL `regex(string, pattern [, flags])` function. |
There was a problem hiding this comment.
This also doesn't validate the flags, and furthermore doesn't support the q flag.
| /// Returns a language tagged string if the first parameter has a language tag. | ||
| /// Otherwise, return a plain string. |
There was a problem hiding this comment.
It would be nice if we could keep the grammar consistent and not use returns and return in the same paragraph.
There was a problem hiding this comment.
This still says “returns …, otherwise return …”.
| #[test] | ||
| fn test_string_trim() { | ||
| let string = AnyDataValue::new_plain_string(" hello ".to_string()); | ||
| let result = AnyDataValue::new_plain_string("hello".to_string()); | ||
| let actual_result = StringTrim.evaluate(string.clone()); | ||
| assert!(actual_result.is_some()); | ||
| assert_eq!(result, actual_result.unwrap()); | ||
|
|
||
| let string_notstring = AnyDataValue::new_integer_from_i64(1); | ||
| let actual_result_notstring = StringTrim.evaluate(string_notstring); | ||
| assert!(actual_result_notstring.is_none()); | ||
|
|
||
| let string_lang = | ||
| AnyDataValue::new_language_tagged_string(" hola ".to_string(), "es".to_string()); | ||
| let result_lang = | ||
| AnyDataValue::new_language_tagged_string("hola".to_string(), "es".to_string()); | ||
| let actual_result_lang = StringTrim.evaluate(string_lang); | ||
| assert!(actual_result_lang.is_some()); | ||
| assert_eq!(result_lang, actual_result_lang.unwrap()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_string_trim_start() { | ||
| let string = AnyDataValue::new_plain_string(" hello ".to_string()); | ||
| let result = AnyDataValue::new_plain_string("hello ".to_string()); | ||
| let actual_result = StringTrimStart.evaluate(string.clone()); | ||
| assert!(actual_result.is_some()); | ||
| assert_eq!(result, actual_result.unwrap()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_string_trim_end() { | ||
| let string = AnyDataValue::new_plain_string(" hello ".to_string()); | ||
| let result = AnyDataValue::new_plain_string(" hello".to_string()); | ||
| let actual_result = StringTrimEnd.evaluate(string.clone()); | ||
| assert!(actual_result.is_some()); | ||
| assert_eq!(result, actual_result.unwrap()); | ||
| } | ||
|
|
There was a problem hiding this comment.
This is essentially testing std functionality (except, perhaps, that it returns None on non-strings, but then we only do that for trim and not for the others). Why?
There was a problem hiding this comment.
This is still just an integration test disguised as a unit test checking for correct behaviour of the Rust standard library, and it's obsoleted by an actual integration test that verifies this end-to-end. There is no situation where these tests fail, but the integration tests don't.
As stated above, there might be some value verifying that these return None on non-string inputs, but we're not doing that consistently either.
| // With flags: case-insensitive match | ||
| let string_flags = AnyDataValue::new_plain_string("Hello".to_string()); | ||
| let pattern_flags = AnyDataValue::new_plain_string("hello".to_string()); | ||
| let flags = AnyDataValue::new_plain_string("i".to_string()); | ||
| let result_flags = AnyDataValue::new_boolean(true); | ||
| let actual_result_flags = StringRegex.evaluate(&[string_flags, pattern_flags, flags]); | ||
| assert!(actual_result_flags.is_some()); | ||
| assert_eq!(result_flags, actual_result_flags.unwrap()); | ||
| } |
There was a problem hiding this comment.
i is the most boring flag to test here. What we should have test for are the q flag (since that requires special handling), though that should rather be an integration test, backreferences in pattern (again an integration test), and that invalid flags lead to None.
There was a problem hiding this comment.
The only checks that are appropriate here are for the cases where we return None. Everything else should be an integration test and only an integration test.
|
We really need to push the nondeterministic functions into SPARQL queries. Consider the following program: @import nemo :- sparql{ endpoint = <https://query.wikidata.org/sparql>, query = "SELECT ?x ?y WHERE { BIND(?x AS ?y) }"} .
@import sparql :- sparql{ endpoint = <https://query.wikidata.org/sparql>, query = "SELECT ?x WHERE { BIND(RAND() AS ?x) }"} .
foo(?x) :- nemo(RAND(), ?x) .
bar(?x) :- sparql(?x) .
|
e4a29f9 to
3a551ea
Compare
There was a problem hiding this comment.
This is still quite a bit from being ready. Most of the issues are just useless comments and useless “unit” tests (that are secretly integration tests, covering parts of what is already covered by proper integration tests), but there's also quite a bit of leaking implementation details (partly cross-crate) into public API docs, lying about what's in the XPath spec, a function as part of the public API that serves no purpose except breaking the invariant that every call to NOW() returns the same value, general silliness like !is_nondeterministic when is_deterministic suffices, and comments from my previous review that have been left unaddressed.
Curiously, the commit messages also seem to not match what's actually in the commits: 1bb98d3, e.g., is titled “Ask regex cache earlier”, and, while it does touch the regex cache, it absolutely does not change when it is queried (which still happens too late). It also adds a lot of documentation to other functions, as well as change the behaviour of language tag matching.
| /// | ||
| /// Lazily initialized with the current time on first access |
There was a problem hiding this comment.
This comment just restates what the code already says. Get rid of it.
| /// Capture the current time as the timestamp returned by `NOW()`. | ||
| /// | ||
| /// This should be called once at the start of each program execution. | ||
| /// All evaluations of `NOW()` during the execution will return this fixed value. | ||
| /// | ||
| /// Note that the timestamp is global to the process. | ||
| pub fn set_now_timestamp() { | ||
| *NOW_TIMESTAMP | ||
| .write() | ||
| .expect("no thread should panic while holding the lock") = DateTime::now().to_string(); | ||
| } |
There was a problem hiding this comment.
Not only do we not need this, it's now offering a public API to break the precise API contract the documentation claims (that each evaluation of NOW() returns the same value), by allowing the timestamp to change when calling set_now_timestamp multiple during reasoning.
| let year = if datatype == XSD_DATETIME { | ||
| DateTime::from_str(&lexical).ok()?.year() | ||
| } else if datatype == XSD_DATE { | ||
| Date::from_str(&lexical).ok()?.year() | ||
| } else { | ||
| return None; | ||
| }; |
There was a problem hiding this comment.
These are still not match statements, despite the (still open) request below?
| /// Returns the timestamp captured by [set_now_timestamp], so all evaluations | ||
| /// during one program execution share the same value. |
There was a problem hiding this comment.
As mentioned above, this is not actually true, because the API doesn't really ensure that set_now_timestamp gets called at most once (even though it need not be called in any case to uphold this guarantee). It's also stating a non-local implementation detail as part of the public documentation of this function, which is just bound to become outdated.
| /// Returns a value of the form `<urn:uuid:…>` containing a version 7 UUID, | ||
| /// which is lexicographically greater than all UUIDs generated before it. |
There was a problem hiding this comment.
The only guarantee we want to make as part of the public API documentation is that this returns a fresh UUID. That it's a v7 UUID is an implementation detail that we might want to change at a later point, certainly we don't want to document it here.
| /// references to capture groups that do not exist in the pattern | ||
| /// expand to the empty string instead of being an error, | ||
| /// and patterns that match a zero-length string are permitted | ||
| /// (where XPath raises an error). |
There was a problem hiding this comment.
References to non-existing capture groups are not an error in the XPath specification (there is still a difference, though, in that XPath takes the longest possible capture group reference that exists (e.g., $23 can refer to group 2 if that exists, with the 3 taken as a literal part of the replacement value), whereas fancy_regex always takes the longest name), but if that doesn't apply, the replacement absolutely is the empty string.
| /// Corresponds to SPARQL langMatches(language-tag, language-range). | ||
| /// Returns `true` if `language-tag` matches `language-range` per RFC 4647 basic filtering: |
There was a problem hiding this comment.
Why do we suddenly use kebab-case for language-tag and language-range? Just call them tag and range.
| /// Corresponds to SPARQL langMatches(language-tag, language-range). | ||
| /// Returns `true` if `language-tag` matches `language-range` per RFC 4647 basic filtering: |
There was a problem hiding this comment.
This should also refer to BCP 47, not to RFC 4647 (which just happens to be the current version right now).
| #[test] | ||
| fn test_string_trim() { | ||
| let string = AnyDataValue::new_plain_string(" hello ".to_string()); | ||
| let result = AnyDataValue::new_plain_string("hello".to_string()); | ||
| let actual_result = StringTrim.evaluate(string.clone()); | ||
| assert!(actual_result.is_some()); | ||
| assert_eq!(result, actual_result.unwrap()); | ||
|
|
||
| let string_notstring = AnyDataValue::new_integer_from_i64(1); | ||
| let actual_result_notstring = StringTrim.evaluate(string_notstring); | ||
| assert!(actual_result_notstring.is_none()); | ||
|
|
||
| let string_lang = | ||
| AnyDataValue::new_language_tagged_string(" hola ".to_string(), "es".to_string()); | ||
| let result_lang = | ||
| AnyDataValue::new_language_tagged_string("hola".to_string(), "es".to_string()); | ||
| let actual_result_lang = StringTrim.evaluate(string_lang); | ||
| assert!(actual_result_lang.is_some()); | ||
| assert_eq!(result_lang, actual_result_lang.unwrap()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_string_trim_start() { | ||
| let string = AnyDataValue::new_plain_string(" hello ".to_string()); | ||
| let result = AnyDataValue::new_plain_string("hello ".to_string()); | ||
| let actual_result = StringTrimStart.evaluate(string.clone()); | ||
| assert!(actual_result.is_some()); | ||
| assert_eq!(result, actual_result.unwrap()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_string_trim_end() { | ||
| let string = AnyDataValue::new_plain_string(" hello ".to_string()); | ||
| let result = AnyDataValue::new_plain_string(" hello".to_string()); | ||
| let actual_result = StringTrimEnd.evaluate(string.clone()); | ||
| assert!(actual_result.is_some()); | ||
| assert_eq!(result, actual_result.unwrap()); | ||
| } | ||
|
|
There was a problem hiding this comment.
This is still just an integration test disguised as a unit test checking for correct behaviour of the Rust standard library, and it's obsoleted by an actual integration test that verifies this end-to-end. There is no situation where these tests fail, but the integration tests don't.
As stated above, there might be some value verifying that these return None on non-string inputs, but we're not doing that consistently either.
| // With flags: case-insensitive match | ||
| let string_flags = AnyDataValue::new_plain_string("Hello".to_string()); | ||
| let pattern_flags = AnyDataValue::new_plain_string("hello".to_string()); | ||
| let flags = AnyDataValue::new_plain_string("i".to_string()); | ||
| let result_flags = AnyDataValue::new_boolean(true); | ||
| let actual_result_flags = StringRegex.evaluate(&[string_flags, pattern_flags, flags]); | ||
| assert!(actual_result_flags.is_some()); | ||
| assert_eq!(result_flags, actual_result_flags.unwrap()); | ||
| } |
There was a problem hiding this comment.
The only checks that are appropriate here are for the cases where we return None. Everything else should be an integration test and only an integration test.
| static NOW_TIMESTAMP: RwLock<Option<String>> = RwLock::new(None); | ||
| /// Lazily initialized with the current time on first access | ||
| static NOW_TIMESTAMP: LazyLock<RwLock<String>> = | ||
| LazyLock::new(|| RwLock::new(DateTime::now().to_string())); |
There was a problem hiding this comment.
There's no need to ever set this after it's been set, so this should just be LazyLock<String>.
There was a problem hiding this comment.
Not sure, what if we allow reasoning to be performed multiple times...
This PR adds new built-in functions. Closes #642 (I moved aggregate functions to a separate issue #782) and closes #769.
String functions
REPLACE(str, pattern, replacement[, flags]): regex-based string replacementlangMatches(tag, range): language tag range matching per SPARQL/RFC 4647STRTRIM(str): remove leading and trailing whitespaceSTRTRIMSTART(str): remove leading whitespace onlySTRTRIMEND(str): remove trailing whitespace onlySTRDT(lex, datatype): construct a typed literal from a lexical value and datatype IRIDate/time functions
YEAR,MONTH,DAY,HOURS,MINUTES,SECONDS: component extraction fromxsd:dateTime,xsd:date, andxsd:timevaluesTIMEZONE: timezone asxsd:dayTimeDurationTZ: timezone as a plain stringNondeterministic functions
RAND(): pseudo-random double in [0, 1)UUID(): fresh IRI-form UUIDSTRUUID(): fresh string-form UUIDNOW(): current date/time as xsd:dateTimeChanges to existing functions
REGEXnow accepts flags as an optional third parameter