oxiland 0.7.0

Embedded RDF datasets, SPARQL, persistence, and streaming I/O for Rust
Documentation
use oxiland::terms::{self, GraphName, Literal, NamedNode, Triple};
use oxiland::{Error, Model, Query, QueryResults, StatementPattern, World};

fn example_statement() -> Triple {
    Triple::new(
        NamedNode::new("https://example.com/alice").unwrap(),
        NamedNode::new("https://example.com/name").unwrap(),
        Literal::new_simple_literal("Alice"),
    )
}

fn assert_send_sync<T: Send + Sync>() {}

#[test]
fn concurrent_add_returns_true_only_once() {
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::thread;

    let model = Model::new().unwrap();
    let statement = example_statement();
    let newly = AtomicUsize::new(0);
    thread::scope(|scope| {
        for _ in 0..8 {
            scope.spawn(|| {
                if model.add(statement.clone()).unwrap() {
                    newly.fetch_add(1, Ordering::SeqCst);
                }
            });
        }
    });
    assert_eq!(newly.load(Ordering::SeqCst), 1);
    assert_eq!(model.len().unwrap(), 1);
}

#[test]
fn concurrent_remove_returns_true_only_once() {
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::thread;

    let model = Model::new().unwrap();
    let statement = example_statement();
    assert!(model.add(statement.clone()).unwrap());
    let removed = AtomicUsize::new(0);
    thread::scope(|scope| {
        for _ in 0..8 {
            scope.spawn(|| {
                if model.remove(statement.clone()).unwrap() {
                    removed.fetch_add(1, Ordering::SeqCst);
                }
            });
        }
    });
    assert_eq!(removed.load(Ordering::SeqCst), 1);
    assert!(model.is_empty().unwrap());
}

#[test]
fn core_types_are_send_sync() {
    assert_send_sync::<Model>();
    assert_send_sync::<World>();
    assert_send_sync::<Query>();
}

#[test]
fn world_features_are_shared_across_clones() {
    let world = World::new();
    let clone = world.clone();
    world.set_feature(
        "http://example.com/feature",
        oxiland::FeatureValue::Boolean(true),
    );
    assert_eq!(
        clone.feature("http://example.com/feature"),
        Some(oxiland::FeatureValue::Boolean(true))
    );
}

#[test]
fn model_supports_redland_style_crud_and_matching() {
    let model = Model::new().unwrap();
    let statement = example_statement();

    assert!(model.add(statement.clone()).unwrap());
    assert!(!model.add(statement.clone()).unwrap());
    assert!(model.contains(statement.as_ref()).unwrap());
    assert_eq!(model.len().unwrap(), 1);

    let matches = model
        .find(StatementPattern {
            subject: Some(statement.subject.as_ref()),
            ..StatementPattern::default()
        })
        .collect::<Result<Vec<_>, _>>()
        .unwrap();
    assert_eq!(matches.len(), 1);

    assert!(model.remove(statement).unwrap());
    assert!(model.is_empty().unwrap());
}

#[test]
fn model_clone_shares_dataset() {
    let model = Model::new().unwrap();
    let clone = model.clone();
    assert!(model.add(example_statement()).unwrap());
    assert_eq!(clone.len().unwrap(), 1);
}

#[test]
fn named_graph_crud_is_isolated_from_default_graph() {
    let model = Model::new().unwrap();
    let statement = example_statement();
    let graph = NamedNode::new("https://example.com/graph").unwrap();
    let graph_name = GraphName::NamedNode(graph.clone());

    assert!(
        model
            .add_to_graph(statement.clone(), graph_name.clone())
            .unwrap()
    );
    assert!(
        !model
            .add_to_graph(statement.clone(), graph_name.clone())
            .unwrap()
    );
    assert!(
        model
            .contains_in_graph(statement.as_ref(), graph_name.as_ref())
            .unwrap()
    );
    assert!(!model.contains(statement.as_ref()).unwrap());

    let in_context = model
        .find(StatementPattern {
            graph_name: Some(graph_name.as_ref()),
            ..StatementPattern::default()
        })
        .collect::<Result<Vec<_>, _>>()
        .unwrap();
    assert_eq!(in_context.len(), 1);

    let in_default = model
        .find(StatementPattern {
            graph_name: Some(oxiland::terms::GraphNameRef::DefaultGraph),
            ..StatementPattern::default()
        })
        .collect::<Result<Vec<_>, _>>()
        .unwrap();
    assert!(in_default.is_empty());

    assert!(
        model
            .remove_from_graph(statement.clone(), graph_name.clone())
            .unwrap()
    );
    assert!(
        !model
            .contains_in_graph(statement.as_ref(), graph_name.as_ref())
            .unwrap()
    );
    assert!(model.is_empty().unwrap());
}

#[test]
fn find_streams_without_full_materialization() {
    let model = Model::new().unwrap();
    for i in 0..8 {
        model
            .add(Triple::new(
                NamedNode::new(format!("https://example.com/s/{i}")).unwrap(),
                NamedNode::new("https://example.com/name").unwrap(),
                Literal::new_simple_literal(format!("v{i}")),
            ))
            .unwrap();
    }

    let first = model
        .find(StatementPattern::default())
        .next()
        .unwrap()
        .unwrap();
    assert!(first.subject.to_string().contains("example.com/s/"));
}

#[test]
fn model_supports_contexts_and_sparql() {
    let model = Model::new().unwrap();
    let graph = NamedNode::new("https://example.com/graph").unwrap();
    model
        .add_to_graph(example_statement(), GraphName::NamedNode(graph))
        .unwrap();

    let ask = Query::new("ASK WHERE { GRAPH ?g { ?s ?p ?o } }")
        .execute(&model)
        .unwrap();
    assert!(matches!(ask, QueryResults::Boolean(true)));

    let QueryResults::Solutions(mut solutions) =
        Query::new("SELECT ?name WHERE { GRAPH ?g { ?s <https://example.com/name> ?name } }")
            .execute(&model)
            .unwrap()
    else {
        panic!("expected solution results");
    };
    let solution = solutions.next().unwrap().unwrap();
    assert_eq!(
        solution.get("name").map(ToString::to_string).as_deref(),
        Some("\"Alice\"")
    );
}

#[test]
fn invalid_iri_maps_to_invalid_rdf_error() {
    let error = terms::named_node("not a valid iri").unwrap_err();
    assert!(matches!(error, Error::InvalidRdf(_)));
}

#[test]
fn invalid_blank_node_id_maps_to_invalid_rdf_error() {
    let error = terms::blank_node(Some("bad id")).unwrap_err();
    assert!(matches!(error, Error::InvalidRdf(_)));
}

#[test]
fn sparql_parse_errors_are_distinct_from_evaluation() {
    let model = Model::new().unwrap();
    match Query::new("NOT VALID SPARQL").execute(&model) {
        Err(Error::SparqlParse(_)) => {}
        Err(error) => panic!("expected SparqlParse, got {error}"),
        Ok(_) => panic!("expected SparqlParse, got successful query results"),
    }
}

#[test]
fn unsupported_storage_backend_is_explicit() {
    let error = Model::storage_backend_available("mysql").unwrap_err();
    assert!(matches!(error, Error::Unsupported(_)));
    assert!(matches!(
        Model::storage_backend_available("rocksdb"),
        Err(Error::Unsupported(_))
    ));
    assert!(matches!(
        Model::storage_backend_available("redb"),
        Err(Error::Unsupported(_))
    ));
    assert!(Model::storage_backend_available("memory").unwrap());
    assert!(Model::storage_backend_available("fjall").unwrap());
}

#[test]
fn fjall_model_round_trips_statements() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("store");
    {
        let model = Model::open(&path).unwrap();
        assert!(model.add(example_statement()).unwrap());
        assert_eq!(model.len().unwrap(), 1);
    }
    let reopened = Model::open(&path).unwrap();
    assert!(reopened.contains(example_statement().as_ref()).unwrap());
}