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