Skip to main content

elasticctl_api/data_views_ops/
import.rs

1use super::{
2    BTreeMap, BTreeSet, DataViewSpec, DataViewUpdate, Error, ErrorKind, Map, MutationPlan, Path,
3    Result, Serialize, Transport, Value, data_views, invalid_plan, json, normalize, validate,
4};
5
6#[derive(Debug, Clone, Default, PartialEq, Serialize)]
7pub struct DataViewPatch {
8    pub base: Option<DataViewUpdate>,
9    pub field_metadata: Map<String, Value>,
10}
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct DataViewImportPlan {
14    pub preview: MutationPlan,
15    pub specs: Vec<DataViewSpec>,
16    pub before: BTreeMap<String, Option<DataViewSpec>>,
17    pub patches: BTreeMap<String, DataViewPatch>,
18    pub skipped: Vec<Value>,
19    pub total: usize,
20    pub overwrite: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize)]
24pub struct DataViewImportReport {
25    pub applied: bool,
26    pub succeeded: Vec<Value>,
27    pub skipped: Vec<Value>,
28    pub failed: Vec<Value>,
29    pub total: usize,
30}
31pub fn build_patch(current: &DataViewSpec, desired: &DataViewSpec) -> Result<DataViewPatch> {
32    current.validate()?;
33    desired.validate()?;
34    if current.id != desired.id {
35        return unsupported("changing data view id is not supported by the data-view update API");
36    }
37    if current.allow_hidden != desired.allow_hidden {
38        return unsupported("changing allowHidden is not supported by the data-view update API");
39    }
40    if current.name.is_some() && desired.name.is_none() {
41        return unsupported("removing name is not supported by the data-view update API");
42    }
43    if current.time_field_name.is_some() && desired.time_field_name.is_none() {
44        return unsupported("removing timeFieldName is not supported by the data-view update API");
45    }
46    if current.view_type.is_some() && desired.view_type.is_none() {
47        return unsupported("removing type is not supported by the data-view update API");
48    }
49    if current.type_meta.is_some() && desired.type_meta.is_none() {
50        return unsupported("removing typeMeta is not supported by the data-view update API");
51    }
52
53    let base = DataViewUpdate {
54        allow_no_index: changed(&current.allow_no_index, &desired.allow_no_index)
55            .then_some(desired.allow_no_index),
56        field_formats: changed(&current.field_formats, &desired.field_formats)
57            .then(|| desired.field_formats.clone()),
58        fields: changed(&current.fields, &desired.fields).then(|| desired.fields.clone()),
59        name: changed(&current.name, &desired.name)
60            .then(|| desired.name.clone())
61            .flatten(),
62        runtime_field_map: changed(&current.runtime_field_map, &desired.runtime_field_map)
63            .then(|| desired.runtime_field_map.clone()),
64        source_filters: changed(&current.source_filters, &desired.source_filters)
65            .then(|| desired.source_filters.clone()),
66        time_field_name: changed(&current.time_field_name, &desired.time_field_name)
67            .then(|| desired.time_field_name.clone())
68            .flatten(),
69        title: changed(&current.title, &desired.title).then(|| desired.title.clone()),
70        view_type: changed(&current.view_type, &desired.view_type)
71            .then(|| desired.view_type.clone())
72            .flatten(),
73        type_meta: changed(&current.type_meta, &desired.type_meta)
74            .then(|| desired.type_meta.clone())
75            .flatten(),
76    };
77    let base = (!is_empty_update(&base)).then_some(base);
78
79    let mut field_metadata = Map::new();
80    let names: BTreeSet<_> = current
81        .field_attrs
82        .keys()
83        .chain(desired.field_attrs.keys())
84        .collect();
85    for name in names {
86        let current_values = current.field_attrs.get(name).and_then(Value::as_object);
87        let desired_values = desired.field_attrs.get(name).and_then(Value::as_object);
88        let keys: BTreeSet<_> = current_values
89            .into_iter()
90            .flat_map(|values| values.keys())
91            .chain(desired_values.into_iter().flat_map(|values| values.keys()))
92            .collect();
93        let mut delta = Map::new();
94        for key in keys {
95            let before = current_values.and_then(|values| values.get(key));
96            let after = desired_values.and_then(|values| values.get(key));
97            if before != after {
98                delta.insert(key.clone(), after.cloned().unwrap_or(Value::Null));
99            }
100        }
101        if !delta.is_empty() {
102            field_metadata.insert(name.clone(), Value::Object(delta));
103        }
104    }
105    Ok(DataViewPatch {
106        base,
107        field_metadata,
108    })
109}
110
111pub async fn plan_import(
112    transport: Option<&Transport>,
113    path: &Path,
114    overwrite: bool,
115    skip_existing: bool,
116) -> Result<DataViewImportPlan> {
117    let mut specs = validate(path)?;
118    if specs.is_empty() {
119        return Err(Error::new(
120            ErrorKind::Error,
121            "data-view import needs at least one data view",
122        ));
123    }
124    if overwrite && skip_existing {
125        return Err(Error::new(
126            ErrorKind::Error,
127            "--overwrite and --skip-existing cannot be used together",
128        ));
129    }
130    let total = specs.len();
131    let requires_server = overwrite || skip_existing || transport.is_some();
132    let transport = if requires_server { transport } else { None };
133    if (overwrite || skip_existing) && transport.is_none() {
134        return Err(Error::new(
135            ErrorKind::Error,
136            "data-view import conflict mode needs a transport",
137        ));
138    }
139
140    let mut before = BTreeMap::new();
141    let mut skipped = Vec::new();
142    let mut conflicts = Vec::new();
143    if let Some(transport) = transport {
144        for spec in &specs {
145            match read_spec(transport, &spec.id).await {
146                Ok(current) => {
147                    if !overwrite && !skip_existing {
148                        conflicts.push(spec.id.clone());
149                    }
150                    before.insert(spec.id.clone(), Some(current));
151                }
152                Err(error) if error.kind == ErrorKind::NotFound => {
153                    before.insert(spec.id.clone(), None);
154                }
155                Err(error) => return Err(error),
156            }
157        }
158    } else {
159        before.extend(specs.iter().map(|spec| (spec.id.clone(), None)));
160    }
161    if !conflicts.is_empty() {
162        return Err(Error::new(
163            ErrorKind::Conflict,
164            format!("data views already exist: {}", conflicts.join(", ")),
165        ));
166    }
167    if skip_existing {
168        specs.retain(|spec| match before.get(&spec.id) {
169            Some(Some(_)) => {
170                skipped.push(json!({"id": spec.id, "reason": "exists"}));
171                false
172            }
173            _ => true,
174        });
175        before.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
176    }
177
178    let mut patches = BTreeMap::new();
179    for spec in &specs {
180        match before.get(&spec.id).and_then(Option::as_ref) {
181            Some(current) if current == spec => {
182                patches.insert(spec.id.clone(), DataViewPatch::default());
183            }
184            Some(current) => {
185                let patch = build_patch(current, spec)?;
186                patches.insert(spec.id.clone(), patch);
187            }
188            None => {}
189        }
190    }
191    let preview = MutationPlan {
192        preview_action: format!(
193            "Import {} data view(s) from {}",
194            specs.len(),
195            path.display()
196        ),
197        preview_details: preview_details(&specs, &before),
198        targets: specs.iter().map(|spec| spec.id.clone()).collect(),
199    };
200    Ok(DataViewImportPlan {
201        preview,
202        specs,
203        before,
204        patches,
205        skipped,
206        total,
207        overwrite,
208    })
209}
210
211pub async fn apply_import(
212    transport: &Transport,
213    plan: &DataViewImportPlan,
214) -> Result<DataViewImportReport> {
215    validate_import_plan(plan)?;
216    let mut succeeded = Vec::new();
217    let mut failed = Vec::new();
218
219    for desired in &plan.specs {
220        let before = match plan.before.get(&desired.id) {
221            Some(before) => before,
222            None => {
223                failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
224                continue;
225            }
226        };
227        let current = match read_spec(transport, &desired.id).await {
228            Ok(current) => Some(current),
229            Err(error) if error.kind == ErrorKind::NotFound => None,
230            Err(error) => {
231                failed.push(failed_row(&desired.id, false, error.message));
232                continue;
233            }
234        };
235
236        match (before, current) {
237            (None, Some(_)) => {
238                failed.push(failed_row(
239                    &desired.id,
240                    false,
241                    "data view appeared since preview",
242                ));
243                continue;
244            }
245            (Some(_), None) => {
246                failed.push(failed_row(
247                    &desired.id,
248                    false,
249                    "data view disappeared since preview",
250                ));
251                continue;
252            }
253            (Some(before), Some(current)) if before != &current => {
254                failed.push(failed_row(
255                    &desired.id,
256                    false,
257                    "data view changed since preview",
258                ));
259                continue;
260            }
261            (None, None) => {
262                let mut applied = false;
263                match data_views::create(transport, desired).await {
264                    Ok(_) => applied = true,
265                    Err(error) => {
266                        failed.push(failed_row(&desired.id, applied, error.message));
267                        continue;
268                    }
269                }
270                match read_spec(transport, &desired.id).await {
271                    Ok(stored) if stored == *desired => {
272                        succeeded.push(json!({"id": desired.id, "action": "created"}));
273                    }
274                    Ok(_) => failed.push(failed_row(
275                        &desired.id,
276                        applied,
277                        "server stored a different data-view spec",
278                    )),
279                    Err(error) => failed.push(failed_row(&desired.id, applied, error.message)),
280                }
281            }
282            (Some(_), Some(_)) => {
283                let patch = match plan.patches.get(&desired.id) {
284                    Some(patch) => patch,
285                    None => {
286                        failed.push(failed_row(&desired.id, false, "missing replacement patch"));
287                        continue;
288                    }
289                };
290                if patch.base.is_none() && patch.field_metadata.is_empty() {
291                    succeeded.push(json!({"id": desired.id, "action": "unchanged"}));
292                    continue;
293                }
294
295                let mut applied = false;
296                if let Some(base) = &patch.base {
297                    if let Err(error) = data_views::update(transport, &desired.id, base).await {
298                        failed.push(failed_row(
299                            &desired.id,
300                            applied,
301                            format!("base update failed: {}", error.message),
302                        ));
303                        continue;
304                    }
305                    applied = true;
306                }
307                if !patch.field_metadata.is_empty()
308                    && let Err(error) = data_views::update_fields_metadata(
309                        transport,
310                        &desired.id,
311                        &patch.field_metadata,
312                    )
313                    .await
314                {
315                    let message = if applied {
316                        format!("base updated; field metadata failed: {}", error.message)
317                    } else {
318                        format!("field metadata failed: {}", error.message)
319                    };
320                    failed.push(failed_row(&desired.id, applied, message));
321                    continue;
322                }
323                if !patch.field_metadata.is_empty() {
324                    applied = true;
325                }
326                match read_spec(transport, &desired.id).await {
327                    Ok(stored) if stored == *desired => {
328                        succeeded.push(json!({"id": desired.id, "action": "replaced"}));
329                    }
330                    Ok(_) => failed.push(failed_row(
331                        &desired.id,
332                        applied,
333                        "server stored a different data-view spec",
334                    )),
335                    Err(error) => failed.push(failed_row(&desired.id, applied, error.message)),
336                }
337            }
338        }
339    }
340
341    Ok(DataViewImportReport {
342        applied: true,
343        succeeded,
344        skipped: plan.skipped.clone(),
345        failed,
346        total: plan.total,
347    })
348}
349
350fn changed<T: PartialEq>(current: &T, desired: &T) -> bool {
351    current != desired
352}
353
354fn is_empty_update(update: &DataViewUpdate) -> bool {
355    update.allow_no_index.is_none()
356        && update.field_formats.is_none()
357        && update.fields.is_none()
358        && update.name.is_none()
359        && update.runtime_field_map.is_none()
360        && update.source_filters.is_none()
361        && update.time_field_name.is_none()
362        && update.title.is_none()
363        && update.view_type.is_none()
364        && update.type_meta.is_none()
365}
366
367fn unsupported(message: impl Into<String>) -> Result<DataViewPatch> {
368    Err(Error::new(ErrorKind::Unsupported, message))
369}
370
371fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
372    json!({"id": id, "applied": applied, "error": error.into()})
373}
374
375async fn read_spec(transport: &Transport, id: &str) -> Result<DataViewSpec> {
376    let spec = normalize(&Value::Object(
377        data_views::get(transport, id).await?.data_view,
378    ))?;
379    if spec.id != id {
380        return Err(Error::new(
381            ErrorKind::Http,
382            format!("decoding data view: expected id '{id}', got '{}'", spec.id),
383        ));
384    }
385    Ok(spec)
386}
387
388fn validate_import_plan(plan: &DataViewImportPlan) -> Result<()> {
389    if plan.total == 0 {
390        return invalid_plan("total must be greater than zero");
391    }
392    if plan.total != plan.specs.len() + plan.skipped.len() {
393        return invalid_plan("total does not equal pending and skipped data views");
394    }
395
396    let mut pending_ids = Vec::with_capacity(plan.specs.len());
397    for spec in &plan.specs {
398        validate_canonical_spec(spec)?;
399        if pending_ids
400            .last()
401            .is_some_and(|previous| previous >= &spec.id)
402        {
403            return invalid_plan("pending data views must be unique and sorted by id");
404        }
405        pending_ids.push(spec.id.clone());
406    }
407    let pending: BTreeSet<_> = pending_ids.iter().cloned().collect();
408    if !plan.skipped.is_empty() && plan.overwrite {
409        return invalid_plan("skipped data views require skip-existing mode");
410    }
411    let mut skipped_ids = BTreeSet::new();
412    let mut previous_skipped = None;
413    for row in &plan.skipped {
414        let object = row
415            .as_object()
416            .filter(|object| object.len() == 2)
417            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid data-view import skipped row"))?;
418        if !object.keys().map(String::as_str).eq(["id", "reason"]) {
419            return invalid_plan("invalid data-view import skipped row order");
420        }
421        let id = object
422            .get("id")
423            .and_then(Value::as_str)
424            .filter(|id| !id.trim().is_empty())
425            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid data-view import skipped row"))?;
426        if object.get("reason").and_then(Value::as_str) != Some("exists")
427            || previous_skipped.is_some_and(|previous: &str| previous >= id)
428            || !skipped_ids.insert(id.to_owned())
429            || pending.contains(id)
430        {
431            return invalid_plan("invalid data-view import skipped rows");
432        }
433        previous_skipped = Some(id);
434    }
435    if plan.preview.targets != pending_ids {
436        return invalid_plan("preview targets do not match pending data views");
437    }
438    let prefix = format!("Import {} data view(s) from ", plan.specs.len());
439    if !plan.preview.preview_action.starts_with(&prefix)
440        || plan.preview.preview_action[prefix.len()..].is_empty()
441    {
442        return invalid_plan("preview action does not match pending data views");
443    }
444    if plan.preview.preview_details != preview_details(&plan.specs, &plan.before) {
445        return invalid_plan("preview details do not match pending data views");
446    }
447
448    let before_ids: BTreeSet<_> = plan.before.keys().cloned().collect();
449    if before_ids != pending {
450        return invalid_plan("preflight snapshots do not match pending data views");
451    }
452    let mut patch_ids = BTreeSet::new();
453    for spec in &plan.specs {
454        let before = plan.before.get(&spec.id).ok_or_else(|| {
455            Error::new(
456                ErrorKind::Error,
457                "preflight snapshots do not match pending data views",
458            )
459        })?;
460        match before {
461            None => {
462                if plan.patches.contains_key(&spec.id) {
463                    return invalid_plan("planned creates must not carry a replacement patch");
464                }
465            }
466            Some(snapshot) => {
467                validate_canonical_spec(snapshot)?;
468                if snapshot.id != spec.id {
469                    return invalid_plan("preflight snapshot id does not match its target");
470                }
471                if !plan.overwrite {
472                    return invalid_plan("replacement plan requires overwrite");
473                }
474                let expected = build_patch(snapshot, spec)?;
475                let patch = plan.patches.get(&spec.id).ok_or_else(|| {
476                    Error::new(ErrorKind::Error, "planned replacement is missing its patch")
477                })?;
478                if patch != &expected {
479                    return invalid_plan("planned replacement patch does not match its snapshots");
480                }
481                patch_ids.insert(spec.id.clone());
482            }
483        }
484    }
485    if plan.patches.keys().cloned().collect::<BTreeSet<_>>() != patch_ids {
486        return invalid_plan("replacement patches do not match preflight snapshots");
487    }
488    Ok(())
489}
490
491fn validate_canonical_spec(spec: &DataViewSpec) -> Result<()> {
492    spec.validate()?;
493    if matches!(spec.type_meta.as_ref(), Some(values) if values.is_empty()) {
494        return Err(Error::new(
495            ErrorKind::Error,
496            "data view typeMeta must not be empty",
497        ));
498    }
499    Ok(())
500}
501
502fn preview_details(
503    specs: &[DataViewSpec],
504    before: &BTreeMap<String, Option<DataViewSpec>>,
505) -> Vec<String> {
506    specs
507        .iter()
508        .filter_map(|spec| match before.get(&spec.id) {
509            Some(None) => Some(format!("{}  create  {}", spec.id, spec.title)),
510            Some(Some(current)) if current == spec => {
511                Some(format!("{}  no-op  {}", spec.id, spec.title))
512            }
513            Some(Some(current)) => Some(format!(
514                "{}  replace  {} -> {}",
515                spec.id, current.title, spec.title
516            )),
517            None => None,
518        })
519        .collect()
520}