Skip to main content

elasticctl_api/data_views_ops/
delete.rs

1use 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}