use rete_core::ingest::{assemble_dataset_with_opts, RawQuad};
use rete_core::{eval_sparql, Rete, TextIndexGraphs, TextIndexScope};
fn quad(s: &str, p: &str, o: &str, g: Option<&str>) -> RawQuad {
(
format!("<http://ex/{s}>"),
format!("<http://ex/{p}>"),
o.to_string(),
g.map(|g| format!("<http://ex/{g}>")),
)
}
fn quads() -> Vec<RawQuad> {
vec![
quad("e1", "title", "\"Glucose metabolism\"", None),
quad("e1", "desc", "\"The entity has been modified.\"", None),
quad("e2", "title", "\"Benzene ring\"", None),
quad("e3", "desc", "\"modified twice\"", None),
quad("e3", "kind", "<http://ex/Thing>", None),
quad("p1", "desc", "\"The entity has been created.\"", Some("g1")),
quad("p1", "note", "\"created by an agent\"", Some("g1")),
quad(
"p2",
"desc",
"\"The entity has been modified.\"",
Some("g2"),
),
]
}
fn build(scope: Option<TextIndexScope>) -> Vec<u8> {
let (bytes, _) = assemble_dataset_with_opts(quads(), false, scope, None, |_, _| Vec::new());
bytes
}
fn rows(rete: &Rete, q: &str) -> Vec<String> {
let (_, sols) = eval_sparql(rete, &format!("PREFIX ex: <http://ex/> {q}")).unwrap();
let mut v: Vec<String> = sols.iter().filter_map(|b| b.get("s").cloned()).collect();
v.sort();
v
}
fn scopes() -> Vec<(&'static str, TextIndexScope)> {
let none: [&str; 0] = [];
vec![
("default", TextIndexScope::default()),
(
"all graphs",
TextIndexScope::from_options(TextIndexGraphs::All, none, none).unwrap(),
),
(
"exclude desc",
TextIndexScope::from_options(TextIndexGraphs::Default, none, ["http://ex/desc"])
.unwrap(),
),
(
"include desc, all graphs",
TextIndexScope::from_options(TextIndexGraphs::All, ["http://ex/desc"], none).unwrap(),
),
(
"include title",
TextIndexScope::from_options(TextIndexGraphs::Default, ["<http://ex/title>"], none)
.unwrap(),
),
]
}
const QUERIES: &[&str] = &[
"SELECT ?s WHERE { ?s ex:desc ?o FILTER(CONTAINS(?o, \"modified\")) }",
"SELECT ?s WHERE { ?s ex:title ?o FILTER(CONTAINS(?o, \"Glucose\")) }",
"SELECT ?s WHERE { ?s ?p ?o FILTER(CONTAINS(?o, \"modified\")) }",
"SELECT ?s WHERE { GRAPH ex:g1 { ?s ?p ?o FILTER(CONTAINS(?o, \"created\")) } }",
"SELECT ?s WHERE { GRAPH ?g { ?s ex:desc ?o FILTER(CONTAINS(?o, \"modified\")) } }",
"SELECT ?s FROM ex:g1 WHERE { ?s ?p ?o FILTER(CONTAINS(?o, \"created\")) }",
];
#[test]
fn every_scope_answers_contains_like_a_file_without_an_index() {
let plain = build(None);
let rete_plain = Rete::open(&plain).unwrap();
for q in QUERIES {
assert!(
!rows(&rete_plain, q).is_empty(),
"fixture answers nothing: {q}"
);
}
for (name, scope) in scopes() {
let bytes = build(Some(scope.clone()));
let rete = Rete::open(&bytes).unwrap();
assert_eq!(
rete.header().text_index_partial,
scope.restricts_predicates()
);
for q in QUERIES {
assert_eq!(
rows(&rete, q),
rows(&rete_plain, q),
"scope {name}: indexed and plain diverge for {q}"
);
}
}
}
#[test]
fn text_search_follows_the_scope() {
let search = |scope: TextIndexScope, word: &str| {
let bytes = build(Some(scope));
Rete::open(&bytes).unwrap().text_search(&[word], None, 0)
};
let all = scopes();
assert_eq!(search(all[0].1.clone(), "created"), Vec::<String>::new());
assert_eq!(search(all[1].1.clone(), "created"), ["<http://ex/p1>"]);
assert_eq!(
search(all[0].1.clone(), "modified"),
["<http://ex/e1>", "<http://ex/e3>"]
);
assert_eq!(
search(all[1].1.clone(), "modified"),
["<http://ex/e1>", "<http://ex/e3>", "<http://ex/p2>"]
);
assert_eq!(search(all[2].1.clone(), "modified"), Vec::<String>::new());
assert_eq!(search(all[2].1.clone(), "glucose"), ["<http://ex/e1>"]);
assert_eq!(search(all[4].1.clone(), "modified"), Vec::<String>::new());
}
#[test]
fn default_scope_is_the_unscoped_index() {
let unscoped = {
let (b, _) = assemble_dataset_with_opts(quads(), false, true, None, |_, _| Vec::new());
b
};
assert_eq!(build(Some(TextIndexScope::default())), unscoped);
let h = rete_core::Header::from_bytes(&unscoped).unwrap();
assert!(h.text_index_len > 0 && !h.text_index_partial);
}
use rete_core::format::content_hash_parts;
use rete_core::{verify, verify_ranged, Header, RangeReader, SectionKind, SliceReader};
use std::sync::{Arc, Mutex};
fn exclude_desc() -> TextIndexScope {
let none: [&str; 0] = [];
TextIndexScope::from_options(TextIndexGraphs::Default, none, ["http://ex/desc"]).unwrap()
}
#[test]
fn a_restricted_text_index_is_self_digested_not_hashed() {
let bytes = build(Some(exclude_desc()));
let h = Header::from_bytes(&bytes).unwrap();
assert!(h.text_index_partial);
let k11 = h.section(SectionKind::TextIndexPartial).unwrap();
assert!(h
.section(SectionKind::TextIndex)
.is_none_or(|s| s.length == 0));
assert!(
content_hash_parts(&h)
.iter()
.all(|&(off, _)| off != k11.offset),
"kind 11 is not folded into the content hash"
);
let plain = Header::from_bytes(&build(None)).unwrap();
assert_eq!(h.content_hash, plain.content_hash);
let (p_off, p_len) = h.text_index_payload();
assert_eq!((p_off, p_len), (k11.offset + 16, k11.length - 16));
let section = &bytes[k11.offset as usize..(k11.offset + k11.length) as usize];
assert_eq!(
§ion[..16],
&blake3::hash(§ion[16..]).as_bytes()[..16]
);
assert!(verify(&bytes).unwrap());
assert!(verify_ranged(&SliceReader::new(&bytes)).unwrap());
for at in [k11.offset + 3, k11.offset + 20, k11.offset + k11.length - 1] {
let mut bad = bytes.clone();
bad[at as usize] ^= 0x40;
assert!(!verify(&bad).unwrap(), "a flipped byte at {at} in kind 11");
assert!(!verify_ranged(&SliceReader::new(&bad)).unwrap());
}
let rete = Rete::open(&bytes).unwrap();
assert_eq!(
rows(
&rete,
r#"SELECT ?s WHERE { ?s ex:title ?t FILTER(CONTAINS(?t, "Glucose")) }"#
),
["<http://ex/e1>"]
);
let lazy = Rete::open_ranged_lazy(Recorded::new(bytes.clone())).unwrap();
assert!(lazy.text_index_scope().unwrap().restricts_predicates());
assert_eq!(
rows(
&lazy,
r#"SELECT ?s WHERE { ?s ex:title ?t FILTER(CONTAINS(?t, "Glucose")) }"#
),
["<http://ex/e1>"]
);
}
#[test]
fn the_named_graphs_bit_follows_the_scope() {
for (name, scope) in scopes() {
let h = Header::from_bytes(&build(Some(scope.clone()))).unwrap();
assert_eq!(
h.text_index_covers_named_graphs(),
scope.covers_named_graphs(),
"{name}"
);
assert_eq!(h.text_index_partial, scope.restricts_predicates(), "{name}");
}
assert!(!Header::from_bytes(&build(None))
.unwrap()
.text_index_covers_named_graphs());
}
#[derive(Clone)]
struct Recorded {
data: Arc<Vec<u8>>,
reads: Arc<Mutex<Vec<(u64, u64)>>>,
}
impl Recorded {
fn new(data: Vec<u8>) -> Self {
Recorded {
data: Arc::new(data),
reads: Arc::new(Mutex::new(Vec::new())),
}
}
fn take(&self) -> Vec<(u64, u64)> {
std::mem::take(&mut *self.reads.lock().unwrap())
}
}
impl RangeReader for Recorded {
fn len(&self) -> u64 {
self.data.len() as u64
}
fn read_at(&self, offset: u64, len: u64) -> std::io::Result<Vec<u8>> {
self.reads.lock().unwrap().push((offset, len));
SliceReader::new(&self.data).read_at(offset, len)
}
}
fn touches(reads: &[(u64, u64)], (off, len): (u64, u64)) -> bool {
reads.iter().any(|&(o, l)| o < off + len && off < o + l)
}
#[test]
fn contains_inside_graph_reads_the_text_index_only_when_it_covers_the_graphs() {
let q = r#"SELECT ?s WHERE { GRAPH ex:g1 { ?s ex:desc ?d FILTER(CONTAINS(?d, "created")) } }"#;
for (scope, uses_index) in [
(TextIndexScope::default(), false),
(scopes()[1].1.clone(), true),
] {
assert_eq!(scope.covers_named_graphs(), uses_index);
let bytes = build(Some(scope));
let h = Header::from_bytes(&bytes).unwrap();
let text = (h.text_index_offset, h.text_index_len);
let rec = Recorded::new(bytes);
let rete = Rete::open_ranged_lazy(rec.clone()).unwrap();
rec.take();
assert_eq!(rows(&rete, q), ["<http://ex/p1>"]);
assert_eq!(
touches(&rec.take(), text),
uses_index,
"GRAPH + CONTAINS, uses_index = {uses_index}"
);
}
let bytes = build(Some(TextIndexScope::default()));
let h = Header::from_bytes(&bytes).unwrap();
let rec = Recorded::new(bytes);
let rete = Rete::open_ranged_lazy(rec.clone()).unwrap();
rec.take();
assert_eq!(
rows(
&rete,
r#"SELECT ?s WHERE { ?s ex:title ?t FILTER(CONTAINS(?t, "Glucose")) }"#
),
["<http://ex/e1>"]
);
assert!(touches(
&rec.take(),
(h.text_index_offset, h.text_index_len)
));
}