Skip to main content

elasticctl_api/
data_views_ops.rs

1//! Data-view selection, portable normalization, and read orchestration.
2
3use crate::content_codec::{self, ContentFormat};
4use crate::data_views::{
5    self, DataView, DataViewReference, DataViewSpec, DataViewSummary, DataViewUpdate,
6};
7use crate::ops::{DeleteOutcome, ExportOutcome, MutationPlan};
8use elasticctl_core::{Error, ErrorKind, Result, Transport};
9use serde::Serialize;
10use serde_json::{Map, Value, json};
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14mod defaults;
15mod delete;
16mod import;
17pub use defaults::{DefaultPlan, apply_default, plan_default_set, plan_default_unset};
18pub use delete::{DataViewDeletePlan, DataViewDeleteTarget, apply_delete, plan_delete};
19pub use import::{
20    DataViewImportPlan, DataViewImportReport, DataViewPatch, apply_import, build_patch, plan_import,
21};
22
23/// A local, case-insensitive substring filter for data-view summaries.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct DataViewFilter {
26    pub search: Option<String>,
27}
28
29/// Data-view list output after local filtering and stable-id sorting.
30#[derive(Debug, Clone, PartialEq, Serialize)]
31pub struct DataViewList {
32    pub total: usize,
33    pub data_views: Vec<DataViewSummary>,
34}
35
36/// Select one entry by stable id or exact name without decoding unrelated
37/// entries. Shared by data-view operations and the legacy search resolver.
38pub(crate) fn select_by_id_or_name<'a, T>(
39    entries: &'a [T],
40    selector: &str,
41    id: impl Fn(&T) -> Option<&str>,
42    name: impl Fn(&T) -> Option<&str>,
43) -> Result<&'a T> {
44    if let Some(entry) = entries.iter().find(|entry| id(entry) == Some(selector)) {
45        return Ok(entry);
46    }
47
48    let matches: Vec<&T> = entries
49        .iter()
50        .filter(|entry| name(entry) == Some(selector))
51        .collect();
52    match matches.as_slice() {
53        [] => Err(Error::new(
54            ErrorKind::NotFound,
55            format!("no data view with id or name '{selector}'"),
56        )),
57        [entry] => Ok(entry),
58        _ => Err(Error::new(
59            ErrorKind::Conflict,
60            format!("data view '{selector}' is ambiguous"),
61        )),
62    }
63}
64
65/// Resolve one selector from already-read data-view summaries.
66///
67/// Stable ids win over names. Names are a convenience selector and must be
68/// unique when used.
69pub fn resolve_from_summaries(
70    views: &[DataViewSummary],
71    selector: &str,
72) -> Result<DataViewSummary> {
73    Ok(select_by_id_or_name(
74        views,
75        selector,
76        |view| Some(view.id.as_str()),
77        |view| view.name.as_deref(),
78    )?
79    .clone())
80}
81
82/// Resolve one selector over the wire with one data-view list read.
83pub async fn resolve(transport: &Transport, selector: &str) -> Result<DataViewSummary> {
84    let views = data_views::list(transport).await?;
85    resolve_from_summaries(&views, selector)
86}
87
88/// List data views using the portable command filter and stable-id ordering.
89pub async fn list_op(transport: &Transport, filter: &DataViewFilter) -> Result<DataViewList> {
90    let needle = filter.search.as_ref().map(|search| search.to_lowercase());
91    let mut data_views: Vec<_> = data_views::list(transport)
92        .await?
93        .into_iter()
94        .filter(|view| {
95            needle.as_ref().is_none_or(|needle| {
96                view.id.to_lowercase().contains(needle)
97                    || view
98                        .name
99                        .as_ref()
100                        .is_some_and(|name| name.to_lowercase().contains(needle))
101                    || view.title.to_lowercase().contains(needle)
102            })
103        })
104        .collect();
105    data_views.sort_by(|left, right| left.id.cmp(&right.id));
106    Ok(DataViewList {
107        total: data_views.len(),
108        data_views,
109    })
110}
111
112/// Resolve a selector, then read its full live data-view object.
113pub async fn get_op(transport: &Transport, selector: &str) -> Result<DataView> {
114    let view = resolve(transport, selector).await?;
115    data_views::get(transport, &view.id).await
116}
117
118/// Convert a full live data-view object into its portable form.
119pub fn normalize(data_view: &Value) -> Result<DataViewSpec> {
120    let source = data_view
121        .as_object()
122        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding data view: expected object"))?;
123    let mut portable = Map::new();
124
125    for key in ["id", "title"] {
126        if let Some(value) = source.get(key) {
127            portable.insert(key.to_string(), canonicalize(value));
128        }
129    }
130    for key in [
131        "name",
132        "timeFieldName",
133        "sourceFilters",
134        "fieldFormats",
135        "runtimeFieldMap",
136        "fieldAttrs",
137        "type",
138    ] {
139        if let Some(value) = source.get(key) {
140            portable.insert(key.to_string(), canonicalize(value));
141        }
142    }
143    for key in ["allowNoIndex", "allowHidden"] {
144        portable.insert(
145            key.to_string(),
146            source
147                .get(key)
148                .map(canonicalize)
149                .unwrap_or(Value::Bool(false)),
150        );
151    }
152    if let Some(type_meta) = source.get("typeMeta")
153        && !type_meta.is_null()
154        && !matches!(type_meta, Value::Object(values) if values.is_empty())
155    {
156        portable.insert("typeMeta".to_string(), canonicalize(type_meta));
157    }
158    if let Some(fields) = source.get("fields") {
159        let fields = match fields {
160            Value::Object(fields) => {
161                let mut scripted: Vec<_> = fields
162                    .iter()
163                    .filter_map(|(name, field)| {
164                        (field.get("scripted").and_then(Value::as_bool) == Some(true))
165                            .then_some(name.as_str())
166                    })
167                    .collect();
168                scripted.sort_unstable();
169                if !scripted.is_empty() {
170                    return Err(Error::new(
171                        ErrorKind::Unsupported,
172                        format!(
173                            "legacy scripted fields are unsupported: {}",
174                            scripted.join(", ")
175                        ),
176                    ));
177                }
178                Value::Object(Map::new())
179            }
180            other => canonicalize(other),
181        };
182        portable.insert("fields".to_string(), canonicalize(&fields));
183    }
184
185    DataViewSpec::try_from(canonicalize(&Value::Object(portable))).map_err(|error| {
186        Error::new(
187            ErrorKind::Http,
188            format!("decoding data view: {}", error.message),
189        )
190    })
191}
192
193/// Rebuild a JSON value with sorted object keys while preserving array order.
194fn canonicalize(value: &Value) -> Value {
195    match value {
196        Value::Object(values) => {
197            let mut keys: Vec<_> = values.keys().collect();
198            keys.sort_unstable();
199            Value::Object(
200                keys.into_iter()
201                    .map(|key| (key.clone(), canonicalize(&values[key])))
202                    .collect(),
203            )
204        }
205        Value::Array(values) => Value::Array(values.iter().map(canonicalize).collect()),
206        _ => value.clone(),
207    }
208}
209
210/// Fully read and validate a portable data-view artifact.
211pub fn validate(path: &Path) -> Result<Vec<DataViewSpec>> {
212    let body = std::fs::read_to_string(path).map_err(|error| {
213        Error::new(
214            ErrorKind::Error,
215            format!("reading {}: {error}", path.display()),
216        )
217    })?;
218    let mut specs = content_codec::decode_sequence::<DataViewSpec>(
219        &body,
220        ContentFormat::from_path(path),
221        "data view",
222    )?;
223
224    let mut seen = BTreeSet::new();
225    let mut duplicates = BTreeSet::new();
226    for spec in &specs {
227        spec.validate()?;
228        if !seen.insert(spec.id.as_str()) {
229            duplicates.insert(spec.id.as_str());
230        }
231    }
232    if !duplicates.is_empty() {
233        return Err(Error::new(
234            ErrorKind::Error,
235            format!(
236                "duplicate data view ids: {}",
237                duplicates.into_iter().collect::<Vec<_>>().join(", ")
238            ),
239        ));
240    }
241
242    specs.sort_by(|left, right| left.id.cmp(&right.id));
243    Ok(specs)
244}
245
246fn invalid_plan(message: impl Into<String>) -> Result<()> {
247    Err(Error::new(ErrorKind::Error, message))
248}
249
250/// Export selected data views as a portable JSON or YAML artifact.
251pub async fn export(
252    transport: &Transport,
253    selectors: &[String],
254    format: ContentFormat,
255) -> Result<ExportOutcome> {
256    let summaries = data_views::list(transport).await?;
257    let selected = if selectors.is_empty() {
258        summaries
259    } else {
260        selectors
261            .iter()
262            .map(|selector| resolve_from_summaries(&summaries, selector))
263            .collect::<Result<Vec<_>>>()?
264    };
265    let selected: BTreeMap<_, _> = selected
266        .into_iter()
267        .map(|summary| (summary.id.clone(), summary))
268        .collect();
269    let expected = selected.len();
270    let mut specs = Vec::with_capacity(expected);
271    for (id, _) in selected {
272        let detail = data_views::get(transport, &id).await?;
273        let spec = normalize(&Value::Object(detail.data_view))?;
274        if spec.id != id {
275            return Err(Error::new(
276                ErrorKind::Http,
277                format!("data view export was short: expected id '{id}'"),
278            ));
279        }
280        specs.push(spec);
281    }
282    if specs.len() != expected {
283        return Err(Error::new(
284            ErrorKind::Http,
285            format!(
286                "data view export was short: expected {expected}, got {}",
287                specs.len()
288            ),
289        ));
290    }
291    specs.sort_by(|left, right| left.id.cmp(&right.id));
292    let body = content_codec::encode_sequence(&specs, format)?;
293    Ok(ExportOutcome {
294        body,
295        exported: specs.len() as u64,
296        missing: Vec::new(),
297    })
298}