use oxilite::functions::HostFunction;
use oxilite::model::{Literal, Term};
use oxilite::sparql::QueryResults;
use oxilite::store::Store;
const FN: &str = "http://example.com/fn#";
const PREFIXES: &str = "PREFIX ex: <http://example.com/> PREFIX fn: <http://example.com/fn#> ";
fn text(t: &Term) -> Option<&str> {
match t {
Term::Literal(l) => Some(l.value()),
_ => None,
}
}
fn slugify() -> HostFunction {
HostFunction::new(format!("{FN}slugify"), |args| {
let s = text(args.first()?)?;
Some(
Literal::new_simple_literal(
s.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join("-"),
)
.into(),
)
})
.cypher_name("ex.slugify")
.arity(1, 1)
.description("URL-safe slug of a string")
}
fn is_long() -> HostFunction {
HostFunction::new(format!("{FN}isLong"), |args| {
Some(Literal::from(text(args.first()?)?.chars().count() > 4).into())
})
.arity(1, 1)
}
fn people<B: oxilite::SyncBackend + Send + Sync + 'static>(store: &Store<B>) {
store.register_function(slugify()).unwrap();
store.register_function(is_long()).unwrap();
store
.update(format!(
r#"{PREFIXES} INSERT DATA {{
ex:ada a ex:Person ; ex:name "Ada Lovelace" .
ex:al a ex:Person ; ex:name "Al" .
ex:grace a ex:Person ; ex:name "Grace Hopper" .
}}"#
))
.unwrap();
}
fn column<B: oxilite::SyncBackend + Send + Sync + 'static>(
store: &Store<B>,
q: &str,
var: &str,
) -> Vec<Option<String>> {
let QueryResults::Solutions(s) = store.query(format!("{PREFIXES}{q}").as_str()).unwrap() else {
panic!("not a SELECT")
};
let mut out: Vec<Option<String>> = s
.map(|r| r.unwrap().get(var).and_then(text).map(str::to_owned))
.collect();
out.sort();
out
}
#[test]
fn registry() {
let store = Store::new().unwrap();
people(&store);
let fs = store.functions();
assert_eq!(fs.len(), 2);
assert!(fs.iter().any(|f| f.cypher() == "ex.slugify"
&& f.description_text() == "URL-safe slug of a string"
&& f.max_arity() == Some(1)));
assert_eq!(store.clone().functions().len(), 2);
for clash in [
HostFunction::new("https://oxilite.dev/ns#textMatch", |_| None),
HostFunction::new("http://www.w3.org/2001/XMLSchema#string", |_| None),
HostFunction::new("http://example.com/x", |_| None).cypher_name("toUpper"),
] {
assert!(store.register_function(clash).is_err());
}
assert!(store.unregister_function(&format!("{FN}isLong")));
assert_eq!(store.functions().len(), 1);
}
#[test]
fn sparql_calls_host_functions() {
let store = Store::new().unwrap();
people(&store);
assert_eq!(
column(
&store,
"SELECT ?slug WHERE { ?s ex:name ?n BIND(fn:slugify(?n) AS ?slug) FILTER(fn:isLong(?n)) }",
"slug"
),
[Some("ada-lovelace".into()), Some("grace-hopper".into())]
);
let explain = store
.explain(format!("{PREFIXES} SELECT ?slug WHERE {{ ?s a ex:Person ; ex:name ?n BIND(fn:slugify(?n) AS ?slug) }}").as_str())
.unwrap();
assert!(explain.contains("SELECT"), "{explain}");
store
.update(format!(
"{PREFIXES} INSERT {{ ?s ex:slug ?slug }} WHERE {{ ?s ex:name ?n BIND(fn:slugify(?n) AS ?slug) }}"
))
.unwrap();
assert_eq!(
column(&store, "SELECT ?x WHERE { ex:al ex:slug ?x }", "x"),
[Some("al".into())]
);
let err = store
.query(
format!("{PREFIXES} SELECT ?x WHERE {{ ex:al ex:name ?n BIND(fn:missing(?n) AS ?x) }}")
.as_str(),
)
.err()
.expect("unknown function");
assert!(err.to_string().contains("fn#missing"), "{err}");
assert_eq!(
column(
&store,
"SELECT ?x WHERE { ex:al ex:name ?n BIND(fn:slugify(?n, ?n) AS ?x) }",
"x"
),
[None]
);
}
#[cfg(feature = "turso")]
#[test]
fn host_functions_with_vector_search() {
let store = Store::new_turso().unwrap();
people(&store);
store
.update(format!(
r#"{PREFIXES} INSERT DATA {{ ex:ada ex:e "[1, 0]" . ex:al ex:e "[0, 1]" . ex:grace ex:e "[0.9, 0.2]" }}"#
))
.unwrap();
store
.create_vector_index(&oxilite::vector::VectorIndex::new(
"people",
oxilite::model::NamedNode::new("http://example.com/e").unwrap(),
2,
))
.unwrap();
assert_eq!(
column(
&store,
"PREFIX oxl: <https://oxilite.dev/ns#> SELECT ?slug WHERE {
SERVICE <oxilite:vector/people> { [] oxl:query \"[1, 0]\" ; oxl:k 2 ; oxl:node ?p }
?p ex:name ?n BIND(fn:slugify(?n) AS ?slug)
}",
"slug"
),
[Some("ada-lovelace".into()), Some("grace-hopper".into())]
);
}
#[test]
fn cypher_calls_host_functions() {
use oxilite::cypher::{CypherOptions, Value, Vocabulary};
let store = Store::new().unwrap();
people(&store);
let options = CypherOptions {
vocabulary: Vocabulary::new("http://example.com/"),
..Default::default()
};
let run = |q: &str| {
store
.cypher_with(q, &Default::default(), &options)
.unwrap_or_else(|e| panic!("{q}: {e}"))
};
let r = run(
"MATCH (p:Person) WHERE isLong(p.name) RETURN ex.slugify(p.name) AS slug ORDER BY slug",
);
assert_eq!(
r.rows,
[
vec![Value::String("ada-lovelace".into())],
vec![Value::String("grace-hopper".into())]
]
);
let r =
run("MATCH (p:Person) WITH p ORDER BY p.name RETURN collect(ex.slugify(p.name)) AS slugs");
assert_eq!(
r.rows[0][0],
Value::List(vec![
Value::String("ada-lovelace".into()),
Value::String("al".into()),
Value::String("grace-hopper".into())
])
);
let r = run("RETURN EX.SLUGIFY('Hello World') AS s, ex.slugify(null) AS n");
assert_eq!(
r.rows[0],
[Value::String("hello-world".into()), Value::Null]
);
let err = store
.cypher_with("RETURN nosuch.fn('x')", &Default::default(), &options)
.unwrap_err();
assert!(err.to_string().contains("nosuch.fn"), "{err}");
}
fn datalog_column<B: oxilite::SyncBackend + Send + Sync + 'static>(
store: &Store<B>,
program: &str,
var: &str,
) -> Vec<String> {
let r = store
.datalog(&format!(
"@prefix ex: <http://example.com/> .\n@prefix fn: <http://example.com/fn#> .\n{program}"
))
.unwrap_or_else(|e| panic!("{program}: {e}"));
let mut out: Vec<String> = (0..r.rows.len())
.map(|i| match r.get(i, var) {
Some(Term::Literal(l)) => l.value().to_owned(),
Some(t) => t.to_string(),
None => "unbound".into(),
})
.collect();
out.sort();
out
}
#[test]
fn datalog_calls_host_functions() {
let store = Store::new().unwrap();
people(&store);
assert_eq!(
datalog_column(
&store,
"slug(?p, ?s) :- ex:name(?p, ?n), ?s = fn:slugify(?n). ?- slug(?p, ?s).",
"s"
),
["ada-lovelace", "al", "grace-hopper"]
);
assert_eq!(
datalog_column(
&store,
"long(?p, ?s) :- ex:name(?p, ?n), fn:isLong(?n), fn:slugify(?n, ?s). ?- long(?p, ?s).",
"s"
),
["ada-lovelace", "grace-hopper"]
);
let program = "slug(?p, ?s) :- ex:name(?p, ?n), ?s = fn:slugify(?n).
short(?p) :- ex:Person(?p), not long(?p).
long(?p) :- slug(?p, ?s), fn:isLong(?s).
both(?p, ?s) :- short(?p), slug(?p, ?s).
?- both(?p, ?s).";
assert_eq!(datalog_column(&store, program, "s"), ["al"]);
assert_eq!(
datalog_column(
&store,
"named(?p, ?n) :- ex:name(?p, ?n). ?- named(?p, ?n), fn:isLong(?n) = true.",
"n"
),
["Ada Lovelace", "Grace Hopper"]
);
assert!(datalog_column(
&store,
"none(?p) :- ex:name(?p, ?n), fn:slugify(?n) = \"nobody\". ?- none(?p).",
"p"
)
.is_empty());
}
#[test]
fn datalog_rejects_recursive_host_rules() {
let store = Store::new().unwrap();
people(&store);
let err = store
.datalog(
"@prefix ex: <http://example.com/> . @prefix fn: <http://example.com/fn#> .
r(?x, ?y) :- ex:name(?x, ?y).
r(?x, ?y) :- r(?x, ?z), ?y = fn:slugify(?z).
?- r(?x, ?y).",
)
.unwrap_err();
assert!(err.to_string().contains("cannot be recursive"), "{err}");
let err = store
.datalog(
"@prefix ex: <http://example.com/> . @prefix fn: <http://example.com/fn#> .
r(?x, ?y) :- ex:name(?x, ?n), ?y = fn:unknown(?n). ?- r(?x, ?y).",
)
.unwrap_err();
assert!(err.to_string().contains("fn#unknown"), "{err}");
}