Skip to content

Add built-in functions - #781

Open
aannleax wants to merge 36 commits into
mainfrom
feature/add-builtins
Open

Add built-in functions#781
aannleax wants to merge 36 commits into
mainfrom
feature/add-builtins

Conversation

@aannleax

@aannleax aannleax commented Apr 8, 2026

Copy link
Copy Markdown
Member

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 replacement
  • langMatches(tag, range): language tag range matching per SPARQL/RFC 4647
  • STRTRIM(str): remove leading and trailing whitespace
  • STRTRIMSTART(str): remove leading whitespace only
  • STRTRIMEND(str): remove trailing whitespace only
  • STRDT(lex, datatype): construct a typed literal from a lexical value and datatype IRI
  • Hashing functions: MD5, SHA1, SHA256, SHA384, SHA512

Date/time functions

  • YEAR, MONTH, DAY, HOURS, MINUTES, SECONDS: component extraction from xsd:dateTime, xsd:date, and xsd:time values
  • TIMEZONE: timezone as xsd:dayTimeDuration
  • TZ: timezone as a plain string

Nondeterministic functions

  • RAND(): pseudo-random double in [0, 1)
  • UUID() : fresh IRI-form UUID
  • STRUUID() : fresh string-form UUID
  • NOW() : current date/time as xsd:dateTime

Changes to existing functions

  • REGEX now accepts flags as an optional third parameter

@github-project-automation github-project-automation Bot moved this to Todo in nemo Apr 8, 2026
@aannleax aannleax added the builtins Issue related to built-in functions label Apr 8, 2026
@aannleax aannleax added this to the Release 0.10.0 milestone Apr 8, 2026
@aannleax
aannleax requested a review from mmarx April 8, 2026 14:40

@mmarx mmarx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 chrono for 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 simple value.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

Comment thread nemo-physical/src/function/definitions/datetime.rs Outdated
Comment thread nemo-physical/src/function/definitions/datetime.rs Outdated
Comment thread nemo-physical/src/function/definitions/datetime.rs Outdated
Comment thread nemo-physical/src/function/definitions/generic.rs Outdated
Comment thread nemo-physical/src/function/definitions/generic.rs
Comment thread nemo-physical/src/function/definitions/hashing.rs Outdated
Comment thread nemo-physical/src/function/definitions/hashing.rs Outdated
Comment thread nemo-physical/src/function/definitions/nondeterministic.rs Outdated
@github-project-automation github-project-automation Bot moved this from Todo to In Progress in nemo Apr 8, 2026
@mkroetzsch

Copy link
Copy Markdown
Member

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.

@aannleax
aannleax requested a review from mmarx April 9, 2026 08:50
Comment thread nemo-physical/src/function/definitions/datetime.rs Outdated
Comment thread nemo-physical/src/function/definitions/datetime.rs
Comment thread nemo-physical/src/function/definitions/datetime.rs
Comment thread nemo-physical/src/function/definitions/datetime.rs
Comment thread nemo-physical/src/function/definitions/datetime.rs
Comment thread nemo-physical/src/function/definitions/string.rs Outdated
Comment on lines +450 to +451
/// An optional third parameter may provide regex flags (e.g. `"i"` for case-insensitive),
/// corresponding to the SPARQL `regex(string, pattern [, flags])` function.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also doesn't validate the flags, and furthermore doesn't support the q flag.

Comment on lines +549 to +550
/// Returns a language tagged string if the first parameter has a language tag.
/// Otherwise, return a plain string.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if we could keep the grammar consistent and not use returns and return in the same paragraph.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still says “returns …, otherwise return …”.

Comment on lines +1336 to +1374
#[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());
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1595 to 1603
// 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());
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mmarx mmarx modified the milestones: Release 0.10.0, Release 0.11.0 Apr 13, 2026
Comment thread nemo-physical/src/function/evaluation.rs Outdated
@mmarx

mmarx commented Apr 14, 2026

Copy link
Copy Markdown
Member

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) .

bar correctly turns into a single SPARQL query, whereas foo will just produce an endless list of queries.

Comment thread nemo-physical/src/function/definitions/nondeterministic.rs Outdated
Comment thread nemo-physical/src/function/definitions/nondeterministic.rs Outdated
@aannleax
aannleax force-pushed the feature/add-builtins branch from e4a29f9 to 3a551ea Compare July 14, 2026 14:32

@mmarx mmarx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +23 to +24
///
/// Lazily initialized with the current time on first access

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment just restates what the code already says. Get rid of it.

Comment on lines +28 to +38
/// 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();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +48 to +54
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;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are still not match statements, despite the (still open) request below?

Comment on lines +252 to +253
/// Returns the timestamp captured by [set_now_timestamp], so all evaluations
/// during one program execution share the same value.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +35 to +36
/// Returns a value of the form `<urn:uuid:…>` containing a version 7 UUID,
/// which is lexicographically greater than all UUIDs generated before it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +891 to +894
/// 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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +939 to +940
/// Corresponds to SPARQL langMatches(language-tag, language-range).
/// Returns `true` if `language-tag` matches `language-range` per RFC 4647 basic filtering:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we suddenly use kebab-case for language-tag and language-range? Just call them tag and range.

Comment on lines +939 to +940
/// Corresponds to SPARQL langMatches(language-tag, language-range).
/// Returns `true` if `language-tag` matches `language-range` per RFC 4647 basic filtering:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also refer to BCP 47, not to RFC 4647 (which just happens to be the current version right now).

Comment on lines +1336 to +1374
#[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());
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1595 to 1603
// 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());
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to ever set this after it's been set, so this should just be LazyLock<String>.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, what if we allow reasoning to be performed multiple times...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

builtins Issue related to built-in functions

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Add built-in to strip/trim strings Suport more functions

3 participants