From 8a6fe06c2704ed5fda5593ea2ef4289dd16082abf92b294878dd87316ba58db1 Mon Sep 17 00:00:00 2001 From: Alex Wied <2+alex@noreply.code.graphofliberty.org> Date: Wed, 20 May 2026 23:45:30 -0400 Subject: [PATCH] Make RdfSource generic over the dataset type --- Cargo.lock | 14 ++++- Cargo.toml | 1 + ldctl/src/main.rs | 5 +- ldp/Cargo.toml | 5 ++ ldp/src/error.rs | 4 ++ ldp/src/keyed.rs | 59 +++++++++++++++++++ ldp/src/lib.rs | 7 ++- ldp/src/rdf_source.rs | 132 +++++++++++++++++++++++++++++++++++++----- ldp/src/resource.rs | 24 +++++--- 9 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 ldp/src/keyed.rs diff --git a/Cargo.lock b/Cargo.lock index 10271fa..2edd05b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,9 +244,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "errno" @@ -687,6 +687,7 @@ dependencies = [ "oxigraph", "parse_link_header", "reqwest-middleware", + "slotmap", "thiserror", "tracing", ] @@ -1308,6 +1309,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index 1a24bcd..6c2c2ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ color-eyre = "0.6" oxigraph = "0.5" parse_link_header = "0.4" reqwest-middleware = { version = "0.5", features = ["stream"] } +slotmap = "1.1" thiserror = "2" tokio = { version = "1", features = ["full"] } tracing = "0.1" diff --git a/ldctl/src/main.rs b/ldctl/src/main.rs index a896cbe..c1f5de1 100644 --- a/ldctl/src/main.rs +++ b/ldctl/src/main.rs @@ -1,5 +1,6 @@ use ldp::ResourceRequestBuilder; use ldp::middleware::BasicAuthMiddleware; +use ldp::oxigraph::model::Dataset; use ldp::reqwest::{Client, Url}; use ldp::reqwest_middleware::ClientBuilder; use tracing::debug; @@ -28,9 +29,9 @@ async fn main() -> color_eyre::Result<()> { .build(); let resource = request.send().await?; - let rdf_source = resource.into_rdf_source().await?; + let rdf_source = resource.into_rdf_source::().await?; for quad in rdf_source.dataset() { - debug!(?quad); + println!("{:#?}", quad); } Ok(()) diff --git a/ldp/Cargo.toml b/ldp/Cargo.toml index 9f4e4b1..c5fdd7b 100644 --- a/ldp/Cargo.toml +++ b/ldp/Cargo.toml @@ -8,6 +8,10 @@ license = "GPL-3.0-only" keywords = ["ldp", "rdf", "sparql"] categories = ["database", "web-programming::http-client"] +[features] +default = ["keyed"] +keyed = ["dep:slotmap"] + [dependencies] async-trait.workspace = true base64.workspace = true @@ -17,5 +21,6 @@ http.workspace = true oxigraph.workspace = true parse_link_header.workspace = true reqwest-middleware.workspace = true +slotmap = { optional = true, workspace = true } thiserror.workspace = true tracing.workspace = true \ No newline at end of file diff --git a/ldp/src/error.rs b/ldp/src/error.rs index 5017215..f444c8b 100644 --- a/ldp/src/error.rs +++ b/ldp/src/error.rs @@ -22,4 +22,8 @@ pub enum Error { /// The response from the server was not in a format we understand. #[error("Response was not in a supported RDF format")] UnsupportedFormat, + + /// The RDF data failed to parse. + #[error(transparent)] + InvalidRdfSyntax(#[from] oxigraph::io::RdfSyntaxError), } diff --git a/ldp/src/keyed.rs b/ldp/src/keyed.rs new file mode 100644 index 0000000..5460b15 --- /dev/null +++ b/ldp/src/keyed.rs @@ -0,0 +1,59 @@ +use oxigraph::model::{Quad, QuadRef}; +use slotmap::{SecondaryMap, SlotMap, new_key_type}; + +new_key_type! { pub struct QuadKey; } + +#[derive(Clone, Debug, Default)] +pub struct KeyedDataset { + pub quads: SlotMap, + pub associated_data: SecondaryMap, +} + +impl FromIterator for KeyedDataset { + fn from_iter>(iter: U) -> Self { + let mut quads = SlotMap::with_key(); + for quad in iter { + quads.insert(quad); + } + Self { + quads, + associated_data: SecondaryMap::new(), + } + } +} + +impl<'a, T> IntoIterator for &'a KeyedDataset { + type Item = QuadRef<'a>; + type IntoIter = std::iter::Map, fn(&Quad) -> QuadRef>; + + fn into_iter(self) -> Self::IntoIter { + self.quads.values().map(|quad| quad.as_ref()) + } +} + +impl KeyedDataset { + /// Extend the dataset with the provided quads, returning a QuadKey for each insertion. + pub fn extend(&mut self, quads: impl Iterator) -> impl Iterator { + quads.map(|quad| self.quads.insert(quad)) + } + + /// Remove a quad and its associated value, if present. + pub fn remove(&mut self, key: QuadKey) { + self.quads.remove(key); + self.associated_data.remove(key); + } + + /// Remove all quads and all associated data. + pub fn clear(&mut self) { + self.quads.clear(); + self.associated_data.clear(); + } + + /// Iterate over both the quads and the associated data. Quads with no corresponding associated + /// data are filtered out. + pub fn iter_both(&self) -> impl Iterator { + self.quads + .iter() + .filter_map(|(key, quad)| self.associated_data.get(key).map(|ad| (key, quad, ad))) + } +} diff --git a/ldp/src/lib.rs b/ldp/src/lib.rs index f811e43..7d8a538 100644 --- a/ldp/src/lib.rs +++ b/ldp/src/lib.rs @@ -7,11 +7,16 @@ mod rdf_source; mod resource; pub mod vocab; +#[cfg(feature = "keyed")] +pub mod keyed; + pub use http; pub use oxigraph; pub use reqwest_middleware; pub use reqwest_middleware::reqwest; pub use error::{Error, Result}; -pub use rdf_source::{RdfSource, RdfSourceUpdateRequest, RdfSourceUpdateResponse}; +pub use rdf_source::{ + RdfSource, RdfSourceUpdateRequest, RdfSourceUpdateResponse, SerializationOptions, +}; pub use resource::{Resource, ResourceRequest, ResourceRequestBuilder, ResponseFormat}; diff --git a/ldp/src/rdf_source.rs b/ldp/src/rdf_source.rs index 80316cd..7ccff14 100644 --- a/ldp/src/rdf_source.rs +++ b/ldp/src/rdf_source.rs @@ -1,20 +1,36 @@ use bytes::BufMut; use http::{StatusCode, header}; use oxigraph::io::{RdfFormat, RdfSerializer}; -use oxigraph::model::Dataset; +use oxigraph::model::{ + GraphName, Literal, NamedNode, NamedOrBlankNode, Quad, QuadRef, Term, Triple, TripleRef, vocab, +}; use reqwest_middleware::ClientWithMiddleware; use reqwest_middleware::reqwest::Url; /// A LDP [RDF Source](https://www.w3.org/TR/ldp/#ldprs). #[derive(Clone, Debug)] -pub struct RdfSource { +pub struct RdfSource { pub(crate) origin: Url, pub(crate) described_by: Option, pub(crate) state_token: Option, - pub(crate) dataset: Dataset, + pub(crate) dataset: D, } -impl RdfSource { +impl RdfSource { + /// Create a new, empty, RdfSource with the given URL. + /// + /// The subject of new quads will use this URL. + pub fn new(origin: Url) -> Self { + Self { + origin, + described_by: None, + state_token: None, + dataset: Default::default(), + } + } +} + +impl RdfSource { /// The original URL used to procure this RDF Source. pub fn origin(&self) -> &Url { &self.origin @@ -34,23 +50,104 @@ impl RdfSource { self.state_token.as_deref() } - /// The underlying Dataset. - pub fn dataset(&self) -> &Dataset { + /// The underlying dataset. + pub fn dataset(&self) -> &D { &self.dataset } - /// Serializes the Dataset in to the provided format. - pub fn serialize(&self, format: RdfFormat) -> crate::Result { - let writer = bytes::BytesMut::new().writer(); - let mut serializer = RdfSerializer::from_format(format).for_writer(writer); + /// A mutable reference to the underlying dataset. + pub fn dataset_mut(&mut self) -> &mut D { + &mut self.dataset + } - if format.supports_datasets() { + /// Create a new quad, using the origin as the subject. + /// + /// The graph name is the `describedby` value, if present. If not present, the graph name is the + /// origin. + pub fn new_quad(&self) -> Quad { + let graph_name = self + .described_by + .as_ref() + .map(|db| db.as_str()) + .unwrap_or(self.origin().as_str()); + Quad::new( + NamedOrBlankNode::NamedNode(NamedNode::new_unchecked(self.origin.clone())), + vocab::rdf::VALUE, + Term::Literal(Literal::new_simple_literal("")), + GraphName::NamedNode(NamedNode::new_unchecked(graph_name)), + ) + } + + /// Create a new quad, using the given triple as a template. + /// + /// The graph name is the `describedby` value, if present. If not present, the graph name is the + /// origin. + pub fn quad_from_triple(&self, triple: Triple) -> Quad { + let graph_name = GraphName::NamedNode(NamedNode::new_unchecked( + self.described_by + .as_ref() + .map(|db| db.as_str()) + .unwrap_or(self.origin().as_str()), + )); + + Quad::new( + triple.subject, + triple.predicate, + triple.object, + graph_name.clone(), + ) + } +} + +/// Holds options related to serialization. +pub struct SerializationOptions { + format: RdfFormat, + filter: Box) -> bool>, +} + +impl SerializationOptions { + /// Create a new set of serialization options with the provided format. + pub fn from_format(format: RdfFormat) -> Self { + Self { + format, + filter: Box::new(|_| true), + } + } + + /// Filter triples that match the predicate. + /// + /// A return value of `true` means that it the triple ought to be included in the serialization. + pub fn with_filter(self, filter: F) -> Self + where + F: Fn(TripleRef<'_>) -> bool + 'static, + { + Self { + format: self.format, + filter: Box::new(filter), + } + } +} + +impl<'a, D: 'a> RdfSource +where + &'a D: IntoIterator>, +{ + /// Serializes the dataset in to the provided format. + pub fn serialize(&'a self, options: SerializationOptions) -> crate::Result { + let writer = bytes::BytesMut::new().writer(); + let mut serializer = RdfSerializer::from_format(options.format).for_writer(writer); + + if options.format.supports_datasets() { for quad in &self.dataset { - serializer.serialize_quad(quad)?; + if (options.filter)(TripleRef::from(quad)) { + serializer.serialize_quad(quad)?; + } } } else { for quad in &self.dataset { - serializer.serialize_triple(quad)?; + if (options.filter)(TripleRef::from(quad)) { + serializer.serialize_triple(quad)?; + } } } @@ -59,10 +156,13 @@ impl RdfSource { } /// Prepare an update request. - pub fn to_update(&self, format: RdfFormat) -> crate::Result { + pub fn to_update( + &'a self, + options: SerializationOptions, + ) -> crate::Result { let url = self.described_by.clone().unwrap_or(self.origin.clone()); - let media_type = format.media_type().to_string(); - let body = self.serialize(format)?; + let media_type = options.format.media_type().to_string(); + let body = self.serialize(options)?; Ok(RdfSourceUpdateRequest { url, diff --git a/ldp/src/resource.rs b/ldp/src/resource.rs index a3b1b58..8c2099d 100644 --- a/ldp/src/resource.rs +++ b/ldp/src/resource.rs @@ -3,7 +3,7 @@ use crate::vocab; use bytes::Bytes; use futures::Stream; use oxigraph::io::{RdfFormat, RdfParser}; -use oxigraph::model::{Dataset, GraphNameRef, NamedNodeRef}; +use oxigraph::model::{GraphNameRef, NamedNodeRef, Quad}; use reqwest_middleware::reqwest::{Client, Response, StatusCode, Url, header}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder}; use tracing::error; @@ -12,17 +12,22 @@ use tracing::error; /// /// # Example /// ```rust +/// use ldp::ResourceRequestBuilder; +/// use ldp::oxigraph::model::Dataset; /// use ldp::reqwest::{Client, Url}; /// use ldp::reqwest_middleware::ClientBuilder; -/// use ldp::ResourceRequestBuilder; /// -/// let client = ClientBuilder::new(Client::new()).build(); /// let url = Url::parse("http://server/resource")?; -/// let resource = ResourceRequestBuilder::with_client_and_url(client.clone(), url) +/// let request = ResourceRequestBuilder::new(url) /// .follow_described_by(true) /// .accept_all_rdf_formats() -/// .send(); -/// .await?; +/// .build(); +/// +/// let resource = request.send().await?; +/// let rdf_source = resource.into_rdf_source::().await?; +/// for quad in rdf_source.dataset() { +/// println!("{:?}", quad); +/// } /// ``` #[derive(Clone, Debug)] pub struct ResourceRequestBuilder { @@ -253,14 +258,17 @@ impl Resource { } /// Parse the response. - pub async fn into_rdf_source(self) -> crate::Result { + pub async fn into_rdf_source(self) -> crate::Result> + where + D: FromIterator, + { if let ResponseFormat::RdfFormat(format) = self.format { let graph_url = self.described_by.as_ref().unwrap_or(&self.origin); let graph = GraphNameRef::NamedNode(NamedNodeRef::new_unchecked(graph_url.as_str())); let parser = RdfParser::from_format(format).with_default_graph(graph); let body = self.response.bytes().await?; let quads = parser.for_slice(&body); - let dataset = quads.filter_map(Result::ok).collect::(); + let dataset = quads.collect::>()?; Ok(RdfSource { origin: self.origin, described_by: self.described_by,