diff --git a/docs/book/src/batch-operations.md b/docs/book/src/batch-operations.md index fad63fd23..ae07d7180 100644 --- a/docs/book/src/batch-operations.md +++ b/docs/book/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parameterized by tree type + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Non-Merk tree append operations (user-facing): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Each operation is wrapped in a `QualifiedGroveDbOp` that includes the path: ```rust diff --git a/docs/book/translations/ar/src/batch-operations.md b/docs/book/translations/ar/src/batch-operations.md index 122a38b85..29fc11766 100644 --- a/docs/book/translations/ar/src/batch-operations.md +++ b/docs/book/translations/ar/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parameterized by tree type + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Non-Merk tree append operations (user-facing): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + كل عملية تُغلَّف في `QualifiedGroveDbOp` يتضمن المسار: ```rust diff --git a/docs/book/translations/cs/src/batch-operations.md b/docs/book/translations/cs/src/batch-operations.md index d3ce46751..c9481d25a 100644 --- a/docs/book/translations/cs/src/batch-operations.md +++ b/docs/book/translations/cs/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parametrizovano typem stromu + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Operace pridavani ne-Merk stromu (orientovane na uzivatele): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Kazda operace je obalena v `QualifiedGroveDbOp`, ktery zahrnuje cestu: ```rust diff --git a/docs/book/translations/de/src/batch-operations.md b/docs/book/translations/de/src/batch-operations.md index d359c2ed6..eb8a2e144 100644 --- a/docs/book/translations/de/src/batch-operations.md +++ b/docs/book/translations/de/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parametrisiert nach Baumtyp + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Nicht-Merk-Baum-Anhängeoperationen (benutzerseitig): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Jede Operation wird in ein `QualifiedGroveDbOp` verpackt, das den Pfad enthält: ```rust diff --git a/docs/book/translations/es/src/batch-operations.md b/docs/book/translations/es/src/batch-operations.md index 77fba83c6..0727ef4fe 100644 --- a/docs/book/translations/es/src/batch-operations.md +++ b/docs/book/translations/es/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parameterized by tree type + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Non-Merk tree append operations (user-facing): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Cada operación se envuelve en un `QualifiedGroveDbOp` que incluye la ruta: ```rust diff --git a/docs/book/translations/fr/src/batch-operations.md b/docs/book/translations/fr/src/batch-operations.md index 381ed1a01..aaac92436 100644 --- a/docs/book/translations/fr/src/batch-operations.md +++ b/docs/book/translations/fr/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parameterized by tree type + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Non-Merk tree append operations (user-facing): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Chaque opération est enveloppée dans un `QualifiedGroveDbOp` qui inclut le chemin : ```rust diff --git a/docs/book/translations/id/src/batch-operations.md b/docs/book/translations/id/src/batch-operations.md index a87fa76b3..b5998b6f1 100644 --- a/docs/book/translations/id/src/batch-operations.md +++ b/docs/book/translations/id/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Diparameterisasi berdasarkan tipe pohon + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Operasi append pohon non-Merk (menghadap pengguna): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Setiap operasi dibungkus dalam `QualifiedGroveDbOp` yang mencakup path: ```rust diff --git a/docs/book/translations/it/src/batch-operations.md b/docs/book/translations/it/src/batch-operations.md index 526dc2e62..a95551476 100644 --- a/docs/book/translations/it/src/batch-operations.md +++ b/docs/book/translations/it/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parametrizzato per tipo di albero + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Operazioni di append per alberi non-Merk (rivolte all'utente): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Ogni operazione e avvolta in un `QualifiedGroveDbOp` che include il percorso: ```rust diff --git a/docs/book/translations/ja/src/batch-operations.md b/docs/book/translations/ja/src/batch-operations.md index bb4530283..3d17800b5 100644 --- a/docs/book/translations/ja/src/batch-operations.md +++ b/docs/book/translations/ja/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parameterized by tree type + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Non-Merk tree append operations (user-facing): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + 各操作はパスを含む `QualifiedGroveDbOp` でラップされます: ```rust diff --git a/docs/book/translations/ko/src/batch-operations.md b/docs/book/translations/ko/src/batch-operations.md index b02c294da..b1c006ea9 100644 --- a/docs/book/translations/ko/src/batch-operations.md +++ b/docs/book/translations/ko/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // 트리 타입으로 매개변수화 + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // 비-Merk 트리 추가 연산 (사용자 대면): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + 각 연산은 경로를 포함하는 `QualifiedGroveDbOp`으로 래핑됩니다: ```rust diff --git a/docs/book/translations/pl/src/batch-operations.md b/docs/book/translations/pl/src/batch-operations.md index fcf5708ff..c3fad9d4f 100644 --- a/docs/book/translations/pl/src/batch-operations.md +++ b/docs/book/translations/pl/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parametryzowane typem drzewa + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Operacje dopisywania drzew nie-Merk (uzytkownika): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Kazda operacja jest opakowana w `QualifiedGroveDbOp` zawierajacy sciezke: ```rust diff --git a/docs/book/translations/pt/src/batch-operations.md b/docs/book/translations/pt/src/batch-operations.md index 6654513d9..d08f6a311 100644 --- a/docs/book/translations/pt/src/batch-operations.md +++ b/docs/book/translations/pt/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parametrizado pelo tipo de arvore + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Operacoes de append para arvores nao-Merk (voltadas ao usuario): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Cada operacao e envolvida em um `QualifiedGroveDbOp` que inclui o caminho: ```rust diff --git a/docs/book/translations/ru/src/batch-operations.md b/docs/book/translations/ru/src/batch-operations.md index 6cc2905a9..2161ad42d 100644 --- a/docs/book/translations/ru/src/batch-operations.md +++ b/docs/book/translations/ru/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parameterized by tree type + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Non-Merk tree append operations (user-facing): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Каждая операция обёрнута в `QualifiedGroveDbOp`, включающий путь: ```rust diff --git a/docs/book/translations/th/src/batch-operations.md b/docs/book/translations/th/src/batch-operations.md index b89d13b84..6977b4cb4 100644 --- a/docs/book/translations/th/src/batch-operations.md +++ b/docs/book/translations/th/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // กำหนดพารามิเตอร์ตามประเภทต้นไม้ + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // การดำเนินการ append สำหรับต้นไม้ non-Merk (ผู้ใช้เรียกใช้): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + แต่ละการดำเนินการถูกครอบด้วย `QualifiedGroveDbOp` ที่รวม path: ```rust diff --git a/docs/book/translations/tr/src/batch-operations.md b/docs/book/translations/tr/src/batch-operations.md index 8722f468b..76842c744 100644 --- a/docs/book/translations/tr/src/batch-operations.md +++ b/docs/book/translations/tr/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Agac tipine gore parametrelenmis + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Merk olmayan agac ekleme islemleri (kullaniciya yonelik): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Her islem, yolu iceren bir `QualifiedGroveDbOp` icine sarilir: ```rust diff --git a/docs/book/translations/vi/src/batch-operations.md b/docs/book/translations/vi/src/batch-operations.md index 7f931bfbf..4a268220e 100644 --- a/docs/book/translations/vi/src/batch-operations.md +++ b/docs/book/translations/vi/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Tham số hóa theo kiểu cây + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Thao tác thêm cho cây không phải Merk (dành cho người dùng): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + Mỗi thao tác được bọc trong `QualifiedGroveDbOp` bao gồm đường dẫn: ```rust diff --git a/docs/book/translations/zh/src/batch-operations.md b/docs/book/translations/zh/src/batch-operations.md index dded5d386..95033ac93 100644 --- a/docs/book/translations/zh/src/batch-operations.md +++ b/docs/book/translations/zh/src/batch-operations.md @@ -13,7 +13,7 @@ pub enum GroveOp { Patch { element: Element, change_in_bytes: i32 }, RefreshReference { reference_path_type, max_reference_hop, flags, trust_refresh_reference }, Delete, - DeleteTree(TreeType), // Parameterized by tree type + DeleteTree(TreeType, SubelementsDeletionBehavior), // Per-op deletion policy // Non-Merk tree append operations (user-facing): CommitmentTreeInsert { cmx: [u8; 32], payload: Vec }, @@ -40,6 +40,17 @@ pub enum NonMerkTreeMeta { } ``` +**SubelementsDeletionBehavior** controls how a `DeleteTree` handles non-empty subtrees: + +```rust +pub enum SubelementsDeletionBehavior { + DontCheck, // Skip emptiness check; caller guarantees tree is empty + Error, // Return Error::DeletingNonEmptyTree if non-empty + DeleteChildren, // Check, and recursively delete children if non-empty + Skip, // Check, and silently skip deletion if non-empty +} +``` + 每个操作被包装在一个 `QualifiedGroveDbOp` 中,其中包含路径: ```rust diff --git a/grovedb/src/batch/batch_structure.rs b/grovedb/src/batch/batch_structure.rs index 9512ca6c9..850d337c5 100644 --- a/grovedb/src/batch/batch_structure.rs +++ b/grovedb/src/batch/batch_structure.rs @@ -161,7 +161,7 @@ where } Ok(()) } - GroveOp::RefreshReference { .. } | GroveOp::Delete | GroveOp::DeleteTree(_) => { + GroveOp::RefreshReference { .. } | GroveOp::Delete | GroveOp::DeleteTree(..) => { Ok(()) } GroveOp::CommitmentTreeInsert { .. } diff --git a/grovedb/src/batch/estimated_costs/average_case_costs.rs b/grovedb/src/batch/estimated_costs/average_case_costs.rs index 3671a4594..5e91cef9c 100644 --- a/grovedb/src/batch/estimated_costs/average_case_costs.rs +++ b/grovedb/src/batch/estimated_costs/average_case_costs.rs @@ -151,7 +151,7 @@ impl GroveOp { propagate, grove_version, ), - GroveOp::DeleteTree(tree_type) => GroveDb::average_case_merk_delete_tree( + GroveOp::DeleteTree(tree_type, _) => GroveDb::average_case_merk_delete_tree( key, *tree_type, layer_element_estimates, @@ -497,7 +497,7 @@ mod tests { use crate::{ batch::{ estimated_costs::EstimatedCostsType::AverageCaseCostsType, key_info::KeyInfo, GroveOp, - KeyInfoPath, NonMerkTreeMeta, QualifiedGroveDbOp, + KeyInfoPath, NonMerkTreeMeta, QualifiedGroveDbOp, SubelementsDeletionBehavior, }, reference_path::ReferencePathType, tests::{common::EMPTY_PATH, make_empty_grovedb}, @@ -1226,6 +1226,7 @@ mod tests { vec![vec![7]], b"tree_key".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let mut paths = HashMap::new(); paths.insert( diff --git a/grovedb/src/batch/estimated_costs/worst_case_costs.rs b/grovedb/src/batch/estimated_costs/worst_case_costs.rs index 85ba39e3d..30cf355c5 100644 --- a/grovedb/src/batch/estimated_costs/worst_case_costs.rs +++ b/grovedb/src/batch/estimated_costs/worst_case_costs.rs @@ -143,7 +143,7 @@ impl GroveOp { propagate, grove_version, ), - GroveOp::DeleteTree(tree_type) => GroveDb::worst_case_merk_delete_tree( + GroveOp::DeleteTree(tree_type, _) => GroveDb::worst_case_merk_delete_tree( key, *tree_type, worst_case_layer_element_estimates, @@ -464,7 +464,7 @@ mod tests { use crate::{ batch::{ estimated_costs::EstimatedCostsType::WorstCaseCostsType, key_info::KeyInfo, GroveOp, - KeyInfoPath, NonMerkTreeMeta, QualifiedGroveDbOp, + KeyInfoPath, NonMerkTreeMeta, QualifiedGroveDbOp, SubelementsDeletionBehavior, }, reference_path::ReferencePathType, tests::{common::EMPTY_PATH, make_empty_grovedb}, @@ -919,6 +919,7 @@ mod tests { vec![vec![7]], b"tree_key".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let mut paths = HashMap::new(); paths.insert(KeyInfoPath(vec![]), MaxElementsNumber(1)); diff --git a/grovedb/src/batch/mod.rs b/grovedb/src/batch/mod.rs index 09bf1c2dd..65d58fdf5 100644 --- a/grovedb/src/batch/mod.rs +++ b/grovedb/src/batch/mod.rs @@ -79,7 +79,7 @@ use crate::batch::estimated_costs::EstimatedCostsType; use crate::{ batch::{batch_structure::BatchStructure, mode::BatchRunMode}, element::MaxReferenceHop, - operations::{get::MAX_REFERENCE_HOPS, proof::util::hex_to_ascii}, + operations::{delete::DeleteOptions, get::MAX_REFERENCE_HOPS, proof::util::hex_to_ascii}, reference_path::{ path_from_reference_path_type, path_from_reference_qualified_path_type, ReferencePathType, }, @@ -87,6 +87,31 @@ use crate::{ Element, ElementFlags, Error, GroveDb, Transaction, TransactionArg, }; +/// Controls how a `DeleteTree` operation handles non-empty subtrees. +/// +/// This enum is attached to each `DeleteTree` operation individually, +/// replacing the old batch-level `allow_deleting_non_empty_trees` / +/// `deleting_non_empty_trees_returns_error` flags on `BatchApplyOptions`. +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +pub enum SubelementsDeletionBehavior { + /// Do not check whether the subtree is empty before deleting. + /// The tree element is removed from the parent Merk unconditionally. + /// Any children that still exist will be left as orphaned data on disk + /// — callers typically use this when they have already ensured the + /// subtree is empty (e.g. the parent cleaned up children first) and + /// want to avoid the cost of a redundant emptiness check. + DontCheck, + /// Check emptiness. If the subtree is non-empty, return + /// `Error::DeletingNonEmptyTree`. + Error, + /// Check emptiness. If the subtree is non-empty, recursively delete + /// all children before deleting the tree itself. + DeleteChildren, + /// Check emptiness. If the subtree is non-empty, silently skip this + /// `DeleteTree` operation (no error, no deletion). + Skip, +} + /// Metadata for non-Merk tree types, carrying tree-type-specific state /// through the batch system. #[derive(Debug, PartialEq, Eq, Hash, Clone)] @@ -306,7 +331,7 @@ pub enum GroveOp { /// Delete Delete, /// Delete tree - DeleteTree(TreeType), + DeleteTree(TreeType, SubelementsDeletionBehavior), /// Insert a note commitment + payload into a CommitmentTree CommitmentTreeInsert { /// 32-byte note commitment (must be a valid Pallas field element) @@ -336,7 +361,7 @@ pub enum GroveOp { impl GroveOp { fn to_u8(&self) -> u8 { match self { - GroveOp::DeleteTree(_) => 0, + GroveOp::DeleteTree(..) => 0, // 1 used to be used for the DeleteSumTree GroveOp::Delete => 2, GroveOp::InsertTreeWithRootHash { .. } => 3, @@ -601,7 +626,9 @@ impl fmt::Debug for QualifiedGroveDbOp { ) } GroveOp::Delete => "Delete".to_string(), - GroveOp::DeleteTree(tree_type) => format!("Delete Tree {}", tree_type), + GroveOp::DeleteTree(tree_type, check) => { + format!("Delete Tree {} ({:?})", tree_type, check) + } GroveOp::ReplaceTreeRootKey { .. } => "Replace Tree Hash and Root Key".to_string(), GroveOp::InsertTreeWithRootHash { .. } => "Insert Tree Hash and Root Key".to_string(), GroveOp::ReplaceNonMerkTreeRoot { meta, .. } => { @@ -793,12 +820,17 @@ impl QualifiedGroveDbOp { } /// A delete tree op using a known owned path and known key - pub fn delete_tree_op(path: Vec>, key: Vec, tree_type: TreeType) -> Self { + pub fn delete_tree_op( + path: Vec>, + key: Vec, + tree_type: TreeType, + subelements_deletion_behavior: SubelementsDeletionBehavior, + ) -> Self { let path = KeyInfoPath::from_known_owned_path(path); Self { path, key: Some(KnownKey(key)), - op: GroveOp::DeleteTree(tree_type), + op: GroveOp::DeleteTree(tree_type, subelements_deletion_behavior), } } @@ -812,11 +844,16 @@ impl QualifiedGroveDbOp { } /// A delete tree op - pub fn delete_estimated_tree_op(path: KeyInfoPath, key: KeyInfo, tree_type: TreeType) -> Self { + pub fn delete_estimated_tree_op( + path: KeyInfoPath, + key: KeyInfo, + tree_type: TreeType, + subelements_deletion_behavior: SubelementsDeletionBehavior, + ) -> Self { Self { path, key: Some(key), - op: GroveOp::DeleteTree(tree_type), + op: GroveOp::DeleteTree(tree_type, subelements_deletion_behavior), } } @@ -1652,7 +1689,7 @@ where grove_version, ) } - GroveOp::Delete | GroveOp::DeleteTree(_) => Err(Error::InvalidBatchOperation( + GroveOp::Delete | GroveOp::DeleteTree(..) => Err(Error::InvalidBatchOperation( "references can not point to something currently being deleted", )) .wrap_with_cost(cost), @@ -2103,7 +2140,7 @@ where ) ); } - GroveOp::DeleteTree(_tree_type) => { + GroveOp::DeleteTree(_tree_type, _) => { cost_return_on_error_into!( &mut cost, Element::delete_into_batch_operations( @@ -2716,7 +2753,7 @@ impl GroveDb { )) .wrap_with_cost(cost); } - GroveOp::Delete | GroveOp::DeleteTree(_) => { + GroveOp::Delete | GroveOp::DeleteTree(..) => { if calculated_root_key.is_some() { return Err(Error::InvalidBatchOperation( "modification of tree when it will be \ @@ -3017,7 +3054,7 @@ impl GroveDb { ); } } - GroveOp::Delete | GroveOp::DeleteTree(_) => { + GroveOp::Delete => { let path_slices: Vec<&[u8]> = op.path.iterator().map(|p| p.as_slice()).collect(); let key = cost_return_on_error_no_add!( @@ -3037,6 +3074,49 @@ impl GroveDb { ) ); } + GroveOp::DeleteTree(_, subelements_deletion_behavior) => { + let path_slices: Vec<&[u8]> = + op.path.iterator().map(|p| p.as_slice()).collect(); + let key = cost_return_on_error_no_add!( + cost, + op.key + .as_ref() + .ok_or(Error::InvalidBatchOperation("delete op is missing a key")) + ); + // Map the per-op enum to the lower-level DeleteOptions. + // DontCheck and DeleteChildren both set + // allow_deleting_non_empty_trees = true because the + // single-op `delete()` already performs recursive child + // subtree cleanup when that flag is true — the two + // behaviors converge at this layer. Skip maps to + // allow=false + error=false, which makes `delete()` + // silently return Ok(false) for non-empty trees. + let delete_options = DeleteOptions { + allow_deleting_non_empty_trees: matches!( + subelements_deletion_behavior, + SubelementsDeletionBehavior::DontCheck + | SubelementsDeletionBehavior::DeleteChildren + ), + deleting_non_empty_trees_returns_error: matches!( + subelements_deletion_behavior, + SubelementsDeletionBehavior::Error + ), + base_root_storage_is_free: options + .as_ref() + .is_none_or(|o| o.base_root_storage_is_free), + validate_tree_at_path_exists: false, + }; + cost_return_on_error!( + &mut cost, + self.delete( + path_slices.as_slice(), + key.as_slice(), + Some(delete_options), + transaction, + grove_version + ) + ); + } GroveOp::CommitmentTreeInsert { cmx, rho, payload } => { let mut path_vec: Vec> = op.path.to_path(); let key = cost_return_on_error_no_add!( @@ -3398,128 +3478,121 @@ impl GroveDb { // nested subtrees. let mut non_merk_delete_paths: Vec>> = Vec::new(); let mut merk_delete_paths: Vec>> = Vec::new(); - - let batch_apply_options_ref = batch_apply_options.as_ref().cloned().unwrap_or_default(); + // Track paths skipped due to SubelementsDeletionBehavior::Skip so we can + // filter the corresponding ops out of the batch before apply_body. + let mut skipped_delete_paths: HashSet>> = HashSet::new(); for op in ops.iter() { - if let GroveOp::DeleteTree(tree_type) = &op.op + if let GroveOp::DeleteTree(tree_type, subelements_deletion_behavior) = &op.op && let Some(key) = op.key.as_ref() { let mut child_path = op.path.to_path(); child_path.push(key.as_slice().to_vec()); - // H2 fix: check emptiness of the subtree before allowing - // deletion, respecting batch_apply_options just like the - // non-batch path does. - if !batch_apply_options_ref.allow_deleting_non_empty_trees { - let is_empty = if tree_type.uses_non_merk_data_storage() { - // Non-Merk trees: check element-level entry count. - let parent_path_vec = op.path.to_path(); - let parent_path: SubtreePath> = parent_path_vec.as_slice().into(); - let parent_storage = self - .db - .get_transactional_storage_context( - parent_path, - Some(&storage_batch), - tx.as_ref(), - ) - .unwrap_add_cost(&mut cost); - let element = cost_return_on_error!( - &mut cost, - Element::get_from_storage( - &parent_storage, - key.as_slice(), - grove_version, - ) - .map_err(|e| { - Error::CorruptedData(format!( - "unable to get element for delete tree emptiness check: {e}" - )) - }) - ); - element.non_merk_entry_count().unwrap_or(0) == 0 - } else { - // Standard Merk trees: use is_empty_tree_except to - // account for other delete ops in the same batch that - // target this subtree. - // - // Limitation: this only considers Delete/DeleteTree ops - // when building the exception set. It does NOT account - // for Insert ops in the same batch that would add new - // keys to this subtree. In theory, a batch could - // contain deletes for every existing key (making the - // tree appear empty) while also containing inserts that - // add new keys, and this check would still report the - // tree as empty. - // - // This is safe in practice because the consistency - // check (`verify_consistency_of_operations`), which - // runs before this code, detects "inserts under a - // deleted path" and rejects such batches. The only way - // to reach this code with conflicting insert + delete- - // tree ops is by setting - // `disable_operation_consistency_check = true`, in - // which case the caller has accepted responsibility for - // ensuring no such conflicts exist. - let batch_deleted_keys = ops - .iter() - .filter_map(|other_op| match &other_op.op { - GroveOp::Delete | GroveOp::DeleteTree(_) => { - if other_op.path.to_path() == child_path { - Some(other_op.key.as_ref()?.as_slice().to_vec()) - } else { + // Per-op emptiness check based on the SubelementsDeletionBehavior policy. + match subelements_deletion_behavior { + SubelementsDeletionBehavior::DontCheck => { + // No check — unconditionally allow the delete. + } + SubelementsDeletionBehavior::Error + | SubelementsDeletionBehavior::DeleteChildren + | SubelementsDeletionBehavior::Skip => { + let is_empty = if tree_type.uses_non_merk_data_storage() { + // Non-Merk trees: check element-level entry count. + let parent_path_vec = op.path.to_path(); + let parent_path: SubtreePath> = + parent_path_vec.as_slice().into(); + let parent_storage = self + .db + .get_transactional_storage_context( + parent_path, + Some(&storage_batch), + tx.as_ref(), + ) + .unwrap_add_cost(&mut cost); + let element = cost_return_on_error!( + &mut cost, + Element::get_from_storage( + &parent_storage, + key.as_slice(), + grove_version, + ) + .map_err(|e| { + Error::CorruptedData(format!( + "unable to get element for delete tree emptiness \ + check: {e}" + )) + }) + ); + element.non_merk_entry_count().unwrap_or(0) == 0 + } else { + // Standard Merk trees: use is_empty_tree_except to + // account for other delete ops in the same batch. + // + // Exclude DeleteTree ops with Skip policy — those + // might not execute if their target is non-empty, + // so we cannot assume they will delete their key. + let batch_deleted_keys = ops + .iter() + .filter_map(|other_op| match &other_op.op { + GroveOp::Delete => { + if other_op.path.to_path() == child_path { + Some(other_op.key.as_ref()?.as_slice().to_vec()) + } else { + None + } + } + GroveOp::DeleteTree(_, SubelementsDeletionBehavior::Skip) => { None } - } - _ => None, - }) - .collect::>>(); - let batch_deleted_keys_refs: std::collections::BTreeSet<&[u8]> = - batch_deleted_keys.iter().map(|k| k.as_slice()).collect(); - - let child_subtree_path: SubtreePath> = child_path.as_slice().into(); - let child_storage = self - .db - .get_transactional_storage_context( - child_subtree_path, - Some(&storage_batch), - tx.as_ref(), - ) - .unwrap_add_cost(&mut cost); - - let child_merk = cost_return_on_error!( - &mut cost, - Merk::open_layered_with_root_key( - child_storage, - None, - *tree_type, - Some(&Element::value_defined_cost_for_serialized_value), - grove_version, - ) - .map_err(|e| { - Error::CorruptedData(format!( - "unable to open subtree for emptiness check: {e}" - )) - }) - ); + GroveOp::DeleteTree(..) => { + if other_op.path.to_path() == child_path { + Some(other_op.key.as_ref()?.as_slice().to_vec()) + } else { + None + } + } + _ => None, + }) + .collect::>>(); + let batch_deleted_keys_refs: std::collections::BTreeSet<&[u8]> = + batch_deleted_keys.iter().map(|k| k.as_slice()).collect(); - child_merk - .is_empty_tree_except(batch_deleted_keys_refs) - .unwrap_add_cost(&mut cost) - }; + let child_merk = cost_return_on_error!( + &mut cost, + self.open_batch_transactional_merk_at_path( + &storage_batch, + child_path.as_slice().into(), + tx.as_ref(), + false, + grove_version, + ) + ); - if !is_empty { - if batch_apply_options_ref.deleting_non_empty_trees_returns_error { - return Err(Error::DeletingNonEmptyTree( - "trying to do a batch delete operation for a non empty tree, \ - but options not allowing this", - )) - .wrap_with_cost(cost); - } else { - // Skip this DeleteTree op — don't add to cleanup - // paths and the op will still be in the batch but - // we filter it out below. - continue; + child_merk + .is_empty_tree_except(batch_deleted_keys_refs) + .unwrap_add_cost(&mut cost) + }; + + if !is_empty { + match subelements_deletion_behavior { + SubelementsDeletionBehavior::Error => { + return Err(Error::DeletingNonEmptyTree( + "trying to do a batch delete operation for a non \ + empty tree, but options not allowing this", + )) + .wrap_with_cost(cost); + } + SubelementsDeletionBehavior::DeleteChildren => { + // Proceed — children will be cleaned up + // in the storage cleanup phase below. + } + SubelementsDeletionBehavior::Skip => { + skipped_delete_paths.insert(child_path); + continue; + } + SubelementsDeletionBehavior::DontCheck => unreachable!(), + } } } } @@ -3532,25 +3605,17 @@ impl GroveDb { } } - // When allow_deleting_non_empty_trees is false and - // deleting_non_empty_trees_returns_error is false, we need to filter - // out DeleteTree ops for non-empty trees (they were skipped above). - let ops = if !batch_apply_options_ref.allow_deleting_non_empty_trees - && !batch_apply_options_ref.deleting_non_empty_trees_returns_error - { - let all_delete_paths: std::collections::HashSet>> = non_merk_delete_paths - .iter() - .chain(merk_delete_paths.iter()) - .cloned() - .collect(); + // Filter out DeleteTree ops that were skipped due to + // SubelementsDeletionBehavior::Skip on non-empty trees. + let ops = if !skipped_delete_paths.is_empty() { ops.into_iter() .filter(|op| { - if let GroveOp::DeleteTree(_) = &op.op + if let GroveOp::DeleteTree(..) = &op.op && let Some(key) = op.key.as_ref() { let mut child_path = op.path.to_path(); child_path.push(key.as_slice().to_vec()); - return all_delete_paths.contains(&child_path); + return !skipped_delete_paths.contains(&child_path); } true }) @@ -3769,110 +3834,112 @@ impl GroveDb { let mut merk_delete_paths: Vec>> = Vec::new(); let mut batch_apply_options = batch_apply_options.unwrap_or_default(); + let mut skipped_delete_paths: HashSet>> = HashSet::new(); for op in ops.iter() { - if let GroveOp::DeleteTree(tree_type) = &op.op + if let GroveOp::DeleteTree(tree_type, subelements_deletion_behavior) = &op.op && let Some(key) = op.key.as_ref() { let mut child_path = op.path.to_path(); child_path.push(key.as_slice().to_vec()); - // H2 fix: check emptiness of the subtree before allowing - // deletion (same logic as apply_batch_with_element_flags_update). - if !batch_apply_options.allow_deleting_non_empty_trees { - let is_empty = if tree_type.uses_non_merk_data_storage() { - let parent_path_vec = op.path.to_path(); - let parent_path: SubtreePath> = parent_path_vec.as_slice().into(); - let parent_storage = self - .db - .get_transactional_storage_context( - parent_path, - Some(&storage_batch), - tx.as_ref(), - ) - .unwrap_add_cost(&mut cost); - let element = cost_return_on_error!( - &mut cost, - Element::get_from_storage( - &parent_storage, - key.as_slice(), - grove_version, - ) - .map_err(|e| { - Error::CorruptedData(format!( - "unable to get element for delete tree emptiness check: {e}" - )) - }) - ); - element.non_merk_entry_count().unwrap_or(0) == 0 - } else { - // Standard Merk trees: use is_empty_tree_except to - // account for other delete ops in the same batch that - // target this subtree. - // - // Limitation: this only considers Delete/DeleteTree ops - // when building the exception set. It does NOT account - // for Insert ops in the same batch that would add new - // keys to this subtree. See the matching comment in - // `apply_batch_with_element_flags_update` for details - // on why this is safe in practice (the consistency - // check guards against this scenario). - let batch_deleted_keys = ops - .iter() - .filter_map(|other_op| match &other_op.op { - GroveOp::Delete | GroveOp::DeleteTree(_) => { - if other_op.path.to_path() == child_path { - Some(other_op.key.as_ref()?.as_slice().to_vec()) - } else { + match subelements_deletion_behavior { + SubelementsDeletionBehavior::DontCheck => { + // No check — unconditionally allow the delete. + } + SubelementsDeletionBehavior::Error + | SubelementsDeletionBehavior::DeleteChildren + | SubelementsDeletionBehavior::Skip => { + let is_empty = if tree_type.uses_non_merk_data_storage() { + let parent_path_vec = op.path.to_path(); + let parent_path: SubtreePath> = + parent_path_vec.as_slice().into(); + let parent_storage = self + .db + .get_transactional_storage_context( + parent_path, + Some(&storage_batch), + tx.as_ref(), + ) + .unwrap_add_cost(&mut cost); + let element = cost_return_on_error!( + &mut cost, + Element::get_from_storage( + &parent_storage, + key.as_slice(), + grove_version, + ) + .map_err(|e| { + Error::CorruptedData(format!( + "unable to get element for delete tree emptiness \ + check: {e}" + )) + }) + ); + element.non_merk_entry_count().unwrap_or(0) == 0 + } else { + // Exclude DeleteTree ops with Skip policy — those + // might not execute if their target is non-empty. + let batch_deleted_keys = ops + .iter() + .filter_map(|other_op| match &other_op.op { + GroveOp::Delete => { + if other_op.path.to_path() == child_path { + Some(other_op.key.as_ref()?.as_slice().to_vec()) + } else { + None + } + } + GroveOp::DeleteTree(_, SubelementsDeletionBehavior::Skip) => { None } - } - _ => None, - }) - .collect::>>(); - let batch_deleted_keys_refs: std::collections::BTreeSet<&[u8]> = - batch_deleted_keys.iter().map(|k| k.as_slice()).collect(); - - let child_subtree_path: SubtreePath> = child_path.as_slice().into(); - let child_storage = self - .db - .get_transactional_storage_context( - child_subtree_path, - Some(&storage_batch), - tx.as_ref(), - ) - .unwrap_add_cost(&mut cost); - - let child_merk = cost_return_on_error!( - &mut cost, - Merk::open_layered_with_root_key( - child_storage, - None, - *tree_type, - Some(&Element::value_defined_cost_for_serialized_value), - grove_version, - ) - .map_err(|e| { - Error::CorruptedData(format!( - "unable to open subtree for emptiness check: {e}" - )) - }) - ); + GroveOp::DeleteTree(..) => { + if other_op.path.to_path() == child_path { + Some(other_op.key.as_ref()?.as_slice().to_vec()) + } else { + None + } + } + _ => None, + }) + .collect::>>(); + let batch_deleted_keys_refs: std::collections::BTreeSet<&[u8]> = + batch_deleted_keys.iter().map(|k| k.as_slice()).collect(); - child_merk - .is_empty_tree_except(batch_deleted_keys_refs) - .unwrap_add_cost(&mut cost) - }; + let child_merk = cost_return_on_error!( + &mut cost, + self.open_batch_transactional_merk_at_path( + &storage_batch, + child_path.as_slice().into(), + tx.as_ref(), + false, + grove_version, + ) + ); - if !is_empty { - if batch_apply_options.deleting_non_empty_trees_returns_error { - return Err(Error::DeletingNonEmptyTree( - "trying to do a batch delete operation for a non empty tree, \ - but options not allowing this", - )) - .wrap_with_cost(cost); - } else { - continue; + child_merk + .is_empty_tree_except(batch_deleted_keys_refs) + .unwrap_add_cost(&mut cost) + }; + + if !is_empty { + match subelements_deletion_behavior { + SubelementsDeletionBehavior::Error => { + return Err(Error::DeletingNonEmptyTree( + "trying to do a batch delete operation for a non \ + empty tree, but options not allowing this", + )) + .wrap_with_cost(cost); + } + SubelementsDeletionBehavior::DeleteChildren => { + // Proceed — children will be cleaned up. + } + SubelementsDeletionBehavior::Skip => { + skipped_delete_paths.insert(child_path); + continue; + } + SubelementsDeletionBehavior::DontCheck => unreachable!(), + } } } } @@ -3885,23 +3952,17 @@ impl GroveDb { } } - // Filter out skipped DeleteTree ops when non-error mode is active. - let ops = if !batch_apply_options.allow_deleting_non_empty_trees - && !batch_apply_options.deleting_non_empty_trees_returns_error - { - let all_delete_paths: std::collections::HashSet>> = non_merk_delete_paths - .iter() - .chain(merk_delete_paths.iter()) - .cloned() - .collect(); + // Filter out DeleteTree ops that were skipped due to + // SubelementsDeletionBehavior::Skip on non-empty trees. + let ops = if !skipped_delete_paths.is_empty() { ops.into_iter() .filter(|op| { - if let GroveOp::DeleteTree(_) = &op.op + if let GroveOp::DeleteTree(..) = &op.op && let Some(key) = op.key.as_ref() { let mut child_path = op.path.to_path(); child_path.push(key.as_slice().to_vec()); - return all_delete_paths.contains(&child_path); + return !skipped_delete_paths.contains(&child_path); } true }) @@ -3970,6 +4031,17 @@ impl GroveDb { // caller-provided, so the returned operations could contain duplicates, // internal-only ops, or inserts under paths being deleted. Apply the // same consistency gate used for the initial batch. + // + // Limitation: add-on DeleteTree ops bypass the + // SubelementsDeletionBehavior preflight (emptiness check, Skip + // filtering, cleanup path collection) that runs on the initial + // batch. They go straight into continue_partial_apply_body → + // apply_body, where DeleteTree is a simple layered Merk delete + // with no emptiness enforcement. In practice this is safe because + // partial-batch callers (Platform) control the callback and only + // return root-level propagation ops, not new DeleteTree ops. If + // add-on DeleteTree support is needed in the future, the preflight + // must be extended to cover new_operations as well. if check_batch_operation_consistency && !new_operations.is_empty() { let consistency_result = QualifiedGroveDbOp::verify_consistency_of_operations(&new_operations); @@ -4351,8 +4423,6 @@ mod tests { Some(BatchApplyOptions { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: true, - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, disable_operation_consistency_check: true, base_root_storage_is_free: true, batch_pause_height: None, @@ -4958,8 +5028,6 @@ mod tests { Some(BatchApplyOptions { validate_insertion_does_not_override: true, validate_insertion_does_not_override_tree: true, - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, disable_operation_consistency_check: false, base_root_storage_is_free: true, batch_pause_height: None, @@ -5001,9 +5069,7 @@ mod tests { Some(BatchApplyOptions { disable_operation_consistency_check: false, validate_insertion_does_not_override_tree: true, - allow_deleting_non_empty_trees: false, validate_insertion_does_not_override: true, - deleting_non_empty_trees_returns_error: true, base_root_storage_is_free: true, batch_pause_height: None, }), @@ -5036,8 +5102,6 @@ mod tests { Some(BatchApplyOptions { validate_insertion_does_not_override: true, validate_insertion_does_not_override_tree: true, - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, disable_operation_consistency_check: false, base_root_storage_is_free: true, batch_pause_height: None, @@ -5513,17 +5577,15 @@ mod tests { .expect("insert commitment tree data"); // Delete it via batch. The tree is non-empty (has one entry), - // so we must set allow_deleting_non_empty_trees. + // so we pass SubelementsDeletionBehavior::DontCheck to skip the emptiness check. let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], b"ct".to_vec(), grovedb_merk::tree_type::TreeType::CommitmentTree(4), + SubelementsDeletionBehavior::DontCheck, )]; - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, - ..Default::default() - }); + let batch_options = Some(BatchApplyOptions::default()); db.apply_batch(ops, batch_options, Some(&tx), grove_version) .unwrap() @@ -5600,18 +5662,16 @@ mod tests { .expect("append mmr value"); } - // The tree is non-empty (has 3 entries), so we must set - // allow_deleting_non_empty_trees. + // The tree is non-empty (has 3 entries), so we pass + // SubelementsDeletionBehavior::DontCheck to skip the emptiness check. let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], b"mmr".to_vec(), grovedb_merk::tree_type::TreeType::MmrTree, + SubelementsDeletionBehavior::DontCheck, )]; - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, - ..Default::default() - }); + let batch_options = Some(BatchApplyOptions::default()); db.apply_batch(ops, batch_options, Some(&tx), grove_version) .unwrap() @@ -5679,18 +5739,16 @@ mod tests { .expect("insert dense tree value"); } - // The tree is non-empty (has 3 entries), so we must set - // allow_deleting_non_empty_trees. + // The tree is non-empty (has 3 entries), so we pass + // SubelementsDeletionBehavior::DontCheck to skip the emptiness check. let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], b"dense".to_vec(), grovedb_merk::tree_type::TreeType::DenseAppendOnlyFixedSizeTree(3), + SubelementsDeletionBehavior::DontCheck, )]; - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, - ..Default::default() - }); + let batch_options = Some(BatchApplyOptions::default()); db.apply_partial_batch( ops, diff --git a/grovedb/src/batch/options.rs b/grovedb/src/batch/options.rs index 5b726912d..5157f036c 100644 --- a/grovedb/src/batch/options.rs +++ b/grovedb/src/batch/options.rs @@ -14,10 +14,6 @@ pub struct BatchApplyOptions { pub validate_insertion_does_not_override: bool, /// Validate insertion does not override tree pub validate_insertion_does_not_override_tree: bool, - /// Allow deleting non-empty trees - pub allow_deleting_non_empty_trees: bool, - /// Deleting non empty trees returns error - pub deleting_non_empty_trees_returns_error: bool, /// Disable the full operation consistency check performed by /// [`super::QualifiedGroveDbOp::verify_consistency_of_operations`]. /// @@ -85,8 +81,6 @@ impl Default for BatchApplyOptions { BatchApplyOptions { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, disable_operation_consistency_check: false, base_root_storage_is_free: true, batch_pause_height: None, @@ -107,10 +101,10 @@ impl BatchApplyOptions { } /// As delete options - pub(crate) fn as_delete_options(&self) -> DeleteOptions where { + pub(crate) fn as_delete_options(&self) -> DeleteOptions { DeleteOptions { - allow_deleting_non_empty_trees: self.allow_deleting_non_empty_trees, - deleting_non_empty_trees_returns_error: self.deleting_non_empty_trees_returns_error, + allow_deleting_non_empty_trees: false, + deleting_non_empty_trees_returns_error: true, base_root_storage_is_free: self.base_root_storage_is_free, validate_tree_at_path_exists: false, } diff --git a/grovedb/src/batch/single_deletion_cost_tests.rs b/grovedb/src/batch/single_deletion_cost_tests.rs index 6e784e571..e77e10c9d 100644 --- a/grovedb/src/batch/single_deletion_cost_tests.rs +++ b/grovedb/src/batch/single_deletion_cost_tests.rs @@ -12,7 +12,7 @@ mod tests { use intmap::IntMap; use crate::{ - batch::QualifiedGroveDbOp, + batch::{QualifiedGroveDbOp, SubelementsDeletionBehavior}, tests::{common::EMPTY_PATH, make_empty_grovedb}, Element, }; @@ -77,6 +77,7 @@ mod tests { vec![], b"key1".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_cost = db .apply_batch(ops, None, Some(&tx), grove_version) @@ -221,6 +222,7 @@ mod tests { vec![], b"key1".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_cost = db .apply_batch(ops, None, None, grove_version) @@ -370,6 +372,7 @@ mod tests { vec![], b"key1".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_cost = db .apply_batch(ops, None, Some(&tx), grove_version) @@ -469,6 +472,7 @@ mod tests { vec![], b"key1".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_cost = db .apply_batch_with_element_flags_update( @@ -644,6 +648,7 @@ mod tests { vec![], b"key1".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_cost = db .apply_batch(ops, None, None, grove_version) diff --git a/grovedb/src/batch/single_sum_item_deletion_cost_tests.rs b/grovedb/src/batch/single_sum_item_deletion_cost_tests.rs index 4763af162..ba54946c8 100644 --- a/grovedb/src/batch/single_sum_item_deletion_cost_tests.rs +++ b/grovedb/src/batch/single_sum_item_deletion_cost_tests.rs @@ -6,7 +6,7 @@ mod tests { use grovedb_version::version::GroveVersion; use crate::{ - batch::QualifiedGroveDbOp, + batch::{QualifiedGroveDbOp, SubelementsDeletionBehavior}, tests::{common::EMPTY_PATH, make_empty_grovedb}, Element, }; @@ -48,6 +48,7 @@ mod tests { vec![], b"key1".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_cost = db .apply_batch(ops, None, Some(&tx), grove_version) @@ -155,6 +156,7 @@ mod tests { vec![], b"key1".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_cost = db .apply_batch(ops, None, Some(&tx), grove_version) diff --git a/grovedb/src/operations/delete/mod.rs b/grovedb/src/operations/delete/mod.rs index 8a6e5a058..25be813c5 100644 --- a/grovedb/src/operations/delete/mod.rs +++ b/grovedb/src/operations/delete/mod.rs @@ -49,7 +49,7 @@ use grovedb_version::{check_grovedb_v0_with_cost, version::GroveVersion}; use crate::util::{compat, TxRef}; #[cfg(feature = "minimal")] use crate::{ - batch::{GroveOp, QualifiedGroveDbOp}, + batch::{GroveOp, QualifiedGroveDbOp, SubelementsDeletionBehavior}, Element, ElementFlags, Error, GroveDb, Transaction, TransactionArg, }; @@ -637,7 +637,7 @@ impl GroveDb { let batch_deleted_keys = current_batch_operations .iter() .filter_map(|op| match op.op { - GroveOp::Delete | GroveOp::DeleteTree(_) => { + GroveOp::Delete | GroveOp::DeleteTree(..) => { if op.path.eq_path_vec(&subtree_merk_path_vec) { Some(op.key.as_ref()?.as_slice()) } else { @@ -666,7 +666,7 @@ impl GroveDb { // If there is any current batch operation that is inserting something in this // tree then it is not empty either is_empty &= !current_batch_operations.iter().any(|op| match op.op { - GroveOp::Delete | GroveOp::DeleteTree(_) => false, + GroveOp::Delete | GroveOp::DeleteTree(..) => false, _ => op.path.eq_path_vec(&subtree_merk_path_vec), }); @@ -680,10 +680,14 @@ impl GroveDb { Ok(None) } } else if is_empty { + // Emptiness was already verified above — use DontCheck + // to avoid a redundant re-check when the batch processes + // this op. Ok(Some(QualifiedGroveDbOp::delete_tree_op( path.to_vec(), key.to_vec(), tree_type, + SubelementsDeletionBehavior::DontCheck, ))) } else { Err(Error::NotSupported( diff --git a/grovedb/src/tests/batch_coverage_tests.rs b/grovedb/src/tests/batch_coverage_tests.rs index 01e10ed4d..c425d5c57 100644 --- a/grovedb/src/tests/batch_coverage_tests.rs +++ b/grovedb/src/tests/batch_coverage_tests.rs @@ -12,7 +12,7 @@ mod tests { use crate::{ batch::{ key_info::KeyInfo::KnownKey, BatchApplyOptions, GroveOp, KeyInfoPath, NonMerkTreeMeta, - QualifiedGroveDbOp, + QualifiedGroveDbOp, SubelementsDeletionBehavior, }, reference_path::ReferencePathType, tests::{common::EMPTY_PATH, make_empty_grovedb, make_test_grovedb, TEST_LEAF}, @@ -154,6 +154,7 @@ mod tests { vec![], b"tree_to_del".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; db.apply_batch(ops, None, None, grove_version) @@ -194,15 +195,15 @@ mod tests { .unwrap() .expect("insert child item"); - // Delete non-empty tree with allow_deleting_non_empty_trees = true + // Delete non-empty tree with SubelementsDeletionBehavior::DontCheck let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], b"tree_with_items".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, )]; let options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, ..Default::default() }); @@ -457,6 +458,7 @@ mod tests { vec![TEST_LEAF.to_vec()], b"subtree_to_delete".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; db.apply_operations_without_batching(ops, None, None, grove_version) @@ -896,7 +898,8 @@ mod tests { #[test] fn test_grove_op_ordering() { // DeleteTree = 0, Delete = 2, InsertOrReplace = 8, InsertWithKnownToNotAlreadyExist = 9, InsertIfNotExists = 10 - let delete_tree = GroveOp::DeleteTree(TreeType::NormalTree); + let delete_tree = + GroveOp::DeleteTree(TreeType::NormalTree, SubelementsDeletionBehavior::Error); let delete = GroveOp::Delete; let insert_or_replace = GroveOp::InsertOrReplace { element: Element::new_item(b"test".to_vec()), @@ -2395,6 +2398,7 @@ mod tests { vec![b"parent_sum".to_vec()], b"child_tree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; db.apply_batch(ops, None, None, grove_version) diff --git a/grovedb/src/tests/batch_delete_tree_tests.rs b/grovedb/src/tests/batch_delete_tree_tests.rs index b8e3a7204..8bed2f4be 100644 --- a/grovedb/src/tests/batch_delete_tree_tests.rs +++ b/grovedb/src/tests/batch_delete_tree_tests.rs @@ -12,7 +12,7 @@ mod tests { use grovedb_version::version::GroveVersion; use crate::{ - batch::{BatchApplyOptions, QualifiedGroveDbOp}, + batch::{BatchApplyOptions, QualifiedGroveDbOp, SubelementsDeletionBehavior}, tests::{common::EMPTY_PATH, make_empty_grovedb}, Element, Error, }; @@ -53,17 +53,15 @@ mod tests { .unwrap() .expect("insert child item"); - // Try to delete the non-empty tree via batch with default options - // (allow_deleting_non_empty_trees: false, deleting_non_empty_trees_returns_error: true) + // Try to delete the non-empty tree via batch with Error mode let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], b"parent_tree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, ..Default::default() }); @@ -119,11 +117,10 @@ mod tests { vec![], b"parent_tree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, )]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, - deleting_non_empty_trees_returns_error: true, ..Default::default() }); @@ -160,11 +157,10 @@ mod tests { vec![], b"empty_tree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, ..Default::default() }); @@ -226,16 +222,15 @@ mod tests { .unwrap() .expect("insert item into inner tree"); - // Step 2: Delete the outer tree via batch (with allow_deleting_non_empty_trees) + // Step 2: Delete the outer tree via batch (with DontCheck for non-empty subtrees) let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], b"outer".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, )]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, - deleting_non_empty_trees_returns_error: true, ..Default::default() }); @@ -358,10 +353,10 @@ mod tests { vec![], b"parent".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, )]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, ..Default::default() }); @@ -432,11 +427,10 @@ mod tests { vec![], b"skip_tree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Skip, )]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: false, ..Default::default() }); @@ -490,11 +484,10 @@ mod tests { vec![], b"empty_tree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Skip, )]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: false, ..Default::default() }); @@ -552,12 +545,15 @@ mod tests { // consider the tree empty. let ops = vec![ QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child".to_vec()), - QualifiedGroveDbOp::delete_tree_op(vec![], b"parent".to_vec(), TreeType::NormalTree), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Error, + ), ]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, ..Default::default() }); @@ -573,13 +569,13 @@ mod tests { } #[test] - fn test_batch_delete_tree_with_partial_child_delete_still_non_empty() { - // When only some children are deleted in the same batch, the tree - // should still be considered non-empty and the delete should fail. + fn test_batch_delete_all_children_plus_delete_tree_error_mode() { + // Delete ALL children explicitly + DeleteTree(Error) on parent. + // is_empty_tree_except accounts for all the child deletes, so the + // tree is considered empty and the deletion succeeds. let grove_version = GroveVersion::latest(); let db = make_empty_grovedb(); - // Create a tree with two children db.insert( EMPTY_PATH, b"parent", @@ -594,7 +590,7 @@ mod tests { db.insert( [b"parent".as_ref()].as_ref(), b"child1", - Element::new_item(b"value1".to_vec()), + Element::new_item(b"v1".to_vec()), None, None, grove_version, @@ -605,7 +601,7 @@ mod tests { db.insert( [b"parent".as_ref()].as_ref(), b"child2", - Element::new_item(b"value2".to_vec()), + Element::new_item(b"v2".to_vec()), None, None, grove_version, @@ -613,365 +609,1667 @@ mod tests { .unwrap() .expect("insert child2"); - // Only delete one child in the batch, then try to delete the parent tree. - // The tree still has child2, so it should fail. + db.insert( + [b"parent".as_ref()].as_ref(), + b"child3", + Element::new_item(b"v3".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child3"); + let ops = vec![ QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child1".to_vec()), - QualifiedGroveDbOp::delete_tree_op(vec![], b"parent".to_vec(), TreeType::NormalTree), + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child2".to_vec()), + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child3".to_vec()), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Error, + ), ]; - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, - ..Default::default() - }); - - let result = db - .apply_batch(ops, batch_options, None, grove_version) - .unwrap(); + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("delete all children + Error mode parent should succeed"); assert!( - result.is_err(), - "should fail when only some children are deleted: {:?}", - result, + db.get(EMPTY_PATH, b"parent", None, grove_version) + .unwrap() + .is_err(), + "parent should have been deleted" ); - match result { - Err(Error::DeletingNonEmptyTree(_)) => { /* expected */ } - Err(e) => panic!("expected DeletingNonEmptyTree, got: {:?}", e), - Ok(()) => panic!("expected error but got Ok"), - } } - // =================================================================== - // Partial batch: emptiness checks and cleanup - // =================================================================== + #[test] + fn test_batch_delete_all_children_plus_delete_tree_dont_check() { + // Delete ALL children explicitly + DeleteTree(DontCheck) on parent. + // DontCheck skips the emptiness check entirely, so it succeeds + // regardless. This is the typical pattern: caller cleans up children + // first, then uses DontCheck to avoid a redundant emptiness check. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"parent", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert parent"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"child1", + Element::new_item(b"v1".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child1"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"child2", + Element::new_item(b"v2".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child2"); + + let ops = vec![ + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child1".to_vec()), + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child2".to_vec()), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, + ), + ]; + + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("delete all children + DontCheck parent should succeed"); + + assert!( + db.get(EMPTY_PATH, b"parent", None, grove_version) + .unwrap() + .is_err(), + "parent should have been deleted" + ); + } #[test] - fn test_partial_batch_delete_tree_non_empty_should_fail_when_not_allowed() { - // The partial batch path has its own copy of the emptiness check logic. - // Verify it also enforces non-empty tree deletion restrictions. + fn test_batch_delete_all_children_plus_delete_tree_skip() { + // Delete ALL children explicitly + DeleteTree(Skip) on parent. + // The emptiness check runs and sees the tree as empty (all children + // are in the batch delete set via is_empty_tree_except), so the + // deletion proceeds normally — Skip never triggers. let grove_version = GroveVersion::latest(); let db = make_empty_grovedb(); - let tx = db.start_transaction(); db.insert( EMPTY_PATH, - b"tree_a", + b"parent", Element::empty_tree(), None, - Some(&tx), + None, grove_version, ) .unwrap() - .expect("insert tree"); + .expect("insert parent"); db.insert( - [b"tree_a".as_ref()].as_ref(), - b"item", - Element::new_item(b"val".to_vec()), + [b"parent".as_ref()].as_ref(), + b"child1", + Element::new_item(b"v1".to_vec()), + None, None, - Some(&tx), grove_version, ) .unwrap() - .expect("insert item"); + .expect("insert child1"); - let ops = vec![QualifiedGroveDbOp::delete_tree_op( - vec![], - b"tree_a".to_vec(), - TreeType::NormalTree, - )]; + db.insert( + [b"parent".as_ref()].as_ref(), + b"child2", + Element::new_item(b"v2".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child2"); - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, - ..Default::default() - }); + let ops = vec![ + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child1".to_vec()), + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child2".to_vec()), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Skip, + ), + ]; - let result = db - .apply_partial_batch( - ops, - batch_options, - |_cost, _left_over_ops| Ok(vec![]), - Some(&tx), - grove_version, - ) - .unwrap(); + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("delete all children + Skip parent should succeed (tree is empty)"); assert!( - result.is_err(), - "partial batch should fail for non-empty tree: {:?}", - result, + db.get(EMPTY_PATH, b"parent", None, grove_version) + .unwrap() + .is_err(), + "parent should have been deleted since tree was effectively empty" ); - match result { - Err(Error::DeletingNonEmptyTree(_)) => { /* expected */ } - Err(e) => panic!("expected DeletingNonEmptyTree, got: {:?}", e), - Ok(()) => panic!("expected error but got Ok"), - } } #[test] - fn test_partial_batch_delete_tree_non_empty_skip_mode() { - // Partial batch skip mode: non-empty tree should be skipped, not errored. + fn test_batch_delete_some_children_plus_delete_tree_dont_check() { + // Delete SOME children + DeleteTree(DontCheck) on parent. + // DontCheck skips the emptiness check, but apply_body rejects + // the combination because child ops produce a new root key for + // a tree that is being deleted. let grove_version = GroveVersion::latest(); let db = make_empty_grovedb(); - let tx = db.start_transaction(); db.insert( EMPTY_PATH, - b"tree_b", + b"parent", Element::empty_tree(), None, - Some(&tx), + None, grove_version, ) .unwrap() - .expect("insert tree"); + .expect("insert parent"); db.insert( - [b"tree_b".as_ref()].as_ref(), - b"item", - Element::new_item(b"val".to_vec()), + [b"parent".as_ref()].as_ref(), + b"child1", + Element::new_item(b"v1".to_vec()), + None, None, - Some(&tx), grove_version, ) .unwrap() - .expect("insert item"); + .expect("insert child1"); - let ops = vec![QualifiedGroveDbOp::delete_tree_op( - vec![], - b"tree_b".to_vec(), - TreeType::NormalTree, - )]; + db.insert( + [b"parent".as_ref()].as_ref(), + b"child2", + Element::new_item(b"v2".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child2"); - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: false, - ..Default::default() - }); + // Delete only child1, DontCheck on parent + let ops = vec![ + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child1".to_vec()), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, + ), + ]; - db.apply_partial_batch( - ops, - batch_options, - |_cost, _left_over_ops| Ok(vec![]), - Some(&tx), + let result = db.apply_batch(ops, None, None, grove_version).unwrap(); + + match result { + Err(Error::InvalidBatchOperation(msg)) => { + assert!( + msg.contains("modification of tree when it will be deleted"), + "unexpected error message: {}", + msg + ); + } + Err(e) => panic!("expected InvalidBatchOperation, got: {:?}", e), + Ok(()) => { + panic!("expected error: child ops produce a root key for a tree being deleted") + } + } + } + + #[test] + fn test_batch_delete_some_children_plus_delete_tree_skip() { + // Delete SOME children + DeleteTree(Skip) on parent. + // The emptiness check finds the tree non-empty (child2 remains), + // so the DeleteTree is skipped (filtered out of the batch). + // The child Delete op still runs: child1 is removed, parent and + // child2 survive. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"parent", + Element::empty_tree(), + None, + None, grove_version, ) .unwrap() - .expect("partial batch skip mode should succeed"); + .expect("insert parent"); - // Tree should still exist - let result = db - .get(EMPTY_PATH, b"tree_b", Some(&tx), grove_version) - .unwrap(); + db.insert( + [b"parent".as_ref()].as_ref(), + b"child1", + Element::new_item(b"v1".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child1"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"child2", + Element::new_item(b"v2".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child2"); + + // Delete only child1, Skip on parent + let ops = vec![ + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child1".to_vec()), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Skip, + ), + ]; + + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("Skip mode: DeleteTree filtered out, child delete still runs"); + + // Parent should still exist (DeleteTree was skipped) assert!( - result.is_ok(), - "tree should still exist after skipped partial batch delete" + db.get(EMPTY_PATH, b"parent", None, grove_version) + .unwrap() + .is_ok(), + "parent should still exist" + ); + + // child1 should be gone (the Delete op ran) + assert!( + db.get( + [b"parent".as_ref()].as_ref(), + b"child1", + None, + grove_version + ) + .unwrap() + .is_err(), + "child1 should have been deleted" + ); + + // child2 should still exist + assert!( + db.get( + [b"parent".as_ref()].as_ref(), + b"child2", + None, + grove_version + ) + .unwrap() + .is_ok(), + "child2 should still exist" + ); + } + + #[test] + fn test_batch_delete_tree_with_partial_child_delete_still_non_empty() { + // When only some children are deleted in the same batch, the tree + // should still be considered non-empty and the delete should fail. + // (This is the Error mode variant of the partial-delete scenario.) + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + // Create a tree with two children + db.insert( + EMPTY_PATH, + b"parent", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert parent"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"child1", + Element::new_item(b"value1".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child1"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"child2", + Element::new_item(b"value2".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child2"); + + // Only delete one child in the batch, then try to delete the parent tree. + // The tree still has child2, so it should fail. + let ops = vec![ + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child1".to_vec()), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Error, + ), + ]; + + let batch_options = Some(BatchApplyOptions { + ..Default::default() + }); + + let result = db + .apply_batch(ops, batch_options, None, grove_version) + .unwrap(); + + assert!( + result.is_err(), + "should fail when only some children are deleted: {:?}", + result, + ); + match result { + Err(Error::DeletingNonEmptyTree(_)) => { /* expected */ } + Err(e) => panic!("expected DeletingNonEmptyTree, got: {:?}", e), + Ok(()) => panic!("expected error but got Ok"), + } + } + + // =================================================================== + // Partial batch: emptiness checks and cleanup + // =================================================================== + + #[test] + fn test_partial_batch_delete_tree_non_empty_should_fail_when_not_allowed() { + // The partial batch path has its own copy of the emptiness check logic. + // Verify it also enforces non-empty tree deletion restrictions. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let tx = db.start_transaction(); + + db.insert( + EMPTY_PATH, + b"tree_a", + Element::empty_tree(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"tree_a".as_ref()].as_ref(), + b"item", + Element::new_item(b"val".to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert item"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"tree_a".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Error, + )]; + + let batch_options = Some(BatchApplyOptions { + ..Default::default() + }); + + let result = db + .apply_partial_batch( + ops, + batch_options, + |_cost, _left_over_ops| Ok(vec![]), + Some(&tx), + grove_version, + ) + .unwrap(); + + assert!( + result.is_err(), + "partial batch should fail for non-empty tree: {:?}", + result, + ); + match result { + Err(Error::DeletingNonEmptyTree(_)) => { /* expected */ } + Err(e) => panic!("expected DeletingNonEmptyTree, got: {:?}", e), + Ok(()) => panic!("expected error but got Ok"), + } + } + + #[test] + fn test_partial_batch_delete_tree_non_empty_skip_mode() { + // Partial batch skip mode: non-empty tree should be skipped, not errored. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let tx = db.start_transaction(); + + db.insert( + EMPTY_PATH, + b"tree_b", + Element::empty_tree(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"tree_b".as_ref()].as_ref(), + b"item", + Element::new_item(b"val".to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert item"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"tree_b".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Skip, + )]; + + let batch_options = Some(BatchApplyOptions { + ..Default::default() + }); + + db.apply_partial_batch( + ops, + batch_options, + |_cost, _left_over_ops| Ok(vec![]), + Some(&tx), + grove_version, + ) + .unwrap() + .expect("partial batch skip mode should succeed"); + + // Tree should still exist + let result = db + .get(EMPTY_PATH, b"tree_b", Some(&tx), grove_version) + .unwrap(); + assert!( + result.is_ok(), + "tree should still exist after skipped partial batch delete" + ); + } + + #[test] + fn test_partial_batch_delete_tree_non_empty_succeeds_when_allowed() { + // Partial batch: deleting a non-empty tree should succeed when allowed, + // exercising the merk cleanup path in apply_partial_batch. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let tx = db.start_transaction(); + + // Create a tree with a nested subtree + db.insert( + EMPTY_PATH, + b"outer", + Element::empty_tree(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert outer"); + + db.insert( + [b"outer".as_ref()].as_ref(), + b"inner", + Element::empty_tree(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert inner"); + + db.insert( + [b"outer".as_ref(), b"inner".as_ref()].as_ref(), + b"data", + Element::new_item(b"some_value".to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert data"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"outer".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, + )]; + + let batch_options = Some(BatchApplyOptions { + ..Default::default() + }); + + db.apply_partial_batch( + ops, + batch_options, + |_cost, _left_over_ops| Ok(vec![]), + Some(&tx), + grove_version, + ) + .unwrap() + .expect("partial batch delete non-empty tree"); + + // Tree should be gone + let result = db + .get(EMPTY_PATH, b"outer", Some(&tx), grove_version) + .unwrap(); + assert!(result.is_err(), "tree should have been deleted"); + + // Re-insert and verify clean state (no stale data) + db.insert( + EMPTY_PATH, + b"outer", + Element::empty_tree(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("re-insert outer"); + + let inner_get = db + .get( + [b"outer".as_ref()].as_ref(), + b"inner", + Some(&tx), + grove_version, + ) + .unwrap(); + assert!( + inner_get.is_err(), + "re-inserted tree should be clean with no stale inner subtree" + ); + } + + #[test] + fn test_partial_batch_delete_tree_with_simultaneous_child_delete() { + // Partial batch version of the is_empty_tree_except test. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let tx = db.start_transaction(); + + db.insert( + EMPTY_PATH, + b"parent", + Element::empty_tree(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert parent"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"only_child", + Element::new_item(b"data".to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert only child"); + + // Delete the only child and the parent tree in the same batch + let ops = vec![ + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"only_child".to_vec()), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Error, + ), + ]; + + let batch_options = Some(BatchApplyOptions { + ..Default::default() + }); + + db.apply_partial_batch( + ops, + batch_options, + |_cost, _left_over_ops| Ok(vec![]), + Some(&tx), + grove_version, + ) + .unwrap() + .expect("partial batch: should succeed since child is also being deleted"); + + let result = db + .get(EMPTY_PATH, b"parent", Some(&tx), grove_version) + .unwrap(); + assert!(result.is_err(), "parent should have been deleted"); + } + + // =================================================================== + // Transactional batch: emptiness check + Merk cleanup + // =================================================================== + + #[test] + fn test_batch_delete_tree_non_empty_error_with_transaction() { + // Same as the non-transactional test but uses a transaction, + // exercising the apply_batch_with_element_flags_update code path + // which is slightly different (uses storage_batch). + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let tx = db.start_transaction(); + + db.insert( + EMPTY_PATH, + b"tree_tx", + Element::empty_tree(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"tree_tx".as_ref()].as_ref(), + b"item", + Element::new_item(b"val".to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert item"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"tree_tx".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Error, + )]; + + let batch_options = Some(BatchApplyOptions { + ..Default::default() + }); + + let result = db + .apply_batch(ops, batch_options, Some(&tx), grove_version) + .unwrap(); + + match result { + Err(Error::DeletingNonEmptyTree(_)) => { /* expected */ } + Err(e) => panic!("expected DeletingNonEmptyTree, got: {:?}", e), + Ok(()) => panic!("expected error but got Ok"), + } + } + + #[test] + fn test_batch_delete_tree_cleans_up_deeply_nested_subtrees() { + // Verify that recursive cleanup works for 3+ levels of nesting. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + // Create: root -> level1 -> level2 -> level3 -> item + db.insert( + EMPTY_PATH, + b"l1", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert l1"); + db.insert( + [b"l1".as_ref()].as_ref(), + b"l2", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert l2"); + db.insert( + [b"l1".as_ref(), b"l2".as_ref()].as_ref(), + b"l3", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert l3"); + db.insert( + [b"l1".as_ref(), b"l2".as_ref(), b"l3".as_ref()].as_ref(), + b"item", + Element::new_item(b"deep_value".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert deep item"); + + // Delete the top-level tree + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"l1".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, + )]; + + let batch_options = Some(BatchApplyOptions { + ..Default::default() + }); + + db.apply_batch(ops, batch_options, None, grove_version) + .unwrap() + .expect("delete deeply nested tree"); + + // Re-insert and verify clean state at all levels + db.insert( + EMPTY_PATH, + b"l1", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("re-insert l1"); + + // l2 should not exist in the re-inserted tree + let l2_get = db + .get([b"l1".as_ref()].as_ref(), b"l2", None, grove_version) + .unwrap(); + assert!(l2_get.is_err(), "l2 should not exist in re-inserted tree"); + } + + // =================================================================== + // DeleteChildren mode — non-empty tree should be deleted with cleanup + // =================================================================== + + #[test] + fn test_batch_delete_tree_delete_children_mode_deletes_non_empty_tree() { + // When SubelementsDeletionBehavior::DeleteChildren is used, + // the emptiness check runs but a non-empty tree should still + // be deleted (children cleaned up). + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + // Create a tree with children + db.insert( + EMPTY_PATH, + b"tree_dc", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"tree_dc".as_ref()].as_ref(), + b"child1", + Element::new_item(b"val1".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child1"); + + db.insert( + [b"tree_dc".as_ref()].as_ref(), + b"child2", + Element::new_item(b"val2".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child2"); + + // Delete the non-empty tree with DeleteChildren mode + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"tree_dc".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DeleteChildren, + )]; + + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("DeleteChildren mode should delete non-empty tree"); + + // Tree should be gone + assert!( + db.get(EMPTY_PATH, b"tree_dc", None, grove_version) + .unwrap() + .is_err(), + "tree should have been deleted" + ); + + // Re-insert and verify no stale data + db.insert( + EMPTY_PATH, + b"tree_dc", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("re-insert tree"); + + assert!( + db.get( + [b"tree_dc".as_ref()].as_ref(), + b"child1", + None, + grove_version + ) + .unwrap() + .is_err(), + "old children should not exist in re-inserted tree" + ); + } + + #[test] + fn test_batch_delete_tree_delete_children_mode_empty_tree_succeeds() { + // DeleteChildren mode on an empty tree should succeed normally. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"empty_dc", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert empty tree"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"empty_dc".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DeleteChildren, + )]; + + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("DeleteChildren on empty tree should succeed"); + + assert!( + db.get(EMPTY_PATH, b"empty_dc", None, grove_version) + .unwrap() + .is_err(), + "empty tree should have been deleted" + ); + } + + #[test] + fn test_partial_batch_delete_tree_delete_children_mode() { + // Partial batch path: DeleteChildren on a non-empty tree. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let tx = db.start_transaction(); + + db.insert( + EMPTY_PATH, + b"ptree_dc", + Element::empty_tree(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"ptree_dc".as_ref()].as_ref(), + b"child", + Element::new_item(b"val".to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert child"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"ptree_dc".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DeleteChildren, + )]; + + db.apply_partial_batch( + ops, + None, + |_cost, _left_over_ops| Ok(vec![]), + Some(&tx), + grove_version, + ) + .unwrap() + .expect("partial batch DeleteChildren should succeed"); + + assert!( + db.get(EMPTY_PATH, b"ptree_dc", Some(&tx), grove_version) + .unwrap() + .is_err(), + "tree should have been deleted" + ); + } + + // =================================================================== + // apply_operations_without_batching: exercises non-batch fallback + // =================================================================== + + #[test] + fn test_without_batching_delete_tree_dont_check() { + // Exercise the non-batch fallback path for DeleteTree with DontCheck. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"nb_tree", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"nb_tree".as_ref()].as_ref(), + b"item", + Element::new_item(b"val".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"nb_tree".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheck, + )]; + + db.apply_operations_without_batching(ops, None, None, grove_version) + .unwrap() + .expect("non-batch DontCheck should succeed"); + + assert!( + db.get(EMPTY_PATH, b"nb_tree", None, grove_version) + .unwrap() + .is_err(), + "tree should have been deleted" + ); + } + + #[test] + fn test_without_batching_delete_tree_error_mode() { + // Exercise the non-batch fallback path for DeleteTree with Error mode. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"nb_err", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"nb_err".as_ref()].as_ref(), + b"item", + Element::new_item(b"val".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + + // Error mode on non-empty tree: should fail + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"nb_err".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Error, + )]; + + let result = db + .apply_operations_without_batching(ops, None, None, grove_version) + .unwrap(); + + match result { + Err(Error::DeletingNonEmptyTree(_)) => { /* expected */ } + Err(e) => panic!("expected DeletingNonEmptyTree, got: {:?}", e), + Ok(()) => panic!("expected error but got Ok"), + } + } + + #[test] + fn test_without_batching_delete_tree_delete_children_mode() { + // Exercise the non-batch fallback path for DeleteTree with + // DeleteChildren mode. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"nb_dc", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"nb_dc".as_ref()].as_ref(), + b"item", + Element::new_item(b"val".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"nb_dc".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DeleteChildren, + )]; + + db.apply_operations_without_batching(ops, None, None, grove_version) + .unwrap() + .expect("non-batch DeleteChildren should succeed"); + + assert!( + db.get(EMPTY_PATH, b"nb_dc", None, grove_version) + .unwrap() + .is_err(), + "tree should have been deleted" + ); + } + + #[test] + fn test_without_batching_delete_tree_skip_mode() { + // Exercise the non-batch fallback for DeleteTree with Skip mode. + // Skip mode maps to allow=false, error=false in DeleteOptions, + // which makes delete() silently skip non-empty trees. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"nb_skip", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"nb_skip".as_ref()].as_ref(), + b"item", + Element::new_item(b"val".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"nb_skip".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Skip, + )]; + + db.apply_operations_without_batching(ops, None, None, grove_version) + .unwrap() + .expect("non-batch Skip should succeed"); + + // Tree should still exist (skip mode) + assert!( + db.get(EMPTY_PATH, b"nb_skip", None, grove_version) + .unwrap() + .is_ok(), + "tree should still exist after skip-mode non-batch delete" ); } #[test] - fn test_partial_batch_delete_tree_non_empty_succeeds_when_allowed() { - // Partial batch: deleting a non-empty tree should succeed when allowed, - // exercising the merk cleanup path in apply_partial_batch. + fn test_without_batching_delete_tree_empty_tree_with_error_mode() { + // Error mode on an empty tree should succeed. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"nb_empty", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert tree"); + + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"nb_empty".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Error, + )]; + + db.apply_operations_without_batching(ops, None, None, grove_version) + .unwrap() + .expect("non-batch Error mode on empty tree should succeed"); + + assert!( + db.get(EMPTY_PATH, b"nb_empty", None, grove_version) + .unwrap() + .is_err(), + "empty tree should have been deleted" + ); + } + + // =================================================================== + // apply_operations_without_batching with BatchApplyOptions (covers + // as_delete_options() in options.rs) + // =================================================================== + + #[test] + fn test_without_batching_delete_item_with_options() { + // Exercise as_delete_options() by passing Some(BatchApplyOptions) + // when deleting a non-tree element via apply_operations_without_batching. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"my_tree", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert tree"); + + db.insert( + [b"my_tree".as_ref()].as_ref(), + b"item", + Element::new_item(b"val".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + + // Delete the item (not a tree) via apply_operations_without_batching + // with explicit options to exercise as_delete_options(). + let ops = vec![QualifiedGroveDbOp::delete_op( + vec![b"my_tree".to_vec()], + b"item".to_vec(), + )]; + + let batch_options = Some(BatchApplyOptions::default()); + + db.apply_operations_without_batching(ops, batch_options, None, grove_version) + .unwrap() + .expect("delete item with options should succeed"); + + assert!( + db.get([b"my_tree".as_ref()].as_ref(), b"item", None, grove_version) + .unwrap() + .is_err(), + "item should have been deleted" + ); + } + + // =================================================================== + // Debug formatting + // =================================================================== + + #[test] + fn test_delete_tree_op_debug_format() { + // Verify the Debug impl includes the SubelementsDeletionBehavior. + let op = QualifiedGroveDbOp::delete_tree_op( + vec![b"root".to_vec()], + b"key".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DeleteChildren, + ); + let debug_str = format!("{:?}", op); + assert!( + debug_str.contains("DeleteChildren"), + "debug format should include the behavior variant, got: {}", + debug_str + ); + + let op2 = QualifiedGroveDbOp::delete_tree_op( + vec![], + b"k".to_vec(), + TreeType::SumTree, + SubelementsDeletionBehavior::Skip, + ); + let debug_str2 = format!("{:?}", op2); + assert!( + debug_str2.contains("Skip"), + "debug format should include Skip, got: {}", + debug_str2 + ); + } + + // =================================================================== + // DeleteChildren + child ops in same batch: architectural constraint + // =================================================================== + + #[test] + fn test_batch_delete_children_rejects_partial_child_delete_in_same_batch() { + // The batch system processes bottom-up: child ops first, then parent. + // If child ops produce a new root key (calculated_root_key is Some) + // for a tree being deleted, apply_body rejects it with + // "modification of tree when it will be deleted". + // + // This means: you cannot mix child-level ops with a DeleteTree of + // the parent UNLESS all children are deleted (leaving root_key = None). + // DeleteChildren is for deleting non-empty trees when NO other ops + // target inside that tree — the cleanup is done post-apply-body. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + + db.insert( + EMPTY_PATH, + b"parent", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert parent"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"child1", + Element::new_item(b"val1".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child1"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"child2", + Element::new_item(b"val2".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child2"); + + // Delete only child1 + DeleteTree(DeleteChildren) on parent. + // child2 remains, so calculated_root_key is Some → rejected. + let ops = vec![ + QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"child1".to_vec()), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"parent".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::DeleteChildren, + ), + ]; + + let result = db.apply_batch(ops, None, None, grove_version).unwrap(); + + match result { + Err(Error::InvalidBatchOperation(msg)) => { + assert!( + msg.contains("modification of tree when it will be deleted"), + "unexpected error message: {}", + msg + ); + } + Err(e) => panic!("expected InvalidBatchOperation, got: {:?}", e), + Ok(()) => panic!("expected error: can't mix child ops with DeleteTree in same batch"), + } + } + + #[test] + fn test_batch_delete_children_standalone_deletes_with_nested_subtrees() { + // DeleteChildren WITHOUT other child ops in the same batch. + // Parent has a child subtree (with deep data). Only the parent's + // DeleteTree is in the batch — cleanup is handled post-apply-body. let grove_version = GroveVersion::latest(); let db = make_empty_grovedb(); - let tx = db.start_transaction(); - // Create a tree with a nested subtree db.insert( EMPTY_PATH, - b"outer", + b"parent", Element::empty_tree(), None, - Some(&tx), + None, grove_version, ) .unwrap() - .expect("insert outer"); + .expect("insert parent"); db.insert( - [b"outer".as_ref()].as_ref(), - b"inner", + [b"parent".as_ref()].as_ref(), + b"item_child", + Element::new_item(b"data".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item child"); + + db.insert( + [b"parent".as_ref()].as_ref(), + b"tree_child", Element::empty_tree(), None, - Some(&tx), + None, grove_version, ) .unwrap() - .expect("insert inner"); + .expect("insert tree child"); db.insert( - [b"outer".as_ref(), b"inner".as_ref()].as_ref(), - b"data", - Element::new_item(b"some_value".to_vec()), + [b"parent".as_ref(), b"tree_child".as_ref()].as_ref(), + b"deep_item", + Element::new_item(b"deep_data".to_vec()), + None, None, - Some(&tx), grove_version, ) .unwrap() - .expect("insert data"); + .expect("insert deep item"); + // Only the parent DeleteTree — no other ops inside the subtree. let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], - b"outer".to_vec(), + b"parent".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::DeleteChildren, )]; - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, - ..Default::default() - }); - - db.apply_partial_batch( - ops, - batch_options, - |_cost, _left_over_ops| Ok(vec![]), - Some(&tx), - grove_version, - ) - .unwrap() - .expect("partial batch delete non-empty tree"); + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("DeleteChildren standalone should succeed"); - // Tree should be gone - let result = db - .get(EMPTY_PATH, b"outer", Some(&tx), grove_version) - .unwrap(); - assert!(result.is_err(), "tree should have been deleted"); + // Parent should be gone + assert!( + db.get(EMPTY_PATH, b"parent", None, grove_version) + .unwrap() + .is_err(), + "parent should have been deleted" + ); - // Re-insert and verify clean state (no stale data) + // Re-insert parent and verify no stale data from tree_child db.insert( EMPTY_PATH, - b"outer", + b"parent", Element::empty_tree(), None, - Some(&tx), + None, grove_version, ) .unwrap() - .expect("re-insert outer"); + .expect("re-insert parent"); - let inner_get = db - .get( - [b"outer".as_ref()].as_ref(), - b"inner", - Some(&tx), - grove_version, + assert!( + db.get( + [b"parent".as_ref()].as_ref(), + b"tree_child", + None, + grove_version ) - .unwrap(); + .unwrap() + .is_err(), + "tree_child should not exist in re-inserted parent" + ); + assert!( - inner_get.is_err(), - "re-inserted tree should be clean with no stale inner subtree" + db.get( + [b"parent".as_ref(), b"tree_child".as_ref()].as_ref(), + b"deep_item", + None, + grove_version + ) + .unwrap() + .is_err(), + "deep_item should not leak through after cleanup" ); } + // =================================================================== + // Non-Merk tree emptiness checks (CommitmentTree, MmrTree, etc.) + // =================================================================== + #[test] - fn test_partial_batch_delete_tree_with_simultaneous_child_delete() { - // Partial batch version of the is_empty_tree_except test. + fn test_batch_delete_non_merk_tree_error_when_non_empty() { + // Exercise the non-Merk branch of the emptiness check in + // apply_batch. CommitmentTree with data should fail with Error mode. let grove_version = GroveVersion::latest(); let db = make_empty_grovedb(); let tx = db.start_transaction(); db.insert( EMPTY_PATH, - b"parent", - Element::empty_tree(), + b"ct_err", + Element::empty_commitment_tree(4).expect("valid chunk_power"), None, Some(&tx), grove_version, ) .unwrap() - .expect("insert parent"); + .expect("insert commitment tree"); - db.insert( - [b"parent".as_ref()].as_ref(), - b"only_child", - Element::new_item(b"data".to_vec()), - None, + // Populate with one entry + db.commitment_tree_insert_raw( + EMPTY_PATH, + b"ct_err", + [1u8; 32], + [2u8; 32], + vec![0u8; 216], Some(&tx), grove_version, ) .unwrap() - .expect("insert only child"); + .expect("insert commitment tree data"); - // Delete the only child and the parent tree in the same batch - let ops = vec![ - QualifiedGroveDbOp::delete_op(vec![b"parent".to_vec()], b"only_child".to_vec()), - QualifiedGroveDbOp::delete_tree_op(vec![], b"parent".to_vec(), TreeType::NormalTree), - ]; + // Try to delete with Error mode — should fail because non-empty + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"ct_err".to_vec(), + grovedb_merk::tree_type::TreeType::CommitmentTree(4), + SubelementsDeletionBehavior::Error, + )]; - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, - ..Default::default() - }); + let result = db.apply_batch(ops, None, Some(&tx), grove_version).unwrap(); - db.apply_partial_batch( - ops, - batch_options, - |_cost, _left_over_ops| Ok(vec![]), + match result { + Err(Error::DeletingNonEmptyTree(_)) => { /* expected */ } + Err(e) => panic!("expected DeletingNonEmptyTree, got: {:?}", e), + Ok(()) => panic!("expected error but got Ok"), + } + } + + #[test] + fn test_batch_delete_non_merk_tree_skip_when_non_empty() { + // Non-Merk tree with Skip mode: should silently skip when non-empty. + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let tx = db.start_transaction(); + + db.insert( + EMPTY_PATH, + b"ct_skip", + Element::empty_commitment_tree(4).expect("valid chunk_power"), + None, Some(&tx), grove_version, ) .unwrap() - .expect("partial batch: should succeed since child is also being deleted"); + .expect("insert commitment tree"); - let result = db - .get(EMPTY_PATH, b"parent", Some(&tx), grove_version) - .unwrap(); - assert!(result.is_err(), "parent should have been deleted"); - } + db.commitment_tree_insert_raw( + EMPTY_PATH, + b"ct_skip", + [1u8; 32], + [2u8; 32], + vec![0u8; 216], + Some(&tx), + grove_version, + ) + .unwrap() + .expect("insert commitment tree data"); - // =================================================================== - // Transactional batch: emptiness check + Merk cleanup - // =================================================================== + let ops = vec![QualifiedGroveDbOp::delete_tree_op( + vec![], + b"ct_skip".to_vec(), + grovedb_merk::tree_type::TreeType::CommitmentTree(4), + SubelementsDeletionBehavior::Skip, + )]; + + db.apply_batch(ops, None, Some(&tx), grove_version) + .unwrap() + .expect("Skip mode should succeed"); + + // Tree should still exist + assert!( + db.get(EMPTY_PATH, b"ct_skip", Some(&tx), grove_version) + .unwrap() + .is_ok(), + "commitment tree should still exist after skipped delete" + ); + } #[test] - fn test_batch_delete_tree_non_empty_error_with_transaction() { - // Same as the non-transactional test but uses a transaction, - // exercising the apply_batch_with_element_flags_update code path - // which is slightly different (uses storage_batch). + fn test_partial_batch_delete_non_merk_tree_error_when_non_empty() { + // Same as above but via partial batch path. let grove_version = GroveVersion::latest(); let db = make_empty_grovedb(); let tx = db.start_transaction(); db.insert( EMPTY_PATH, - b"tree_tx", - Element::empty_tree(), + b"ct_perr", + Element::empty_commitment_tree(4).expect("valid chunk_power"), None, Some(&tx), grove_version, ) .unwrap() - .expect("insert tree"); + .expect("insert commitment tree"); - db.insert( - [b"tree_tx".as_ref()].as_ref(), - b"item", - Element::new_item(b"val".to_vec()), - None, + db.commitment_tree_insert_raw( + EMPTY_PATH, + b"ct_perr", + [1u8; 32], + [2u8; 32], + vec![0u8; 216], Some(&tx), grove_version, ) .unwrap() - .expect("insert item"); + .expect("insert commitment tree data"); let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], - b"tree_tx".to_vec(), - TreeType::NormalTree, + b"ct_perr".to_vec(), + grovedb_merk::tree_type::TreeType::CommitmentTree(4), + SubelementsDeletionBehavior::Error, )]; - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: true, - ..Default::default() - }); - let result = db - .apply_batch(ops, batch_options, Some(&tx), grove_version) + .apply_partial_batch( + ops, + None, + |_cost, _left_over_ops| Ok(vec![]), + Some(&tx), + grove_version, + ) .unwrap(); match result { @@ -982,86 +2280,53 @@ mod tests { } #[test] - fn test_batch_delete_tree_cleans_up_deeply_nested_subtrees() { - // Verify that recursive cleanup works for 3+ levels of nesting. + fn test_batch_delete_non_merk_tree_delete_children_mode() { + // Non-Merk tree with DeleteChildren mode: should succeed even + // when non-empty and proceed with deletion. let grove_version = GroveVersion::latest(); let db = make_empty_grovedb(); + let tx = db.start_transaction(); - // Create: root -> level1 -> level2 -> level3 -> item db.insert( EMPTY_PATH, - b"l1", - Element::empty_tree(), - None, - None, - grove_version, - ) - .unwrap() - .expect("insert l1"); - db.insert( - [b"l1".as_ref()].as_ref(), - b"l2", - Element::empty_tree(), - None, - None, - grove_version, - ) - .unwrap() - .expect("insert l2"); - db.insert( - [b"l1".as_ref(), b"l2".as_ref()].as_ref(), - b"l3", - Element::empty_tree(), - None, + b"ct_dc", + Element::empty_commitment_tree(4).expect("valid chunk_power"), None, + Some(&tx), grove_version, ) .unwrap() - .expect("insert l3"); - db.insert( - [b"l1".as_ref(), b"l2".as_ref(), b"l3".as_ref()].as_ref(), - b"item", - Element::new_item(b"deep_value".to_vec()), - None, - None, + .expect("insert commitment tree"); + + db.commitment_tree_insert_raw( + EMPTY_PATH, + b"ct_dc", + [1u8; 32], + [2u8; 32], + vec![0u8; 216], + Some(&tx), grove_version, ) .unwrap() - .expect("insert deep item"); + .expect("insert commitment tree data"); - // Delete the top-level tree let ops = vec![QualifiedGroveDbOp::delete_tree_op( vec![], - b"l1".to_vec(), - TreeType::NormalTree, + b"ct_dc".to_vec(), + grovedb_merk::tree_type::TreeType::CommitmentTree(4), + SubelementsDeletionBehavior::DeleteChildren, )]; - let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: true, - ..Default::default() - }); - - db.apply_batch(ops, batch_options, None, grove_version) + db.apply_batch(ops, None, Some(&tx), grove_version) .unwrap() - .expect("delete deeply nested tree"); - - // Re-insert and verify clean state at all levels - db.insert( - EMPTY_PATH, - b"l1", - Element::empty_tree(), - None, - None, - grove_version, - ) - .unwrap() - .expect("re-insert l1"); + .expect("DeleteChildren on non-empty non-Merk tree should succeed"); - // l2 should not exist in the re-inserted tree - let l2_get = db - .get([b"l1".as_ref()].as_ref(), b"l2", None, grove_version) - .unwrap(); - assert!(l2_get.is_err(), "l2 should not exist in re-inserted tree"); + assert!( + db.get(EMPTY_PATH, b"ct_dc", Some(&tx), grove_version) + .unwrap() + .is_err(), + "commitment tree should have been deleted" + ); } #[test] @@ -1107,8 +2372,18 @@ mod tests { // Batch: delete non-empty tree (should be skipped) and delete empty tree // (should succeed), plus insert an item let ops = vec![ - QualifiedGroveDbOp::delete_tree_op(vec![], b"non_empty".to_vec(), TreeType::NormalTree), - QualifiedGroveDbOp::delete_tree_op(vec![], b"empty_one".to_vec(), TreeType::NormalTree), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"non_empty".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Skip, + ), + QualifiedGroveDbOp::delete_tree_op( + vec![], + b"empty_one".to_vec(), + TreeType::NormalTree, + SubelementsDeletionBehavior::Skip, + ), QualifiedGroveDbOp::insert_or_replace_op( vec![], b"new_item".to_vec(), @@ -1117,8 +2392,6 @@ mod tests { ]; let batch_options = Some(BatchApplyOptions { - allow_deleting_non_empty_trees: false, - deleting_non_empty_trees_returns_error: false, ..Default::default() }); diff --git a/grovedb/src/tests/batch_unit_tests.rs b/grovedb/src/tests/batch_unit_tests.rs index 331e7f4ca..e43b9208c 100644 --- a/grovedb/src/tests/batch_unit_tests.rs +++ b/grovedb/src/tests/batch_unit_tests.rs @@ -12,7 +12,9 @@ mod tests { use grovedb_version::version::GroveVersion; use crate::batch::key_info::KeyInfo::KnownKey; - use crate::batch::{GroveOp, KeyInfoPath, NonMerkTreeMeta, QualifiedGroveDbOp}; + use crate::batch::{ + GroveOp, KeyInfoPath, NonMerkTreeMeta, QualifiedGroveDbOp, SubelementsDeletionBehavior, + }; use crate::reference_path::ReferencePathType; use crate::tests::{common::EMPTY_PATH, make_empty_grovedb, make_test_grovedb, TEST_LEAF}; use crate::Element; @@ -83,8 +85,8 @@ mod tests { }; let all_ops: Vec = vec![ - GroveOp::DeleteTree(TreeType::NormalTree), // 0 - GroveOp::Delete, // 2 + GroveOp::DeleteTree(TreeType::NormalTree, SubelementsDeletionBehavior::Error), // 0 + GroveOp::Delete, // 2 GroveOp::InsertTreeWithRootHash { // 3 hash: dummy_hash, @@ -315,8 +317,12 @@ mod tests { path.clone(), key.clone(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, ); - assert!(matches!(op.op, GroveOp::DeleteTree(TreeType::NormalTree))); + assert!(matches!( + op.op, + GroveOp::DeleteTree(TreeType::NormalTree, SubelementsDeletionBehavior::Error) + )); } #[test] @@ -372,6 +378,7 @@ mod tests { vec![b"p".to_vec()], b"k".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, ), "Delete Tree", ), @@ -555,6 +562,7 @@ mod tests { vec![b"root".to_vec()], b"subtree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, ), QualifiedGroveDbOp::insert_or_replace_op( vec![b"root".to_vec(), b"subtree".to_vec()], diff --git a/grovedb/src/tests/misc_coverage_tests.rs b/grovedb/src/tests/misc_coverage_tests.rs index bdfa4e154..49659fa07 100644 --- a/grovedb/src/tests/misc_coverage_tests.rs +++ b/grovedb/src/tests/misc_coverage_tests.rs @@ -32,7 +32,7 @@ use crate::{ batch::{ estimated_costs::EstimatedCostsType::{AverageCaseCostsType, WorstCaseCostsType}, key_info::KeyInfo, - KeyInfoPath, QualifiedGroveDbOp, + KeyInfoPath, QualifiedGroveDbOp, SubelementsDeletionBehavior, }, operations::proof::util::{ element_hex_to_ascii, hex_to_ascii, optional_element_hex_to_ascii, @@ -1486,6 +1486,7 @@ fn batch_worst_case_delete_tree_cost() { vec![b"leaf".to_vec()], b"child_tree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let mut paths = HashMap::new(); @@ -1523,6 +1524,7 @@ fn batch_average_case_delete_tree_cost() { vec![b"leaf".to_vec()], b"child_tree".to_vec(), TreeType::NormalTree, + SubelementsDeletionBehavior::Error, )]; let mut paths = HashMap::new(); @@ -2357,6 +2359,7 @@ fn batch_worst_case_delete_sum_tree_cost() { vec![b"leaf".to_vec()], b"sum_tree_del".to_vec(), TreeType::SumTree, + SubelementsDeletionBehavior::Error, )]; let mut paths = HashMap::new(); @@ -2394,6 +2397,7 @@ fn batch_average_case_delete_sum_tree_cost() { vec![b"leaf".to_vec()], b"sum_tree_del".to_vec(), TreeType::SumTree, + SubelementsDeletionBehavior::Error, )]; let mut paths = HashMap::new();