Skip to main content

oxirs_stream/patch/
context.rs

1//! Patch context and application
2
3use super::PatchResult;
4use crate::{PatchOperation, RdfPatch};
5use anyhow::{anyhow, Result};
6use tracing::debug;
7
8pub struct PatchContext {
9    pub strict_mode: bool,
10    pub validate_operations: bool,
11    pub dry_run: bool,
12}
13
14impl Default for PatchContext {
15    fn default() -> Self {
16        Self {
17            strict_mode: false,
18            validate_operations: true,
19            dry_run: false,
20        }
21    }
22}
23
24/// A destination that RDF Patch operations can be applied to.
25///
26/// This is the seam between the patch engine and a concrete RDF store. Callers
27/// that want a patch to actually mutate a dataset implement this trait (or use
28/// the real store integration in [`crate::store_integration`]) and pass it to
29/// [`apply_patch_to_sink`]. Implementations should return `Err` when an
30/// operation cannot be applied so that the error is surfaced rather than
31/// silently swallowed.
32pub trait PatchSink {
33    fn add_triple(&mut self, subject: &str, predicate: &str, object: &str) -> Result<()>;
34    fn remove_triple(&mut self, subject: &str, predicate: &str, object: &str) -> Result<()>;
35    fn add_graph(&mut self, graph: &str) -> Result<()>;
36    fn delete_graph(&mut self, graph: &str) -> Result<()>;
37    fn add_prefix(&mut self, prefix: &str, namespace: &str) -> Result<()>;
38    fn remove_prefix(&mut self, prefix: &str) -> Result<()>;
39    fn begin_transaction(&mut self, transaction_id: Option<&str>) -> Result<()>;
40    fn commit_transaction(&mut self) -> Result<()>;
41    fn abort_transaction(&mut self) -> Result<()>;
42    /// Handle a header operation. Headers are metadata and default to a no-op.
43    fn header(&mut self, _key: &str, _value: &str) -> Result<()> {
44        Ok(())
45    }
46}
47
48/// Validate (and optionally dry-run) an RDF Patch **without** persisting it.
49///
50/// This function performs real work — it validates every operation — but it has
51/// no RDF store to mutate. To avoid the fail-loud contract violation of
52/// reporting a fabricated success while the dataset is untouched, it requires
53/// `context.dry_run` to be set. To actually apply a patch, use
54/// [`apply_patch_to_sink`] with a [`PatchSink`], or the store-backed
55/// integration in [`crate::store_integration`].
56pub fn apply_patch_with_context(patch: &RdfPatch, context: &PatchContext) -> Result<PatchResult> {
57    if !context.dry_run {
58        return Err(anyhow!(
59            "apply_patch_with_context cannot persist changes: no RDF store is wired. \
60             Use apply_patch_to_sink() with a PatchSink, or set context.dry_run \
61             for validation-only processing."
62        ));
63    }
64
65    debug!("Performing dry run / validation of patch {}", patch.id);
66
67    let mut result = PatchResult::new();
68    for operation in patch.operations.iter() {
69        if context.validate_operations {
70            validate_operation(operation)?;
71        }
72        result.operations_applied += 1; // Counted as validated for dry run.
73    }
74
75    result.patch_id = patch.id.clone();
76    result.total_operations = patch.operations.len();
77    Ok(result)
78}
79
80/// Validate an RDF Patch without applying it (convenience function).
81///
82/// Uses the default context which has `dry_run = false`, so this returns an
83/// error directing callers to a real sink. It exists to keep the historical
84/// signature; prefer [`apply_patch_to_sink`].
85pub fn apply_patch(patch: &RdfPatch) -> Result<PatchResult> {
86    apply_patch_with_context(patch, &PatchContext::default())
87}
88
89/// Apply RDF Patch operations to a concrete [`PatchSink`].
90///
91/// Every operation is validated (when configured) and then applied to `sink`.
92/// Errors from the sink are recorded in [`PatchResult::errors`]; in
93/// `strict_mode` the first failure aborts with an error. When `context.dry_run`
94/// is set the sink is never touched and operations are only validated.
95pub fn apply_patch_to_sink<S: PatchSink + ?Sized>(
96    patch: &RdfPatch,
97    context: &PatchContext,
98    sink: &mut S,
99) -> Result<PatchResult> {
100    let mut result = PatchResult::new();
101
102    for (i, operation) in patch.operations.iter().enumerate() {
103        if context.validate_operations {
104            validate_operation(operation)?;
105        }
106
107        if context.dry_run {
108            result.operations_applied += 1;
109            continue;
110        }
111
112        match apply_operation(operation, sink) {
113            Ok(_) => {
114                result.operations_applied += 1;
115                debug!("Applied operation {}: {:?}", i, operation);
116            }
117            Err(e) => {
118                result.errors.push(format!("Operation {i}: {e}"));
119                if context.strict_mode {
120                    return Err(anyhow!("Failed to apply operation {}: {}", i, e));
121                }
122            }
123        }
124    }
125
126    result.patch_id = patch.id.clone();
127    result.total_operations = patch.operations.len();
128    Ok(result)
129}
130
131fn validate_operation(operation: &PatchOperation) -> Result<()> {
132    match operation {
133        PatchOperation::Add {
134            subject,
135            predicate,
136            object,
137        }
138        | PatchOperation::Delete {
139            subject,
140            predicate,
141            object,
142        } => {
143            if subject.is_empty() || predicate.is_empty() || object.is_empty() {
144                return Err(anyhow!("Triple operation has empty components"));
145            }
146        }
147        PatchOperation::AddGraph { graph } | PatchOperation::DeleteGraph { graph } => {
148            if graph.is_empty() {
149                return Err(anyhow!("Graph operation has empty graph URI"));
150            }
151        }
152        PatchOperation::AddPrefix {
153            prefix: _,
154            namespace: _,
155        } => {
156            // Prefix operations are always valid
157        }
158        PatchOperation::DeletePrefix { prefix: _ } => {
159            // Prefix operations are always valid
160        }
161        PatchOperation::TransactionBegin { .. } => {
162            // Transaction operations are always valid
163        }
164        PatchOperation::TransactionCommit => {
165            // Transaction operations are always valid
166        }
167        PatchOperation::TransactionAbort => {
168            // Transaction operations are always valid
169        }
170        PatchOperation::Header { .. } => {
171            // Header operations are always valid
172        }
173    }
174    Ok(())
175}
176
177/// Apply a single patch operation to a [`PatchSink`].
178///
179/// Each triple/graph component is validated before being handed to the sink,
180/// and any error returned by the sink is propagated to the caller.
181fn apply_operation<S: PatchSink + ?Sized>(operation: &PatchOperation, sink: &mut S) -> Result<()> {
182    use tracing::warn;
183
184    match operation {
185        PatchOperation::Add {
186            subject,
187            predicate,
188            object,
189        } => {
190            validate_rdf_term(subject, "subject")?;
191            validate_rdf_term(predicate, "predicate")?;
192            validate_rdf_term(object, "object")?;
193            sink.add_triple(subject, predicate, object)?;
194        }
195
196        PatchOperation::Delete {
197            subject,
198            predicate,
199            object,
200        } => {
201            validate_rdf_term(subject, "subject")?;
202            validate_rdf_term(predicate, "predicate")?;
203            validate_rdf_term(object, "object")?;
204            sink.remove_triple(subject, predicate, object)?;
205        }
206
207        PatchOperation::AddGraph { graph } => {
208            validate_rdf_term(graph, "graph")?;
209            sink.add_graph(graph)?;
210        }
211
212        PatchOperation::DeleteGraph { graph } => {
213            validate_rdf_term(graph, "graph")?;
214            sink.delete_graph(graph)?;
215        }
216
217        PatchOperation::AddPrefix { prefix, namespace } => {
218            if prefix.is_empty() {
219                return Err(anyhow!("Prefix name cannot be empty"));
220            }
221            if !namespace.starts_with("http://")
222                && !namespace.starts_with("https://")
223                && !namespace.starts_with("urn:")
224            {
225                warn!(
226                    "Namespace '{}' doesn't follow standard URI scheme",
227                    namespace
228                );
229            }
230            sink.add_prefix(prefix, namespace)?;
231        }
232
233        PatchOperation::DeletePrefix { prefix } => {
234            if prefix.is_empty() {
235                return Err(anyhow!("Prefix name cannot be empty"));
236            }
237            sink.remove_prefix(prefix)?;
238        }
239
240        PatchOperation::TransactionBegin { transaction_id } => {
241            sink.begin_transaction(transaction_id.as_deref())?;
242        }
243
244        PatchOperation::TransactionCommit => {
245            sink.commit_transaction()?;
246        }
247
248        PatchOperation::TransactionAbort => {
249            sink.abort_transaction()?;
250        }
251
252        PatchOperation::Header { key, value } => {
253            if key == "timestamp" && chrono::DateTime::parse_from_rfc3339(value).is_err() {
254                warn!("Invalid timestamp format in header: {}", value);
255            }
256            sink.header(key, value)?;
257        }
258    }
259
260    Ok(())
261}
262
263/// Validate an RDF term (IRI, blank node, or literal)
264fn validate_rdf_term(term: &str, term_type: &str) -> Result<()> {
265    if term.is_empty() {
266        return Err(anyhow!("{} cannot be empty", term_type));
267    }
268
269    // Check for IRI format
270    if term.starts_with('<') && term.ends_with('>') {
271        let iri = &term[1..term.len() - 1];
272        if iri.is_empty() {
273            return Err(anyhow!("Empty IRI in {}", term_type));
274        }
275
276        // Basic IRI validation - should contain valid characters
277        if iri.contains(' ') || iri.contains('\n') || iri.contains('\t') {
278            return Err(anyhow!("Invalid characters in IRI: {}", iri));
279        }
280    }
281    // Check for blank node format
282    else if term.starts_with('_') {
283        if !term.starts_with("_:") {
284            return Err(anyhow!("Invalid blank node format: {}", term));
285        }
286
287        let local_name = &term[2..];
288        if local_name.is_empty() {
289            return Err(anyhow!("Empty blank node local name"));
290        }
291    }
292    // Check for literal format (quoted strings)
293    else if term.starts_with('"') {
294        if !term.ends_with('"') && !term.contains("\"@") && !term.contains("\"^^") {
295            return Err(anyhow!("Invalid literal format: {}", term));
296        }
297    }
298    // Check for prefixed name
299    else if term.contains(':') {
300        let parts: Vec<&str> = term.splitn(2, ':').collect();
301        if parts.len() != 2 {
302            return Err(anyhow!("Invalid prefixed name format: {}", term));
303        }
304
305        let prefix = parts[0];
306        let local_name = parts[1];
307
308        // Prefix should not be empty (unless it's the default prefix)
309        if prefix.is_empty() && local_name.is_empty() {
310            return Err(anyhow!("Invalid prefixed name: {}", term));
311        }
312    }
313    // If none of the above, it might be a relative IRI or invalid
314    else if term_type == "predicate" {
315        // Predicates should always be IRIs or prefixed names
316        return Err(anyhow!(
317            "Predicate must be an IRI or prefixed name: {}",
318            term
319        ));
320    }
321
322    Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    /// In-memory sink used to prove operations really reach the store.
330    #[derive(Default)]
331    struct CollectingSink {
332        triples: Vec<(String, String, String)>,
333        removed: Vec<(String, String, String)>,
334        transactions: Vec<String>,
335    }
336
337    impl PatchSink for CollectingSink {
338        fn add_triple(&mut self, subject: &str, predicate: &str, object: &str) -> Result<()> {
339            self.triples.push((
340                subject.to_string(),
341                predicate.to_string(),
342                object.to_string(),
343            ));
344            Ok(())
345        }
346        fn remove_triple(&mut self, subject: &str, predicate: &str, object: &str) -> Result<()> {
347            self.removed.push((
348                subject.to_string(),
349                predicate.to_string(),
350                object.to_string(),
351            ));
352            Ok(())
353        }
354        fn add_graph(&mut self, _graph: &str) -> Result<()> {
355            Ok(())
356        }
357        fn delete_graph(&mut self, _graph: &str) -> Result<()> {
358            Ok(())
359        }
360        fn add_prefix(&mut self, _prefix: &str, _namespace: &str) -> Result<()> {
361            Ok(())
362        }
363        fn remove_prefix(&mut self, _prefix: &str) -> Result<()> {
364            Ok(())
365        }
366        fn begin_transaction(&mut self, transaction_id: Option<&str>) -> Result<()> {
367            self.transactions
368                .push(format!("begin:{}", transaction_id.unwrap_or("auto")));
369            Ok(())
370        }
371        fn commit_transaction(&mut self) -> Result<()> {
372            self.transactions.push("commit".to_string());
373            Ok(())
374        }
375        fn abort_transaction(&mut self) -> Result<()> {
376            self.transactions.push("abort".to_string());
377            Ok(())
378        }
379    }
380
381    #[test]
382    fn regression_apply_patch_to_sink_actually_mutates() {
383        let mut patch = RdfPatch::new();
384        patch.add_operation(PatchOperation::Add {
385            subject: "http://example.org/s".to_string(),
386            predicate: "http://example.org/p".to_string(),
387            object: "http://example.org/o".to_string(),
388        });
389        patch.add_operation(PatchOperation::Delete {
390            subject: "http://example.org/s".to_string(),
391            predicate: "http://example.org/p".to_string(),
392            object: "http://example.org/old".to_string(),
393        });
394
395        let mut sink = CollectingSink::default();
396        let result = apply_patch_to_sink(&patch, &PatchContext::default(), &mut sink).unwrap();
397
398        assert_eq!(result.operations_applied, 2);
399        assert_eq!(sink.triples.len(), 1);
400        assert_eq!(sink.removed.len(), 1);
401        assert_eq!(sink.triples[0].0, "http://example.org/s");
402    }
403
404    #[test]
405    fn regression_storeless_apply_is_fail_loud() {
406        let mut patch = RdfPatch::new();
407        patch.add_operation(PatchOperation::Add {
408            subject: "http://example.org/s".to_string(),
409            predicate: "http://example.org/p".to_string(),
410            object: "http://example.org/o".to_string(),
411        });
412
413        // Non-dry-run without a store must error rather than fabricate success.
414        let ctx = PatchContext {
415            strict_mode: false,
416            validate_operations: true,
417            dry_run: false,
418        };
419        assert!(apply_patch_with_context(&patch, &ctx).is_err());
420
421        // Dry run validates and reports success without a store.
422        let dry = PatchContext {
423            dry_run: true,
424            ..Default::default()
425        };
426        let result = apply_patch_with_context(&patch, &dry).unwrap();
427        assert_eq!(result.operations_applied, 1);
428    }
429}