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        let upstream_operations = Operation::get_upstream(op_conn, &to_hash);
135        (upstream_operations.clone(), upstream_operations, vec![])
136    };
137
138    let added_graphs = build_block_group_diffs(workspace, op_conn, &added_ops, db_path)?;
139    let removed_graphs = build_block_group_diffs(workspace, op_conn, &removed_ops, db_path)?;
140
141    Ok(build_operation_diffs(
142        &operations_in_order,
143        &added_graphs,
144        &removed_graphs,
145    ))
146}
147
148/// The idea here is to build a merged changeset from the changesets of operations. This changeset is then fed into
149/// the machinery for rendering single operation changesets. If multiple operations are passed in, this is a merged
150/// changeset that represents the combined effect of all the operations.
151fn build_block_group_diffs(
152    workspace: &Workspace,
153    op_conn: &OperationsConnection,
154    operations: &[HashId],
155    db_path: Option<&str>,
156) -> Result<HashMap<String, BlockGroupDiffs>, OperationDiffError> {
157    if operations.is_empty() {
158        return Ok(HashMap::new());
159    }
160
161    #[derive(Default)]
162    struct DbAccumulator {
163        operations: Vec<HashId>,
164        block_group_info: HashMap<HashId, BlockGroup>,
165        block_groups: HashSet<gen_models::block_group::BlockGroup>,
166        edges: HashSet<gen_models::edge::Edge>,
167        block_group_edges: HashSet<gen_models::block_group_edge::BlockGroupEdge>,
168        nodes: HashSet<gen_models::node::Node>,
169        sequences: HashSet<gen_models::sequence::Sequence>,
170        dep_edges: HashSet<gen_models::edge::Edge>,
171        dep_nodes: HashSet<gen_models::node::Node>,
172        dep_sequences: HashSet<gen_models::sequence::Sequence>,
173    }
174
175    let mut accumulators: HashMap<String, DbAccumulator> = HashMap::new();
176
177    for op_hash in operations {
178        let operation = Operation::get_by_id(op_conn, op_hash)
179            .ok_or_else(|| OperationDiffError::OperationMissing(*op_hash))?;
180        let changeset = operation.get_changeset(workspace);
181        if let Some(db_path) = db_path
182            && changeset.db_path != db_path
183        {
184            continue;
185        }
186        let changeset_db = changeset.db_path.clone();
187        let changeset = changeset.changes;
188        let dependencies = operation.get_changeset_dependencies(workspace);
189
190        let entry = accumulators.entry(changeset_db).or_default();
191        entry.operations.push(*op_hash);
192
193        for block_group in changeset
194            .block_groups
195            .iter()
196            .chain(dependencies.block_group.iter())
197        {
198            entry
199                .block_group_info
200                .entry(block_group.id)
201                .or_insert_with(|| block_group.clone());
202        }
203
204        entry.block_groups.extend(changeset.block_groups);
205        entry.edges.extend(changeset.edges);
206        entry.block_group_edges.extend(changeset.block_group_edges);
207        entry.nodes.extend(changeset.nodes);
208        entry.sequences.extend(changeset.sequences);
209        entry.dep_edges.extend(dependencies.edges);
210        entry.dep_nodes.extend(dependencies.nodes);
211        entry.dep_sequences.extend(dependencies.sequences);
212    }
213
214    let mut results = HashMap::new();
215    for (db_path, acc) in accumulators {
216        let merged_graphs = get_diff_graph(
217            &ChangesetModels {
218                block_groups: acc.block_groups.into_iter().collect(),
219                edges: acc.edges.into_iter().collect(),
220                block_group_edges: acc.block_group_edges.into_iter().collect(),
221                nodes: acc.nodes.into_iter().collect(),
222                sequences: acc.sequences.into_iter().collect(),
223                ..Default::default()
224            },
225            &DependencyModels {
226                edges: acc.dep_edges.into_iter().collect(),
227                nodes: acc.dep_nodes.into_iter().collect(),
228                sequences: acc.dep_sequences.into_iter().collect(),
229                ..Default::default()
230            },
231        );
232
233        let mut block_groups = merged_graphs
234            .into_iter()
235            .map(|(id, graph)| {
236                let block_group = acc.block_group_info.get(&id).cloned();
237                BlockGroupDiff {
238                    id,
239                    block_group,
240                    graph,
241                }
242            })
243            .collect::<Vec<_>>();
244        block_groups.sort_by_key(|a| {
245            if let Some(bg) = &a.block_group {
246                (
247                    bg.collection_name.clone(),
248                    bg.sample_name.clone(),
249                    bg.name.clone(),
250                    format!("{id}", id = a.id),
251                )
252            } else {
253                (
254                    String::new(),
255                    String::new(),
256                    String::new(),
257                    format!("{id}", id = a.id),
258                )
259            }
260        });
261        results.insert(
262            db_path,
263            BlockGroupDiffs {
264                operations: acc.operations,
265                block_group_diffs: block_groups,
266            },
267        );
268    }
269
270    Ok(results)
271}
272
273#[cfg(test)]
274mod tests {
275    use gen_core::{HashId, Strand};
276    use gen_models::{
277        block_group::BlockGroup,
278        block_group_edge::BlockGroupEdge,
279        changesets::{ChangesetModels, DatabaseChangeset, write_changeset},
280        edge::Edge,
281        node::Node,
282        operations::{Branch, Operation, OperationState},
283        sequence::{NewSequence, Sequence},
284    };
285
286    use super::*;
287    use crate::test_helpers::setup_gen;
288
289    fn get_db_diff<'a>(diffs: &'a HashMap<String, OperationDiff>, db_path: &str) -> &'a DbDiff {
290        diffs
291            .get(db_path)
292            .and_then(|diff| diff.dbs.get(db_path))
293            .expect("db diff")
294    }
295
296    fn base_dependencies(start_node: &Node, end_node: &Node) -> DependencyModels {
297        let mut start_sequence = Sequence::new()
298            .sequence_type("DNA")
299            .sequence("")
300            .name("start")
301            .build();
302        start_sequence.hash = start_node.sequence_hash;
303        let mut end_sequence = Sequence::new()
304            .sequence_type("DNA")
305            .sequence("")
306            .name("end")
307            .build();
308        end_sequence.hash = end_node.sequence_hash;
309        DependencyModels {
310            collections: vec![],
311            samples: vec![],
312            sequences: vec![start_sequence, end_sequence],
313            block_group: vec![],
314            nodes: vec![start_node.clone(), end_node.clone()],
315            edges: vec![],
316            paths: vec![],
317            accessions: vec![],
318            accession_edges: vec![],
319        }
320    }
321
322    fn simple_changeset(
323        block_group: &BlockGroup,
324        node: &Node,
325        seq: &Sequence,
326        start_node: &Node,
327        end_node: &Node,
328    ) -> (ChangesetModels, DependencyModels) {
329        let edges = vec![
330            Edge {
331                id: HashId::convert_str(&format!("{}-{}-start", block_group.id, node.id)),
332                source_node_id: start_node.id,
333                source_coordinate: 0,
334                source_strand: Strand::Forward,
335                target_node_id: node.id,
336                target_coordinate: 0,
337                target_strand: Strand::Forward,
338            },
339            Edge {
340                id: HashId::convert_str(&format!("{}-{}-end", block_group.id, node.id)),
341                source_node_id: node.id,
342                source_coordinate: seq.length,
343                source_strand: Strand::Forward,
344                target_node_id: end_node.id,
345                target_coordinate: 0,
346                target_strand: Strand::Forward,
347            },
348        ];
349        let block_group_edges = vec![
350            BlockGroupEdge {
351                id: HashId::convert_str(&format!("{}-{}-start-bge", block_group.id, node.id)),
352                block_group_id: block_group.id,
353                edge_id: edges[0].id,
354                chromosome_index: 0,
355                phased: 0,
356                created_on: 0,
357            },
358            BlockGroupEdge {
359                id: HashId::convert_str(&format!("{}-{}-end-bge", block_group.id, node.id)),
360                block_group_id: block_group.id,
361                edge_id: edges[1].id,
362                chromosome_index: 0,
363                phased: 0,
364                created_on: 0,
365            },
366        ];
367        let changeset = ChangesetModels {
368            collections: vec![],
369            samples: vec![],
370            sample_lineages: 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: "s".to_string(),
413            name: "bg".to_string(),
414            created_on: 0,
415            parent_block_group_id: None,
416            is_default: false,
417        };
418
419        let head = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op");
420        let (changeset, dependencies) =
421            simple_changeset(&block_group, &node_one, &seq_one, &start_node, &end_node);
422        write_changeset(
423            workspace,
424            &head,
425            DatabaseChangeset {
426                db_path: "diff.db".to_string(),
427                changes: changeset,
428            },
429            &dependencies,
430        );
431
432        let diffs = collect_operation_diff(workspace, op_conn, Some(base_op.hash), head.hash, None)
433            .expect("diff");
434        let diff = diffs.get("diff.db").expect("diff db");
435        let db_diff = get_db_diff(&diffs, "diff.db");
436        assert_eq!(diff.operations, vec![head.hash]);
437        assert_eq!(db_diff.added_block_groups.len(), 1);
438        assert!(db_diff.removed_block_groups.is_empty());
439        let graph = &db_diff.added_block_groups[0].graph;
440        assert_eq!(graph.nodes().count(), 3);
441        assert_eq!(graph.all_edges().count(), 2);
442    }
443
444    #[test]
445    fn initial_operation_diff_contains_added_block_groups() {
446        let context = setup_gen();
447        let op_conn = context.operations().conn();
448        let workspace = context.workspace();
449        let start_node = Node::get_start_node();
450        let end_node = Node::get_end_node();
451
452        let seq_one = NewSequence::new()
453            .sequence_type("dna")
454            .sequence("AAAAA")
455            .name("one")
456            .build();
457        let node_one = Node {
458            id: HashId::pad_str(10),
459            sequence_hash: seq_one.hash,
460        };
461        let block_group = BlockGroup {
462            id: HashId::pad_str(3),
463            collection_name: "c".to_string(),
464            sample_name: "s".to_string(),
465            name: "bg".to_string(),
466            created_on: 0,
467            parent_block_group_id: None,
468            is_default: false,
469        };
470
471        let head = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op");
472        let (changeset, dependencies) =
473            simple_changeset(&block_group, &node_one, &seq_one, &start_node, &end_node);
474        write_changeset(
475            workspace,
476            &head,
477            DatabaseChangeset {
478                db_path: "diff.db".to_string(),
479                changes: changeset,
480            },
481            &dependencies,
482        );
483
484        let diffs =
485            collect_operation_diff(workspace, op_conn, None, head.hash, None).expect("diff");
486        let diff = diffs.get("diff.db").expect("diff db");
487        let db_diff = get_db_diff(&diffs, "diff.db");
488        assert_eq!(diff.operations, vec![head.hash]);
489        assert_eq!(db_diff.added_block_groups.len(), 1);
490        assert!(db_diff.removed_block_groups.is_empty());
491        let graph = &db_diff.added_block_groups[0].graph;
492        assert_eq!(graph.nodes().count(), 3);
493        assert_eq!(graph.all_edges().count(), 2);
494    }
495
496    #[test]
497    fn merges_multiple_operations() {
498        let context = setup_gen();
499        let op_conn = context.operations().conn();
500        let workspace = context.workspace();
501        let start_node = Node::get_start_node();
502        let end_node = Node::get_end_node();
503
504        let op1 = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("create base op");
505
506        let bg_one = BlockGroup {
507            id: HashId::pad_str(3),
508            collection_name: "c".to_string(),
509            sample_name: "s".to_string(),
510            name: "bg1".to_string(),
511            created_on: 0,
512            parent_block_group_id: None,
513            is_default: false,
514        };
515        let seq_one = NewSequence::new()
516            .sequence_type("dna")
517            .sequence("AAAAA")
518            .name("one")
519            .build();
520        let node_one = Node {
521            id: HashId::pad_str(10),
522            sequence_hash: seq_one.hash,
523        };
524        let op2 = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("create op2");
525        let (changeset_one, dependencies_one) =
526            simple_changeset(&bg_one, &node_one, &seq_one, &start_node, &end_node);
527        write_changeset(
528            workspace,
529            &op2,
530            DatabaseChangeset {
531                db_path: "diff.db".to_string(),
532                changes: changeset_one,
533            },
534            &dependencies_one,
535        );
536
537        let bg_two = BlockGroup {
538            id: HashId::pad_str(4),
539            collection_name: "c".to_string(),
540            sample_name: "s".to_string(),
541            name: "bg2".to_string(),
542            created_on: 0,
543            parent_block_group_id: None,
544            is_default: false,
545        };
546        let seq_two = NewSequence::new()
547            .sequence_type("dna")
548            .sequence("CCCCC")
549            .name("two")
550            .build();
551        let node_two = Node {
552            id: HashId::pad_str(11),
553            sequence_hash: seq_two.hash,
554        };
555        let op3 = Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("create op3");
556        let (changeset_two, dependencies_two) =
557            simple_changeset(&bg_two, &node_two, &seq_two, &start_node, &end_node);
558        write_changeset(
559            workspace,
560            &op3,
561            DatabaseChangeset {
562                db_path: "diff.db".to_string(),
563                changes: changeset_two,
564            },
565            &dependencies_two,
566        );
567
568        let diffs = collect_operation_diff(workspace, op_conn, Some(op1.hash), op3.hash, None)
569            .expect("diff");
570        let diff = diffs.get("diff.db").expect("diff db");
571        let db_diff = get_db_diff(&diffs, "diff.db");
572        assert_eq!(diff.operations, vec![op2.hash, op3.hash]);
573        assert_eq!(db_diff.added_block_groups.len(), 2);
574    }
575
576    #[test]
577    fn diff_against_itself_is_empty() {
578        let context = setup_gen();
579        let op_conn = context.operations().conn();
580        let workspace = context.workspace();
581        let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("create base op");
582        let diffs = collect_operation_diff(workspace, op_conn, Some(base.hash), base.hash, None)
583            .expect("diff");
584        assert!(diffs.is_empty());
585    }
586
587    #[test]
588    fn diffs_across_branches() {
589        let context = setup_gen();
590        let op_conn = context.operations().conn();
591        let workspace = context.workspace();
592        let start_node = Node::get_start_node();
593        let end_node = Node::get_end_node();
594
595        let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("base op");
596
597        let main_block_group = BlockGroup {
598            id: HashId::pad_str(20),
599            collection_name: "c".to_string(),
600            sample_name: "s".to_string(),
601            name: "main".to_string(),
602            created_on: 0,
603            parent_block_group_id: None,
604            is_default: false,
605        };
606        let main_seq = NewSequence::new()
607            .sequence_type("dna")
608            .sequence("AAAAA")
609            .name("main")
610            .build();
611        let main_node = Node {
612            id: HashId::pad_str(21),
613            sequence_hash: main_seq.hash,
614        };
615        let op_main = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("main op");
616        let (main_changeset, main_deps) = simple_changeset(
617            &main_block_group,
618            &main_node,
619            &main_seq,
620            &start_node,
621            &end_node,
622        );
623        write_changeset(
624            workspace,
625            &op_main,
626            DatabaseChangeset {
627                db_path: "diff.db".to_string(),
628                changes: main_changeset,
629            },
630            &main_deps,
631        );
632
633        let feature_branch = Branch::create_with_remote(op_conn, "feature", None).unwrap();
634        OperationState::set_branch(op_conn, &feature_branch.name);
635        OperationState::set_operation(op_conn, &base.hash);
636
637        let feature_block_group = BlockGroup {
638            id: HashId::pad_str(30),
639            collection_name: "c".to_string(),
640            sample_name: "s".to_string(),
641            name: "feature".to_string(),
642            created_on: 0,
643            parent_block_group_id: None,
644            is_default: false,
645        };
646        let feature_seq = NewSequence::new()
647            .sequence_type("dna")
648            .sequence("CCCCC")
649            .name("feature")
650            .build();
651        let feature_node = Node {
652            id: HashId::pad_str(31),
653            sequence_hash: feature_seq.hash,
654        };
655        let op_feature =
656            Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("feature op");
657        let (feature_changeset, feature_deps) = simple_changeset(
658            &feature_block_group,
659            &feature_node,
660            &feature_seq,
661            &start_node,
662            &end_node,
663        );
664        write_changeset(
665            workspace,
666            &op_feature,
667            DatabaseChangeset {
668                db_path: "diff.db".to_string(),
669                changes: feature_changeset,
670            },
671            &feature_deps,
672        );
673
674        let diffs = collect_operation_diff(
675            workspace,
676            op_conn,
677            Some(op_main.hash),
678            op_feature.hash,
679            None,
680        )
681        .expect("diff");
682        let diff = diffs.get("diff.db").expect("diff db");
683        let db_diff = get_db_diff(&diffs, "diff.db");
684        assert_eq!(diff.operations, vec![op_main.hash, op_feature.hash]);
685        assert_eq!(db_diff.added_block_groups.len(), 1);
686        assert_eq!(db_diff.removed_block_groups.len(), 1);
687        assert_eq!(db_diff.added_block_groups[0].id, feature_block_group.id);
688        assert_eq!(db_diff.removed_block_groups[0].id, main_block_group.id);
689    }
690
691    #[test]
692    fn filters_by_database_path() {
693        let context = setup_gen();
694        let op_conn = context.operations().conn();
695        let workspace = context.workspace();
696        let start_node = Node::get_start_node();
697        let end_node = Node::get_end_node();
698
699        let base = Operation::create(op_conn, "seed", &HashId::pad_str(1)).expect("base op");
700
701        let block_group_one = BlockGroup {
702            id: HashId::pad_str(40),
703            collection_name: "c".to_string(),
704            sample_name: "s".to_string(),
705            name: "db-one".to_string(),
706            created_on: 0,
707            parent_block_group_id: None,
708            is_default: false,
709        };
710        let seq_one = NewSequence::new()
711            .sequence_type("dna")
712            .sequence("AAAAA")
713            .name("one")
714            .build();
715        let node_one = Node {
716            id: HashId::pad_str(41),
717            sequence_hash: seq_one.hash,
718        };
719        let op_one = Operation::create(op_conn, "add", &HashId::pad_str(2)).expect("op one");
720        let (changeset_one, deps_one) = simple_changeset(
721            &block_group_one,
722            &node_one,
723            &seq_one,
724            &start_node,
725            &end_node,
726        );
727        write_changeset(
728            workspace,
729            &op_one,
730            DatabaseChangeset {
731                db_path: "db-one.db".to_string(),
732                changes: changeset_one,
733            },
734            &deps_one,
735        );
736
737        let block_group_two = BlockGroup {
738            id: HashId::pad_str(50),
739            collection_name: "c".to_string(),
740            sample_name: "s".to_string(),
741            name: "db-two".to_string(),
742            created_on: 0,
743            parent_block_group_id: None,
744            is_default: false,
745        };
746        let seq_two = NewSequence::new()
747            .sequence_type("dna")
748            .sequence("CCCCC")
749            .name("two")
750            .build();
751        let node_two = Node {
752            id: HashId::pad_str(51),
753            sequence_hash: seq_two.hash,
754        };
755        let op_two = Operation::create(op_conn, "add", &HashId::pad_str(3)).expect("op two");
756        let (changeset_two, deps_two) = simple_changeset(
757            &block_group_two,
758            &node_two,
759            &seq_two,
760            &start_node,
761            &end_node,
762        );
763        write_changeset(
764            workspace,
765            &op_two,
766            DatabaseChangeset {
767                db_path: "db-two.db".to_string(),
768                changes: changeset_two,
769            },
770            &deps_two,
771        );
772
773        let diffs = collect_operation_diff(
774            workspace,
775            op_conn,
776            Some(base.hash),
777            op_two.hash,
778            Some("db-one.db"),
779        )
780        .expect("diff");
781        let diff = diffs.get("db-one.db").expect("diff db");
782        let db_diff = get_db_diff(&diffs, "db-one.db");
783        assert_eq!(diff.operations, vec![op_one.hash]);
784        assert_eq!(db_diff.added_block_groups.len(), 1);
785        assert_eq!(db_diff.added_block_groups[0].id, block_group_one.id);
786
787        let diff_none = collect_operation_diff(
788            workspace,
789            op_conn,
790            Some(base.hash),
791            op_two.hash,
792            Some("missing.db"),
793        )
794        .expect("diff");
795        assert!(diff_none.is_empty());
796    }
797}