Skip to main content

gen_diff/
operations.rs

1use std::collections::{HashMap, HashSet};
2
3use gen_core::{HashId, Workspace};
4use gen_models::{
5    block_group::BlockGroup, changesets::ChangesetModels, db::OperationsConnection,
6    errors::OperationError, operations::Operation, session_operations::DependencyModels,
7    traits::Query,
8};
9use petgraph::Direction;
10use thiserror::Error;
11
12use crate::graph::{DiffGenGraph, get_diff_graph};
13
14#[derive(Debug, Error)]
15pub enum OperationDiffError {
16    #[error("No current operation is checked out.")]
17    NoCurrentOperation,
18    #[error("Operation {0} not found.")]
19    OperationMissing(HashId),
20    #[error("Unable to find path between {0} and {1}.")]
21    PathNotFound(HashId, HashId),
22    #[error("Missing changeset data for operation {0}.")]
23    MissingChangeset(HashId),
24    #[error(transparent)]
25    OperationError(#[from] OperationError),
26}
27
28#[derive(Clone, Debug)]
29pub struct BlockGroupDiff {
30    pub id: HashId,
31    pub block_group: Option<BlockGroup>,
32    pub graph: DiffGenGraph,
33}
34
35#[derive(Clone, Debug)]
36pub struct OperationDiff {
37    pub operations: Vec<HashId>,
38    pub dbs: HashMap<String, DbDiff>,
39}
40
41#[derive(Clone, Debug)]
42pub struct DbDiff {
43    pub db_path: String,
44    pub added_block_groups: Vec<BlockGroupDiff>,
45    pub removed_block_groups: Vec<BlockGroupDiff>,
46}
47
48#[derive(Clone, Debug, Default)]
49pub struct BlockGroupDiffs {
50    pub operations: Vec<HashId>,
51    pub block_group_diffs: Vec<BlockGroupDiff>,
52}
53
54fn build_operation_diffs(
55    operations_in_order: &[HashId],
56    added_graphs: &HashMap<String, BlockGroupDiffs>,
57    removed_graphs: &HashMap<String, BlockGroupDiffs>,
58) -> HashMap<String, OperationDiff> {
59    let mut db_paths = HashSet::new();
60    db_paths.extend(added_graphs.keys().cloned());
61    db_paths.extend(removed_graphs.keys().cloned());
62
63    let mut diffs = HashMap::new();
64    for db_path in db_paths {
65        let mut op_set = HashSet::new();
66        if let Some(diffs) = added_graphs.get(&db_path) {
67            op_set.extend(diffs.operations.iter().copied());
68        }
69        if let Some(diffs) = removed_graphs.get(&db_path) {
70            op_set.extend(diffs.operations.iter().copied());
71        }
72        let operations = operations_in_order
73            .iter()
74            .copied()
75            .filter(|hash| op_set.contains(hash))
76            .collect::<Vec<_>>();
77        let db_diff = DbDiff {
78            db_path: db_path.clone(),
79            added_block_groups: added_graphs
80                .get(&db_path)
81                .map(|diffs| diffs.block_group_diffs.clone())
82                .unwrap_or_default(),
83            removed_block_groups: removed_graphs
84                .get(&db_path)
85                .map(|diffs| diffs.block_group_diffs.clone())
86                .unwrap_or_default(),
87        };
88        let mut dbs = HashMap::new();
89        dbs.insert(db_path.clone(), db_diff);
90        diffs.insert(db_path, OperationDiff { operations, dbs });
91    }
92
93    diffs
94}
95
96pub fn collect_operation_diff(
97    workspace: &Workspace,
98    op_conn: &OperationsConnection,
99    from_hash: Option<HashId>,
100    to_hash: HashId,
101    db_path: Option<&str>,
102) -> Result<HashMap<String, OperationDiff>, OperationDiffError> {
103    let (operations_in_order, added_ops, removed_ops) = if let Some(from_hash) = from_hash {
104        if from_hash == to_hash {
105            return Ok(HashMap::new());
106        }
107
108        let path = Operation::get_path_between(op_conn, from_hash, to_hash);
109        if path.is_empty() {
110            return Err(OperationDiffError::PathNotFound(from_hash, to_hash));
111        }
112
113        let mut operations_in_order = vec![];
114        let mut added_ops = vec![];
115        let mut removed_ops = vec![];
116        for (src, direction, dest) in path {
117            let op_hash = match direction {
118                Direction::Outgoing => dest,
119                Direction::Incoming => src,
120            };
121            operations_in_order.push(op_hash);
122            match direction {
123                Direction::Outgoing => {
124                    added_ops.push(op_hash);
125                }
126                Direction::Incoming => {
127                    removed_ops.push(op_hash);
128                }
129            }
130        }
131
132        (operations_in_order, added_ops, removed_ops)
133    } else {
134        (vec![to_hash], vec![to_hash], vec![])
135    };
136
137    let added_graphs = build_block_group_diffs(workspace, op_conn, &added_ops, db_path)?;
138    let removed_graphs = build_block_group_diffs(workspace, op_conn, &removed_ops, db_path)?;
139
140    Ok(build_operation_diffs(
141        &operations_in_order,
142        &added_graphs,
143        &removed_graphs,
144    ))
145}
146
147/// The idea here is to build a merged changeset from the changesets of operations. This changeset is then fed into
148/// the machinery for rendering single operation changesets. If multiple operations are passed in, this is a merged
149/// changeset that represents the combined effect of all the operations.
150fn build_block_group_diffs(
151    workspace: &Workspace,
152    op_conn: &OperationsConnection,
153    operations: &[HashId],
154    db_path: Option<&str>,
155) -> Result<HashMap<String, BlockGroupDiffs>, OperationDiffError> {
156    if operations.is_empty() {
157        return Ok(HashMap::new());
158    }
159
160    #[derive(Default)]
161    struct DbAccumulator {
162        operations: Vec<HashId>,
163        block_group_info: HashMap<HashId, BlockGroup>,
164        block_groups: HashSet<gen_models::block_group::BlockGroup>,
165        edges: HashSet<gen_models::edge::Edge>,
166        block_group_edges: HashSet<gen_models::block_group_edge::BlockGroupEdge>,
167        nodes: HashSet<gen_models::node::Node>,
168        sequences: HashSet<gen_models::sequence::Sequence>,
169        dep_edges: HashSet<gen_models::edge::Edge>,
170        dep_nodes: HashSet<gen_models::node::Node>,
171        dep_sequences: HashSet<gen_models::sequence::Sequence>,
172    }
173
174    let mut accumulators: HashMap<String, DbAccumulator> = HashMap::new();
175
176    for op_hash in operations {
177        let operation = Operation::get_by_id(op_conn, op_hash)
178            .ok_or_else(|| OperationDiffError::OperationMissing(*op_hash))?;
179        let changeset = operation.get_changeset(workspace);
180        if let Some(db_path) = db_path
181            && changeset.db_path != db_path
182        {
183            continue;
184        }
185        let changeset_db = changeset.db_path.clone();
186        let changeset = changeset.changes;
187        let dependencies = operation.get_changeset_dependencies(workspace);
188
189        let entry = accumulators.entry(changeset_db).or_default();
190        entry.operations.push(*op_hash);
191
192        for block_group in changeset
193            .block_groups
194            .iter()
195            .chain(dependencies.block_group.iter())
196        {
197            entry
198                .block_group_info
199                .entry(block_group.id)
200                .or_insert_with(|| block_group.clone());
201        }
202
203        entry.block_groups.extend(changeset.block_groups);
204        entry.edges.extend(changeset.edges);
205        entry.block_group_edges.extend(changeset.block_group_edges);
206        entry.nodes.extend(changeset.nodes);
207        entry.sequences.extend(changeset.sequences);
208        entry.dep_edges.extend(dependencies.edges);
209        entry.dep_nodes.extend(dependencies.nodes);
210        entry.dep_sequences.extend(dependencies.sequences);
211    }
212
213    let mut results = HashMap::new();
214    for (db_path, acc) in accumulators {
215        let merged_graphs = get_diff_graph(
216            &ChangesetModels {
217                block_groups: acc.block_groups.into_iter().collect(),
218                edges: acc.edges.into_iter().collect(),
219                block_group_edges: acc.block_group_edges.into_iter().collect(),
220                nodes: acc.nodes.into_iter().collect(),
221                sequences: acc.sequences.into_iter().collect(),
222                ..Default::default()
223            },
224            &DependencyModels {
225                edges: acc.dep_edges.into_iter().collect(),
226                nodes: acc.dep_nodes.into_iter().collect(),
227                sequences: acc.dep_sequences.into_iter().collect(),
228                ..Default::default()
229            },
230        );
231
232        let mut block_groups = merged_graphs
233            .into_iter()
234            .map(|(id, graph)| {
235                let block_group = acc.block_group_info.get(&id).cloned();
236                BlockGroupDiff {
237                    id,
238                    block_group,
239                    graph,
240                }
241            })
242            .collect::<Vec<_>>();
243        block_groups.sort_by_key(|a| {
244            if let Some(bg) = &a.block_group {
245                (
246                    bg.collection_name.clone(),
247                    bg.sample_name
248                        .clone()
249                        .unwrap_or_else(|| "Reference".to_string()),
250                    bg.name.clone(),
251                    format!("{id}", id = a.id),
252                )
253            } else {
254                (
255                    String::new(),
256                    String::new(),
257                    String::new(),
258                    format!("{id}", id = a.id),
259                )
260            }
261        });
262        results.insert(
263            db_path,
264            BlockGroupDiffs {
265                operations: acc.operations,
266                block_group_diffs: block_groups,
267            },
268        );
269    }
270
271    Ok(results)
272}
273
274#[cfg(test)]
275mod tests {
276    use gen_core::{HashId, Strand};
277    use gen_models::{
278        block_group::BlockGroup,
279        block_group_edge::BlockGroupEdge,
280        changesets::{ChangesetModels, DatabaseChangeset, write_changeset},
281        edge::Edge,
282        node::Node,
283        operations::{Branch, Operation, OperationState},
284        sequence::{NewSequence, Sequence},
285    };
286
287    use super::*;
288    use crate::test_helpers::setup_gen;
289
290    fn get_db_diff<'a>(diffs: &'a HashMap<String, OperationDiff>, db_path: &str) -> &'a DbDiff {
291        diffs
292            .get(db_path)
293            .and_then(|diff| diff.dbs.get(db_path))
294            .expect("db diff")
295    }
296
297    fn base_dependencies(start_node: &Node, end_node: &Node) -> DependencyModels {
298        let mut start_sequence = Sequence::new()
299            .sequence_type("DNA")
300            .sequence("")
301            .name("start")
302            .build();
303        start_sequence.hash = start_node.sequence_hash;
304        let mut end_sequence = Sequence::new()
305            .sequence_type("DNA")
306            .sequence("")
307            .name("end")
308            .build();
309        end_sequence.hash = end_node.sequence_hash;
310        DependencyModels {
311            collections: vec![],
312            samples: vec![],
313            sequences: vec![start_sequence, end_sequence],
314            block_group: vec![],
315            nodes: vec![start_node.clone(), end_node.clone()],
316            edges: vec![],
317            paths: vec![],
318            accessions: vec![],
319            accession_edges: vec![],
320        }
321    }
322
323    fn simple_changeset(
324        block_group: &BlockGroup,
325        node: &Node,
326        seq: &Sequence,
327        start_node: &Node,
328        end_node: &Node,
329    ) -> (ChangesetModels, DependencyModels) {
330        let edges = vec![
331            Edge {
332                id: HashId::convert_str(&format!("{}-{}-start", block_group.id, node.id)),
333                source_node_id: start_node.id,
334                source_coordinate: 0,
335                source_strand: Strand::Forward,
336                target_node_id: node.id,
337                target_coordinate: 0,
338                target_strand: Strand::Forward,
339            },
340            Edge {
341                id: HashId::convert_str(&format!("{}-{}-end", block_group.id, node.id)),
342                source_node_id: node.id,
343                source_coordinate: seq.length,
344                source_strand: Strand::Forward,
345                target_node_id: end_node.id,
346                target_coordinate: 0,
347                target_strand: Strand::Forward,
348            },
349        ];
350        let block_group_edges = vec![
351            BlockGroupEdge {
352                id: HashId::convert_str(&format!("{}-{}-start-bge", block_group.id, node.id)),
353                block_group_id: block_group.id,
354                edge_id: edges[0].id,
355                chromosome_index: 0,
356                phased: 0,
357                created_on: 0,
358            },
359            BlockGroupEdge {
360                id: HashId::convert_str(&format!("{}-{}-end-bge", block_group.id, node.id)),
361                block_group_id: block_group.id,
362                edge_id: edges[1].id,
363                chromosome_index: 0,
364                phased: 0,
365                created_on: 0,
366            },
367        ];
368        let changeset = ChangesetModels {
369            collections: vec![],
370            samples: vec![],
371            sequences: vec![seq.clone()],
372            block_groups: vec![block_group.clone()],
373            nodes: vec![node.clone()],
374            edges,
375            block_group_edges,
376            paths: vec![],
377            path_edges: vec![],
378            accessions: vec![],
379            accession_edges: vec![],
380            accession_paths: vec![],
381            annotation_groups: vec![],
382            annotations: vec![],
383            annotation_group_samples: vec![],
384        };
385        let dependencies = base_dependencies(start_node, end_node);
386        (changeset, dependencies)
387    }
388
389    #[test]
390    fn one_operation_diff() {
391        let context = setup_gen();
392        let op_conn = context.operations().conn();
393        let workspace = context.workspace();
394        let start_node = Node::get_start_node();
395        let end_node = Node::get_end_node();
396
397        let base_op =
398            Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("create base op");
399
400        let seq_one = NewSequence::new()
401            .sequence_type("dna")
402            .sequence("AAAAA")
403            .name("one")
404            .build();
405        let node_one = Node {
406            id: HashId::pad_str(10),
407            sequence_hash: seq_one.hash,
408        };
409        let block_group = BlockGroup {
410            id: HashId::pad_str(3),
411            collection_name: "c".to_string(),
412            sample_name: Some("s".to_string()),
413            name: "bg".to_string(),
414            created_on: 0,
415        };
416
417        let head = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op");
418        let (changeset, dependencies) =
419            simple_changeset(&block_group, &node_one, &seq_one, &start_node, &end_node);
420        write_changeset(
421            workspace,
422            &head,
423            DatabaseChangeset {
424                db_path: "diff.db".to_string(),
425                changes: changeset,
426            },
427            &dependencies,
428        );
429
430        let diffs = collect_operation_diff(workspace, op_conn, Some(base_op.hash), head.hash, None)
431            .expect("diff");
432        let diff = diffs.get("diff.db").expect("diff db");
433        let db_diff = get_db_diff(&diffs, "diff.db");
434        assert_eq!(diff.operations, vec![head.hash]);
435        assert_eq!(db_diff.added_block_groups.len(), 1);
436        assert!(db_diff.removed_block_groups.is_empty());
437        let graph = &db_diff.added_block_groups[0].graph;
438        assert_eq!(graph.nodes().count(), 3);
439        assert_eq!(graph.all_edges().count(), 2);
440    }
441
442    #[test]
443    fn initial_operation_diff_contains_added_block_groups() {
444        let context = setup_gen();
445        let op_conn = context.operations().conn();
446        let workspace = context.workspace();
447        let start_node = Node::get_start_node();
448        let end_node = Node::get_end_node();
449
450        let seq_one = NewSequence::new()
451            .sequence_type("dna")
452            .sequence("AAAAA")
453            .name("one")
454            .build();
455        let node_one = Node {
456            id: HashId::pad_str(10),
457            sequence_hash: seq_one.hash,
458        };
459        let block_group = BlockGroup {
460            id: HashId::pad_str(3),
461            collection_name: "c".to_string(),
462            sample_name: Some("s".to_string()),
463            name: "bg".to_string(),
464            created_on: 0,
465        };
466
467        let head = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op");
468        let (changeset, dependencies) =
469            simple_changeset(&block_group, &node_one, &seq_one, &start_node, &end_node);
470        write_changeset(
471            workspace,
472            &head,
473            DatabaseChangeset {
474                db_path: "diff.db".to_string(),
475                changes: changeset,
476            },
477            &dependencies,
478        );
479
480        let diffs =
481            collect_operation_diff(workspace, op_conn, None, head.hash, None).expect("diff");
482        let diff = diffs.get("diff.db").expect("diff db");
483        let db_diff = get_db_diff(&diffs, "diff.db");
484        assert_eq!(diff.operations, vec![head.hash]);
485        assert_eq!(db_diff.added_block_groups.len(), 1);
486        assert!(db_diff.removed_block_groups.is_empty());
487        let graph = &db_diff.added_block_groups[0].graph;
488        assert_eq!(graph.nodes().count(), 3);
489        assert_eq!(graph.all_edges().count(), 2);
490    }
491
492    #[test]
493    fn merges_multiple_operations() {
494        let context = setup_gen();
495        let op_conn = context.operations().conn();
496        let workspace = context.workspace();
497        let start_node = Node::get_start_node();
498        let end_node = Node::get_end_node();
499
500        let op1 = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("create base op");
501
502        let bg_one = BlockGroup {
503            id: HashId::pad_str(3),
504            collection_name: "c".to_string(),
505            sample_name: Some("s".to_string()),
506            name: "bg1".to_string(),
507            created_on: 0,
508        };
509        let seq_one = NewSequence::new()
510            .sequence_type("dna")
511            .sequence("AAAAA")
512            .name("one")
513            .build();
514        let node_one = Node {
515            id: HashId::pad_str(10),
516            sequence_hash: seq_one.hash,
517        };
518        let op2 = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op2");
519        let (changeset_one, dependencies_one) =
520            simple_changeset(&bg_one, &node_one, &seq_one, &start_node, &end_node);
521        write_changeset(
522            workspace,
523            &op2,
524            DatabaseChangeset {
525                db_path: "diff.db".to_string(),
526                changes: changeset_one,
527            },
528            &dependencies_one,
529        );
530
531        let bg_two = BlockGroup {
532            id: HashId::pad_str(4),
533            collection_name: "c".to_string(),
534            sample_name: Some("s".to_string()),
535            name: "bg2".to_string(),
536            created_on: 0,
537        };
538        let seq_two = NewSequence::new()
539            .sequence_type("dna")
540            .sequence("CCCCC")
541            .name("two")
542            .build();
543        let node_two = Node {
544            id: HashId::pad_str(11),
545            sequence_hash: seq_two.hash,
546        };
547        let op3 = Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("create op3");
548        let (changeset_two, dependencies_two) =
549            simple_changeset(&bg_two, &node_two, &seq_two, &start_node, &end_node);
550        write_changeset(
551            workspace,
552            &op3,
553            DatabaseChangeset {
554                db_path: "diff.db".to_string(),
555                changes: changeset_two,
556            },
557            &dependencies_two,
558        );
559
560        let diffs = collect_operation_diff(workspace, op_conn, Some(op1.hash), op3.hash, None)
561            .expect("diff");
562        let diff = diffs.get("diff.db").expect("diff db");
563        let db_diff = get_db_diff(&diffs, "diff.db");
564        assert_eq!(diff.operations, vec![op2.hash, op3.hash]);
565        assert_eq!(db_diff.added_block_groups.len(), 2);
566    }
567
568    #[test]
569    fn diff_against_itself_is_empty() {
570        let context = setup_gen();
571        let op_conn = context.operations().conn();
572        let workspace = context.workspace();
573        let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("create base op");
574        let diffs = collect_operation_diff(workspace, op_conn, Some(base.hash), base.hash, None)
575            .expect("diff");
576        assert!(diffs.is_empty());
577    }
578
579    #[test]
580    fn diffs_across_branches() {
581        let context = setup_gen();
582        let op_conn = context.operations().conn();
583        let workspace = context.workspace();
584        let start_node = Node::get_start_node();
585        let end_node = Node::get_end_node();
586
587        let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("base op");
588
589        let main_block_group = BlockGroup {
590            id: HashId::pad_str(20),
591            collection_name: "c".to_string(),
592            sample_name: Some("s".to_string()),
593            name: "main".to_string(),
594            created_on: 0,
595        };
596        let main_seq = NewSequence::new()
597            .sequence_type("dna")
598            .sequence("AAAAA")
599            .name("main")
600            .build();
601        let main_node = Node {
602            id: HashId::pad_str(21),
603            sequence_hash: main_seq.hash,
604        };
605        let op_main = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("main op");
606        let (main_changeset, main_deps) = simple_changeset(
607            &main_block_group,
608            &main_node,
609            &main_seq,
610            &start_node,
611            &end_node,
612        );
613        write_changeset(
614            workspace,
615            &op_main,
616            DatabaseChangeset {
617                db_path: "diff.db".to_string(),
618                changes: main_changeset,
619            },
620            &main_deps,
621        );
622
623        let feature_branch = Branch::create_with_remote(op_conn, "feature", None).unwrap();
624        OperationState::set_branch(op_conn, &feature_branch.name);
625        OperationState::set_operation(op_conn, &base.hash);
626
627        let feature_block_group = BlockGroup {
628            id: HashId::pad_str(30),
629            collection_name: "c".to_string(),
630            sample_name: Some("s".to_string()),
631            name: "feature".to_string(),
632            created_on: 0,
633        };
634        let feature_seq = NewSequence::new()
635            .sequence_type("dna")
636            .sequence("CCCCC")
637            .name("feature")
638            .build();
639        let feature_node = Node {
640            id: HashId::pad_str(31),
641            sequence_hash: feature_seq.hash,
642        };
643        let op_feature =
644            Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("feature op");
645        let (feature_changeset, feature_deps) = simple_changeset(
646            &feature_block_group,
647            &feature_node,
648            &feature_seq,
649            &start_node,
650            &end_node,
651        );
652        write_changeset(
653            workspace,
654            &op_feature,
655            DatabaseChangeset {
656                db_path: "diff.db".to_string(),
657                changes: feature_changeset,
658            },
659            &feature_deps,
660        );
661
662        let diffs = collect_operation_diff(
663            workspace,
664            op_conn,
665            Some(op_main.hash),
666            op_feature.hash,
667            None,
668        )
669        .expect("diff");
670        let diff = diffs.get("diff.db").expect("diff db");
671        let db_diff = get_db_diff(&diffs, "diff.db");
672        assert_eq!(diff.operations, vec![op_main.hash, op_feature.hash]);
673        assert_eq!(db_diff.added_block_groups.len(), 1);
674        assert_eq!(db_diff.removed_block_groups.len(), 1);
675        assert_eq!(db_diff.added_block_groups[0].id, feature_block_group.id);
676        assert_eq!(db_diff.removed_block_groups[0].id, main_block_group.id);
677    }
678
679    #[test]
680    fn filters_by_database_path() {
681        let context = setup_gen();
682        let op_conn = context.operations().conn();
683        let workspace = context.workspace();
684        let start_node = Node::get_start_node();
685        let end_node = Node::get_end_node();
686
687        let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("base op");
688
689        let block_group_one = BlockGroup {
690            id: HashId::pad_str(40),
691            collection_name: "c".to_string(),
692            sample_name: Some("s".to_string()),
693            name: "db-one".to_string(),
694            created_on: 0,
695        };
696        let seq_one = NewSequence::new()
697            .sequence_type("dna")
698            .sequence("AAAAA")
699            .name("one")
700            .build();
701        let node_one = Node {
702            id: HashId::pad_str(41),
703            sequence_hash: seq_one.hash,
704        };
705        let op_one = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("op one");
706        let (changeset_one, deps_one) = simple_changeset(
707            &block_group_one,
708            &node_one,
709            &seq_one,
710            &start_node,
711            &end_node,
712        );
713        write_changeset(
714            workspace,
715            &op_one,
716            DatabaseChangeset {
717                db_path: "db-one.db".to_string(),
718                changes: changeset_one,
719            },
720            &deps_one,
721        );
722
723        let block_group_two = BlockGroup {
724            id: HashId::pad_str(50),
725            collection_name: "c".to_string(),
726            sample_name: Some("s".to_string()),
727            name: "db-two".to_string(),
728            created_on: 0,
729        };
730        let seq_two = NewSequence::new()
731            .sequence_type("dna")
732            .sequence("CCCCC")
733            .name("two")
734            .build();
735        let node_two = Node {
736            id: HashId::pad_str(51),
737            sequence_hash: seq_two.hash,
738        };
739        let op_two = Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("op two");
740        let (changeset_two, deps_two) = simple_changeset(
741            &block_group_two,
742            &node_two,
743            &seq_two,
744            &start_node,
745            &end_node,
746        );
747        write_changeset(
748            workspace,
749            &op_two,
750            DatabaseChangeset {
751                db_path: "db-two.db".to_string(),
752                changes: changeset_two,
753            },
754            &deps_two,
755        );
756
757        let diffs = collect_operation_diff(
758            workspace,
759            op_conn,
760            Some(base.hash),
761            op_two.hash,
762            Some("db-one.db"),
763        )
764        .expect("diff");
765        let diff = diffs.get("db-one.db").expect("diff db");
766        let db_diff = get_db_diff(&diffs, "db-one.db");
767        assert_eq!(diff.operations, vec![op_one.hash]);
768        assert_eq!(db_diff.added_block_groups.len(), 1);
769        assert_eq!(db_diff.added_block_groups[0].id, block_group_one.id);
770
771        let diff_none = collect_operation_diff(
772            workspace,
773            op_conn,
774            Some(base.hash),
775            op_two.hash,
776            Some("missing.db"),
777        )
778        .expect("diff");
779        assert!(diff_none.is_empty());
780    }
781}