Skip to main content

oxirs_stream/patch/
normalizer.rs

1//! Patch normalizer
2
3use crate::{PatchOperation, RdfPatch};
4use anyhow::Result;
5use std::collections::BTreeSet;
6use tracing::info;
7
8pub struct PatchNormalizer {
9    canonical_ordering: bool,
10    deduplicate_operations: bool,
11    normalize_uris: bool,
12    sort_by_subject: bool,
13}
14
15impl PatchNormalizer {
16    pub fn new() -> Self {
17        Self {
18            canonical_ordering: true,
19            deduplicate_operations: true,
20            normalize_uris: true,
21            sort_by_subject: true,
22        }
23    }
24
25    pub fn with_canonical_ordering(mut self, enabled: bool) -> Self {
26        self.canonical_ordering = enabled;
27        self
28    }
29
30    pub fn with_deduplication(mut self, enabled: bool) -> Self {
31        self.deduplicate_operations = enabled;
32        self
33    }
34
35    pub fn with_uri_normalization(mut self, enabled: bool) -> Self {
36        self.normalize_uris = enabled;
37        self
38    }
39
40    /// Enable or disable sorting triple operations by subject.
41    ///
42    /// Sorting is applied only *within* contiguous runs of triple operations so
43    /// that transaction boundaries and other structural operations are never
44    /// reordered.
45    pub fn with_subject_sort(mut self, enabled: bool) -> Self {
46        self.sort_by_subject = enabled;
47        self
48    }
49
50    /// Normalize a patch according to configured rules
51    pub fn normalize(&self, patch: &RdfPatch) -> Result<RdfPatch> {
52        let mut normalized = patch.clone();
53        normalized.id = format!("{}-normalized", patch.id);
54
55        // Step 1: Normalize URIs
56        if self.normalize_uris {
57            normalized = self.normalize_uris_in_patch(normalized)?;
58        }
59
60        // Step 2: Deduplicate operations
61        if self.deduplicate_operations {
62            normalized = self.deduplicate_operations_in_patch(normalized)?;
63        }
64
65        // Step 3: Canonical ordering
66        if self.canonical_ordering {
67            normalized = self.apply_canonical_ordering(normalized)?;
68        }
69
70        // Step 4: Sort by subject if enabled
71        if self.sort_by_subject {
72            normalized = self.sort_operations_by_subject(normalized)?;
73        }
74
75        info!(
76            "Normalized patch: {} -> {} operations",
77            patch.operations.len(),
78            normalized.operations.len()
79        );
80        Ok(normalized)
81    }
82
83    fn normalize_uris_in_patch(&self, mut patch: RdfPatch) -> Result<RdfPatch> {
84        for operation in &mut patch.operations {
85            match operation {
86                PatchOperation::Add {
87                    subject,
88                    predicate,
89                    object,
90                } => {
91                    *subject = self.normalize_uri(subject);
92                    *predicate = self.normalize_uri(predicate);
93                    *object = self.normalize_uri(object);
94                }
95                PatchOperation::Delete {
96                    subject,
97                    predicate,
98                    object,
99                } => {
100                    *subject = self.normalize_uri(subject);
101                    *predicate = self.normalize_uri(predicate);
102                    *object = self.normalize_uri(object);
103                }
104                PatchOperation::AddGraph { graph } => {
105                    *graph = self.normalize_uri(graph);
106                }
107                PatchOperation::DeleteGraph { graph } => {
108                    *graph = self.normalize_uri(graph);
109                }
110                _ => {} // Other operations don't have URIs to normalize
111            }
112        }
113        Ok(patch)
114    }
115
116    fn normalize_uri(&self, uri: &str) -> String {
117        // Remove trailing slashes, normalize case, etc.
118        let mut normalized = uri.trim_end_matches('/').to_string();
119
120        // Convert to lowercase for schemes
121        if normalized.starts_with("http://") || normalized.starts_with("https://") {
122            if let Some(pos) = normalized.find("://") {
123                let (scheme, rest) = normalized.split_at(pos + 3);
124                if let Some(domain_end) = rest.find('/') {
125                    let (domain, path) = rest.split_at(domain_end);
126                    normalized =
127                        format!("{}{}{}", scheme.to_lowercase(), domain.to_lowercase(), path);
128                } else {
129                    normalized = format!("{}{}", scheme.to_lowercase(), rest.to_lowercase());
130                }
131            }
132        }
133
134        normalized
135    }
136
137    fn deduplicate_operations_in_patch(&self, mut patch: RdfPatch) -> Result<RdfPatch> {
138        let mut seen = BTreeSet::new();
139        patch.operations.retain(|op| {
140            let key = format!("{op:?}");
141            if seen.contains(&key) {
142                false
143            } else {
144                seen.insert(key);
145                true
146            }
147        });
148        Ok(patch)
149    }
150
151    fn apply_canonical_ordering(&self, mut patch: RdfPatch) -> Result<RdfPatch> {
152        // Group operations by type and apply canonical ordering within each group
153        let mut headers = Vec::new();
154        let mut prefixes = Vec::new();
155        let mut tx_begin = Vec::new();
156        let mut adds = Vec::new();
157        let mut deletes = Vec::new();
158        let mut graphs = Vec::new();
159        let mut tx_end = Vec::new();
160
161        for operation in &patch.operations {
162            match operation {
163                PatchOperation::Header { .. } => headers.push(operation.clone()),
164                PatchOperation::AddPrefix { .. } | PatchOperation::DeletePrefix { .. } => {
165                    prefixes.push(operation.clone())
166                }
167                PatchOperation::TransactionBegin { .. } => tx_begin.push(operation.clone()),
168                PatchOperation::Add { .. } => adds.push(operation.clone()),
169                PatchOperation::Delete { .. } => deletes.push(operation.clone()),
170                PatchOperation::AddGraph { .. } | PatchOperation::DeleteGraph { .. } => {
171                    graphs.push(operation.clone())
172                }
173                PatchOperation::TransactionCommit | PatchOperation::TransactionAbort => {
174                    tx_end.push(operation.clone())
175                }
176            }
177        }
178
179        // Rebuild operations in canonical order
180        patch.operations.clear();
181        patch.operations.extend(headers);
182        patch.operations.extend(prefixes);
183        patch.operations.extend(tx_begin);
184        patch.operations.extend(graphs);
185        patch.operations.extend(deletes); // Deletes before adds
186        patch.operations.extend(adds);
187        patch.operations.extend(tx_end);
188
189        Ok(patch)
190    }
191
192    fn sort_operations_by_subject(&self, mut patch: RdfPatch) -> Result<RdfPatch> {
193        // Sort triple (Add/Delete) operations by subject, but ONLY within
194        // contiguous runs of triple operations. A single global `sort_by` would
195        // move structural operations such as TransactionCommit/TransactionAbort
196        // (whose `extract_subject` is the empty string, which sorts before any
197        // URI) ahead of the very operations they are meant to enclose. Sorting
198        // within runs preserves the canonical grouping and all transaction
199        // boundaries produced by `apply_canonical_ordering`.
200        let ops = &mut patch.operations;
201        let mut i = 0;
202        while i < ops.len() {
203            if Self::is_triple_operation(&ops[i]) {
204                let run_start = i;
205                while i < ops.len() && Self::is_triple_operation(&ops[i]) {
206                    i += 1;
207                }
208                // Sort the contiguous run [run_start, i). Keep deletes before
209                // adds (matching canonical ordering), then order by subject.
210                ops[run_start..i].sort_by(|a, b| {
211                    let key_a = (Self::triple_kind_order(a), Self::extract_subject(a));
212                    let key_b = (Self::triple_kind_order(b), Self::extract_subject(b));
213                    key_a.cmp(&key_b)
214                });
215            } else {
216                i += 1;
217            }
218        }
219
220        Ok(patch)
221    }
222
223    /// Whether an operation is a triple-level Add/Delete (the only operations
224    /// that participate in subject sorting).
225    fn is_triple_operation(operation: &PatchOperation) -> bool {
226        matches!(
227            operation,
228            PatchOperation::Add { .. } | PatchOperation::Delete { .. }
229        )
230    }
231
232    /// Ordering key that keeps deletes before adds within a run.
233    fn triple_kind_order(operation: &PatchOperation) -> u8 {
234        match operation {
235            PatchOperation::Delete { .. } => 0,
236            PatchOperation::Add { .. } => 1,
237            _ => 2,
238        }
239    }
240
241    fn extract_subject(operation: &PatchOperation) -> String {
242        match operation {
243            PatchOperation::Add { subject, .. } | PatchOperation::Delete { subject, .. } => {
244                subject.clone()
245            }
246            _ => String::new(),
247        }
248    }
249}
250
251impl Default for PatchNormalizer {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn regression_normalize_keeps_transaction_commit_last() {
263        let mut patch = RdfPatch::new();
264        patch.add_operation(PatchOperation::TransactionBegin {
265            transaction_id: Some("tx-1".to_string()),
266        });
267        patch.add_operation(PatchOperation::Add {
268            subject: "http://example.org/s1".to_string(),
269            predicate: "http://example.org/p".to_string(),
270            object: "http://example.org/o".to_string(),
271        });
272        patch.add_operation(PatchOperation::TransactionCommit);
273
274        let normalizer = PatchNormalizer::new();
275        let normalized = normalizer.normalize(&patch).unwrap();
276
277        // TransactionBegin must come first, TransactionCommit must come last,
278        // and the Add must sit strictly between them (never before the begin or
279        // after the commit).
280        let ops = &normalized.operations;
281        assert!(matches!(
282            ops.first(),
283            Some(PatchOperation::TransactionBegin { .. })
284        ));
285        assert!(matches!(
286            ops.last(),
287            Some(PatchOperation::TransactionCommit)
288        ));
289        let add_pos = ops
290            .iter()
291            .position(|op| matches!(op, PatchOperation::Add { .. }))
292            .expect("Add operation should be present");
293        assert!(add_pos > 0 && add_pos < ops.len() - 1);
294    }
295
296    #[test]
297    fn regression_subject_sort_within_run() {
298        let mut patch = RdfPatch::new();
299        for subject in [
300            "http://example.org/s3",
301            "http://example.org/s1",
302            "http://example.org/s2",
303        ] {
304            patch.add_operation(PatchOperation::Add {
305                subject: subject.to_string(),
306                predicate: "http://example.org/p".to_string(),
307                object: "http://example.org/o".to_string(),
308            });
309        }
310
311        let normalized = PatchNormalizer::new().normalize(&patch).unwrap();
312        let subjects: Vec<String> = normalized
313            .operations
314            .iter()
315            .filter_map(|op| match op {
316                PatchOperation::Add { subject, .. } => Some(subject.clone()),
317                _ => None,
318            })
319            .collect();
320
321        assert_eq!(
322            subjects,
323            vec![
324                "http://example.org/s1".to_string(),
325                "http://example.org/s2".to_string(),
326                "http://example.org/s3".to_string(),
327            ]
328        );
329    }
330}