elasticctl_api/data_views_ops/
delete.rs1use super::{
2 BTreeSet, DataViewReference, DataViewSummary, DeleteOutcome, Error, ErrorKind, MutationPlan,
3 Result, Transport, data_views, invalid_plan, json, resolve_from_summaries,
4};
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct DataViewDeleteTarget {
8 pub source: DataViewSummary,
9 pub references: Vec<DataViewReference>,
10 pub was_default: bool,
11}
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct DataViewDeletePlan {
15 pub preview: MutationPlan,
16 pub targets: Vec<DataViewDeleteTarget>,
17 pub replacement: Option<DataViewSummary>,
18 pub default_before: Option<String>,
19}
20pub async fn plan_delete(
21 transport: &Transport,
22 selectors: &[String],
23 replacement_selector: Option<&str>,
24) -> Result<DataViewDeletePlan> {
25 if selectors.is_empty() {
26 return Err(Error::new(
27 ErrorKind::Error,
28 "data-view delete needs at least one source",
29 ));
30 }
31
32 let views = data_views::list(transport).await?;
33 let mut seen = BTreeSet::new();
34 let mut sources = Vec::new();
35 for selector in selectors {
36 let source = resolve_from_summaries(&views, selector)?;
37 if seen.insert(source.id.clone()) {
38 sources.push(source);
39 }
40 }
41 let replacement = replacement_selector
42 .map(|selector| resolve_from_summaries(&views, selector))
43 .transpose()?;
44
45 if replacement.is_some() && sources.len() != 1 {
46 return Err(Error::new(
47 ErrorKind::Error,
48 "--replace-with accepts exactly one source data view",
49 ));
50 }
51 if let Some(replacement) = &replacement
52 && sources.iter().any(|source| source.id == replacement.id)
53 {
54 return Err(Error::new(
55 ErrorKind::Error,
56 "--replace-with must differ from every source data view",
57 ));
58 }
59
60 let default_before = data_views::get_default(transport).await?;
61 let mut targets = Vec::with_capacity(sources.len());
62 for source in sources {
63 let mut references = data_views::preview_swap(transport, &source.id, &source.id).await?;
64 normalize_references(&mut references)?;
65 targets.push(DataViewDeleteTarget {
66 was_default: default_before.as_deref() == Some(source.id.as_str()),
67 source,
68 references,
69 });
70 }
71
72 if replacement.is_none() {
73 let referenced: Vec<_> = targets
74 .iter()
75 .filter(|target| !target.references.is_empty())
76 .map(|target| {
77 format!(
78 "{}: {}",
79 target.source.id,
80 reference_names(&target.references).join(", ")
81 )
82 })
83 .collect();
84 if !referenced.is_empty() {
85 return Err(Error::new(
86 ErrorKind::Conflict,
87 format!(
88 "data views have live references; use --replace-with: {}",
89 referenced.join("; ")
90 ),
91 ));
92 }
93 let defaults: Vec<_> = targets
94 .iter()
95 .filter(|target| target.was_default)
96 .map(|target| target.source.id.as_str())
97 .collect();
98 if !defaults.is_empty() {
99 return Err(Error::new(
100 ErrorKind::Conflict,
101 format!(
102 "data views are the current default; use data-views default unset or --replace-with: {}",
103 defaults.join(", ")
104 ),
105 ));
106 }
107 }
108
109 Ok(DataViewDeletePlan {
110 preview: delete_preview(&targets, replacement.as_ref()),
111 targets,
112 replacement,
113 default_before,
114 })
115}
116
117pub async fn apply_delete(
118 transport: &Transport,
119 plan: &DataViewDeletePlan,
120) -> Result<DeleteOutcome> {
121 validate_delete_plan(plan)?;
122 let mut deleted = Vec::new();
123 let mut failed = Vec::new();
124
125 match (&plan.replacement, plan.targets.as_slice()) {
126 (None, targets) => {
127 for target in targets {
128 if let Err(error) =
129 check_delete_snapshot(transport, target, plan.default_before.as_deref()).await
130 {
131 failed.push(json!({"id": target.source.id, "error": error.message}));
132 continue;
133 }
134 match data_views::delete(transport, &target.source.id).await {
135 Ok(()) => deleted.push(json!({"id": target.source.id})),
136 Err(error) => {
137 failed.push(json!({"id": target.source.id, "error": error.message}))
138 }
139 }
140 }
141 }
142 (Some(replacement), [target]) => {
143 if let Err(error) =
144 check_delete_snapshot(transport, target, plan.default_before.as_deref()).await
145 {
146 failed.push(json!({"id": target.source.id, "error": error.message}));
147 return Ok(DeleteOutcome {
148 applied: true,
149 deleted,
150 failed,
151 total: plan.targets.len(),
152 });
153 }
154 match data_views::swap(transport, &target.source.id, &replacement.id).await {
155 Err(error) => failed.push(json!({"id": target.source.id, "error": error.message})),
156 Ok(swap)
157 if !swap.delete_status.delete_performed
158 || swap.delete_status.remaining_refs != 0 =>
159 {
160 failed.push(json!({
161 "id": target.source.id,
162 "error": "reference swap did not delete source or left references"
163 }));
164 }
165 Ok(_) if target.was_default => {
166 match data_views::get_default(transport).await {
167 Err(error) => failed.push(json!({
168 "id": target.source.id,
169 "error": format!("references moved and source deleted; default recheck failed: {}", error.message)
170 })),
171 Ok(Some(current)) if current == target.source.id => {
172 if let Err(error) =
173 data_views::set_default(transport, Some(&replacement.id)).await
174 {
175 failed.push(json!({
176 "id": target.source.id,
177 "error": format!("references moved and source deleted; default update failed: {}", error.message)
178 }));
179 } else {
180 deleted.push(json!({"id": target.source.id}));
181 }
182 }
183 Ok(Some(current)) if current == replacement.id => {
184 deleted.push(json!({"id": target.source.id}));
185 }
186 Ok(_) => failed.push(json!({
187 "id": target.source.id,
188 "error": "references moved and source deleted; default changed before update"
189 })),
190 }
191 }
192 Ok(_) => deleted.push(json!({"id": target.source.id})),
193 }
194 }
195 _ => unreachable!("the public plan validator checked replacement cardinality"),
196 }
197
198 Ok(DeleteOutcome {
199 applied: true,
200 deleted,
201 failed,
202 total: plan.targets.len(),
203 })
204}
205
206async fn check_delete_snapshot(
207 transport: &Transport,
208 target: &DataViewDeleteTarget,
209 default_before: Option<&str>,
210) -> Result<()> {
211 let mut references =
212 data_views::preview_swap(transport, &target.source.id, &target.source.id).await?;
213 normalize_references(&mut references)?;
214 let default = data_views::get_default(transport).await?;
215 if references != target.references {
216 return Err(Error::new(
217 ErrorKind::Conflict,
218 "references changed since preview",
219 ));
220 }
221 if default.as_deref() != default_before {
222 return Err(Error::new(
223 ErrorKind::Conflict,
224 "default data view changed since preview",
225 ));
226 }
227 Ok(())
228}
229
230fn normalize_references(references: &mut Vec<DataViewReference>) -> Result<()> {
231 if references
232 .iter()
233 .any(|reference| reference.id.trim().is_empty() || reference.object_type.trim().is_empty())
234 {
235 return Err(Error::new(
236 ErrorKind::Http,
237 "decoding data view reference swap preview: reference id and type must not be empty",
238 ));
239 }
240 references.sort_by(|left, right| {
241 (left.object_type.as_str(), left.id.as_str())
242 .cmp(&(right.object_type.as_str(), right.id.as_str()))
243 });
244 references.dedup_by(|left, right| left.object_type == right.object_type && left.id == right.id);
245 Ok(())
246}
247
248fn reference_names(references: &[DataViewReference]) -> Vec<String> {
249 references
250 .iter()
251 .map(|reference| format!("{}/{}", reference.object_type, reference.id))
252 .collect()
253}
254
255fn delete_preview(
256 targets: &[DataViewDeleteTarget],
257 replacement: Option<&DataViewSummary>,
258) -> MutationPlan {
259 let preview_details = targets
260 .iter()
261 .flat_map(|target| {
262 let mut details: Vec<_> = reference_names(&target.references)
263 .into_iter()
264 .map(|reference| format!("{} {reference}", target.source.id))
265 .collect();
266 match replacement {
267 None => details.push(format!("{} direct delete", target.source.id)),
268 Some(replacement) if target.was_default => details.push(format!(
269 "{} default -> {}",
270 target.source.id, replacement.id
271 )),
272 Some(_) => {}
273 }
274 details
275 })
276 .collect();
277 MutationPlan {
278 preview_action: match replacement {
279 Some(replacement) => format!(
280 "Replace references and delete {} with {}",
281 targets[0].source.id, replacement.id
282 ),
283 None => format!("Delete {} data view(s)", targets.len()),
284 },
285 preview_details,
286 targets: targets
287 .iter()
288 .map(|target| target.source.id.clone())
289 .collect(),
290 }
291}
292
293fn validate_delete_plan(plan: &DataViewDeletePlan) -> Result<()> {
294 if plan.targets.is_empty() {
295 return invalid_plan("data-view delete plan needs at least one target");
296 }
297 let mut ids = BTreeSet::new();
298 if plan
299 .default_before
300 .as_deref()
301 .is_some_and(|id| id.trim().is_empty())
302 {
303 return invalid_plan("default data-view snapshot id must not be empty");
304 }
305 for target in &plan.targets {
306 if target.source.id.trim().is_empty() || target.source.title.trim().is_empty() {
307 return invalid_plan("data-view delete target identity must not be empty");
308 }
309 if !ids.insert(target.source.id.clone()) {
310 return invalid_plan("data-view delete targets must be unique by id");
311 }
312 if target.was_default != (plan.default_before.as_deref() == Some(target.source.id.as_str()))
313 {
314 return invalid_plan("data-view delete default claims do not match its snapshot");
315 }
316 let mut canonical = target.references.clone();
317 normalize_references(&mut canonical).map_err(|error| {
318 Error::new(
319 ErrorKind::Error,
320 format!("invalid data-view delete plan: {}", error.message),
321 )
322 })?;
323 if canonical != target.references {
324 return invalid_plan("data-view delete references must be sorted and unique");
325 }
326 }
327 if let Some(replacement) = &plan.replacement {
328 if plan.targets.len() != 1 {
329 return invalid_plan("replacement delete plan must have exactly one target");
330 }
331 if replacement.id.trim().is_empty() || replacement.title.trim().is_empty() {
332 return invalid_plan("replacement data-view identity must not be empty");
333 }
334 if replacement.id == plan.targets[0].source.id {
335 return invalid_plan("replacement data view must differ from its source");
336 }
337 } else if plan
338 .targets
339 .iter()
340 .any(|target| target.was_default || !target.references.is_empty())
341 {
342 return invalid_plan("direct delete plan contains default or referenced data view");
343 }
344
345 if plan.preview != delete_preview(&plan.targets, plan.replacement.as_ref()) {
346 return invalid_plan("data-view delete preview does not match guarded targets");
347 }
348 Ok(())
349}