1use 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
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct DataViewFilter {
17 pub search: Option<String>,
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct DataViewList {
23 pub total: usize,
24 pub data_views: Vec<DataViewSummary>,
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Serialize)]
29pub struct DataViewPatch {
30 pub base: Option<DataViewUpdate>,
31 pub field_metadata: Map<String, Value>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct DataViewImportPlan {
37 pub preview: MutationPlan,
38 pub specs: Vec<DataViewSpec>,
39 pub before: BTreeMap<String, Option<DataViewSpec>>,
40 pub patches: BTreeMap<String, DataViewPatch>,
41 pub skipped: Vec<Value>,
42 pub total: usize,
43 pub overwrite: bool,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize)]
48pub struct DataViewImportReport {
49 pub applied: bool,
50 pub succeeded: Vec<Value>,
51 pub skipped: Vec<Value>,
52 pub failed: Vec<Value>,
53 pub total: usize,
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct DefaultPlan {
59 pub preview: MutationPlan,
60 pub before: Option<String>,
61 pub after: Option<String>,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct DataViewDeleteTarget {
67 pub source: DataViewSummary,
68 pub references: Vec<DataViewReference>,
69 pub was_default: bool,
70}
71
72#[derive(Debug, Clone, PartialEq)]
74pub struct DataViewDeletePlan {
75 pub preview: MutationPlan,
76 pub targets: Vec<DataViewDeleteTarget>,
77 pub replacement: Option<DataViewSummary>,
78 pub default_before: Option<String>,
79}
80
81pub(crate) fn select_by_id_or_name<'a, T>(
84 entries: &'a [T],
85 selector: &str,
86 id: impl Fn(&T) -> Option<&str>,
87 name: impl Fn(&T) -> Option<&str>,
88) -> Result<&'a T> {
89 if let Some(entry) = entries.iter().find(|entry| id(entry) == Some(selector)) {
90 return Ok(entry);
91 }
92
93 let matches: Vec<&T> = entries
94 .iter()
95 .filter(|entry| name(entry) == Some(selector))
96 .collect();
97 match matches.as_slice() {
98 [] => Err(Error::new(
99 ErrorKind::NotFound,
100 format!("no data view with id or name '{selector}'"),
101 )),
102 [entry] => Ok(entry),
103 _ => Err(Error::new(
104 ErrorKind::Conflict,
105 format!("data view '{selector}' is ambiguous"),
106 )),
107 }
108}
109
110pub fn resolve_from_summaries(
115 views: &[DataViewSummary],
116 selector: &str,
117) -> Result<DataViewSummary> {
118 Ok(select_by_id_or_name(
119 views,
120 selector,
121 |view| Some(view.id.as_str()),
122 |view| view.name.as_deref(),
123 )?
124 .clone())
125}
126
127pub async fn resolve(transport: &Transport, selector: &str) -> Result<DataViewSummary> {
129 let views = data_views::list(transport).await?;
130 resolve_from_summaries(&views, selector)
131}
132
133pub async fn list_op(transport: &Transport, filter: &DataViewFilter) -> Result<DataViewList> {
135 let needle = filter.search.as_ref().map(|search| search.to_lowercase());
136 let mut data_views: Vec<_> = data_views::list(transport)
137 .await?
138 .into_iter()
139 .filter(|view| {
140 needle.as_ref().is_none_or(|needle| {
141 view.id.to_lowercase().contains(needle)
142 || view
143 .name
144 .as_ref()
145 .is_some_and(|name| name.to_lowercase().contains(needle))
146 || view.title.to_lowercase().contains(needle)
147 })
148 })
149 .collect();
150 data_views.sort_by(|left, right| left.id.cmp(&right.id));
151 Ok(DataViewList {
152 total: data_views.len(),
153 data_views,
154 })
155}
156
157pub async fn get_op(transport: &Transport, selector: &str) -> Result<DataView> {
159 let view = resolve(transport, selector).await?;
160 data_views::get(transport, &view.id).await
161}
162
163pub async fn plan_default_set(transport: &Transport, selector: &str) -> Result<DefaultPlan> {
165 let views = data_views::list(transport).await?;
166 let target = resolve_from_summaries(&views, selector)?;
167 let before = data_views::get_default(transport).await?;
168 let label = default_label(&target);
169 Ok(default_plan(before, Some(target.id), Some(label)))
170}
171
172pub async fn plan_default_unset(transport: &Transport) -> Result<DefaultPlan> {
174 Ok(default_plan(
175 data_views::get_default(transport).await?,
176 None,
177 None,
178 ))
179}
180
181pub async fn apply_default(transport: &Transport, plan: &DefaultPlan) -> Result<()> {
184 validate_default_plan(plan)?;
185 let current = data_views::get_default(transport).await?;
186 if current != plan.before {
187 return Err(Error::new(
188 ErrorKind::Conflict,
189 "default data view changed since preview",
190 ));
191 }
192 if plan.before == plan.after {
193 return Ok(());
194 }
195 data_views::set_default(transport, plan.after.as_deref()).await
196}
197
198fn default_plan(
199 before: Option<String>,
200 after: Option<String>,
201 target_label: Option<String>,
202) -> DefaultPlan {
203 let (preview_action, preview_details, targets) = match (&before, &after) {
204 (before, Some(after)) => {
205 let label = target_label.expect("set default plans always have a resolved label");
206 let detail = if before.as_deref() == Some(after) {
207 format!("{after} already default")
208 } else {
209 format!(
210 "{} default -> {after}",
211 before.as_deref().unwrap_or("none")
212 )
213 };
214 (
215 format!("Set default data view to {after} ({label})"),
216 vec![detail],
217 vec![after.clone()],
218 )
219 }
220 (Some(before), None) => (
221 format!("Unset default data view {before}"),
222 vec![format!("{before} default -> unset")],
223 vec![before.clone()],
224 ),
225 (None, None) => (
226 "Unset default data view".into(),
227 vec!["no default data view set".into()],
228 Vec::new(),
229 ),
230 };
231 DefaultPlan {
232 preview: MutationPlan {
233 preview_action,
234 preview_details,
235 targets,
236 },
237 before,
238 after,
239 }
240}
241
242fn default_label(view: &DataViewSummary) -> String {
243 view.name.clone().unwrap_or_else(|| view.title.clone())
244}
245
246fn validate_default_plan(plan: &DefaultPlan) -> Result<()> {
247 for id in [&plan.before, &plan.after].into_iter().flatten() {
248 if id.trim().is_empty() {
249 return invalid_plan("default data-view ids must not be empty");
250 }
251 }
252 match (&plan.before, &plan.after) {
253 (Some(before), Some(after)) if before == after => {
254 if plan.preview.targets != [after.clone()]
255 || plan.preview.preview_details != [format!("{after} already default")]
256 || !plan
257 .preview
258 .preview_action
259 .starts_with(&format!("Set default data view to {after} ("))
260 || !plan.preview.preview_action.ends_with(')')
261 {
262 return invalid_plan("default preview does not match its snapshots");
263 }
264 }
265 (before, Some(after)) => {
266 if plan.preview.targets != [after.clone()]
267 || plan.preview.preview_details
268 != [format!(
269 "{} default -> {after}",
270 before.as_deref().unwrap_or("none")
271 )]
272 || !plan
273 .preview
274 .preview_action
275 .starts_with(&format!("Set default data view to {after} ("))
276 || !plan.preview.preview_action.ends_with(')')
277 {
278 return invalid_plan("default preview does not match its snapshots");
279 }
280 }
281 (Some(before), None) => {
282 if plan.preview.targets != [before.clone()]
283 || plan.preview.preview_action != format!("Unset default data view {before}")
284 || plan.preview.preview_details != [format!("{before} default -> unset")]
285 {
286 return invalid_plan("default preview does not match its snapshots");
287 }
288 }
289 (None, None) => {
290 if !plan.preview.targets.is_empty()
291 || plan.preview.preview_action != "Unset default data view"
292 || plan.preview.preview_details != ["no default data view set"]
293 {
294 return invalid_plan("default preview does not match its snapshots");
295 }
296 }
297 }
298 Ok(())
299}
300
301pub async fn plan_delete(
303 transport: &Transport,
304 selectors: &[String],
305 replacement_selector: Option<&str>,
306) -> Result<DataViewDeletePlan> {
307 if selectors.is_empty() {
308 return Err(Error::new(
309 ErrorKind::Error,
310 "data-view delete needs at least one source",
311 ));
312 }
313
314 let views = data_views::list(transport).await?;
318 let mut seen = BTreeSet::new();
319 let mut sources = Vec::new();
320 for selector in selectors {
321 let source = resolve_from_summaries(&views, selector)?;
322 if seen.insert(source.id.clone()) {
323 sources.push(source);
324 }
325 }
326 let replacement = replacement_selector
327 .map(|selector| resolve_from_summaries(&views, selector))
328 .transpose()?;
329
330 if replacement.is_some() && sources.len() != 1 {
331 return Err(Error::new(
332 ErrorKind::Error,
333 "--replace-with accepts exactly one source data view",
334 ));
335 }
336 if let Some(replacement) = &replacement
337 && sources.iter().any(|source| source.id == replacement.id)
338 {
339 return Err(Error::new(
340 ErrorKind::Error,
341 "--replace-with must differ from every source data view",
342 ));
343 }
344
345 let default_before = data_views::get_default(transport).await?;
346 let mut targets = Vec::with_capacity(sources.len());
347 for source in sources {
348 let mut references = data_views::preview_swap(transport, &source.id, &source.id).await?;
349 normalize_references(&mut references)?;
350 targets.push(DataViewDeleteTarget {
351 was_default: default_before.as_deref() == Some(source.id.as_str()),
352 source,
353 references,
354 });
355 }
356
357 if replacement.is_none() {
358 let referenced: Vec<_> = targets
359 .iter()
360 .filter(|target| !target.references.is_empty())
361 .map(|target| {
362 format!(
363 "{}: {}",
364 target.source.id,
365 reference_names(&target.references).join(", ")
366 )
367 })
368 .collect();
369 if !referenced.is_empty() {
370 return Err(Error::new(
371 ErrorKind::Conflict,
372 format!(
373 "data views have live references; use --replace-with: {}",
374 referenced.join("; ")
375 ),
376 ));
377 }
378 let defaults: Vec<_> = targets
379 .iter()
380 .filter(|target| target.was_default)
381 .map(|target| target.source.id.as_str())
382 .collect();
383 if !defaults.is_empty() {
384 return Err(Error::new(
385 ErrorKind::Conflict,
386 format!(
387 "data views are the current default; use data-views default unset or --replace-with: {}",
388 defaults.join(", ")
389 ),
390 ));
391 }
392 }
393
394 Ok(DataViewDeletePlan {
395 preview: delete_preview(&targets, replacement.as_ref()),
396 targets,
397 replacement,
398 default_before,
399 })
400}
401
402pub async fn apply_delete(
404 transport: &Transport,
405 plan: &DataViewDeletePlan,
406) -> Result<DeleteOutcome> {
407 validate_delete_plan(plan)?;
408 let mut deleted = Vec::new();
409 let mut failed = Vec::new();
410
411 match (&plan.replacement, plan.targets.as_slice()) {
412 (None, targets) => {
413 for target in targets {
414 if let Err(error) =
415 check_delete_snapshot(transport, target, plan.default_before.as_deref()).await
416 {
417 failed.push(json!({"id": target.source.id, "error": error.message}));
418 continue;
419 }
420 match data_views::delete(transport, &target.source.id).await {
421 Ok(()) => deleted.push(json!({"id": target.source.id})),
422 Err(error) => {
423 failed.push(json!({"id": target.source.id, "error": error.message}))
424 }
425 }
426 }
427 }
428 (Some(replacement), [target]) => {
429 if let Err(error) =
430 check_delete_snapshot(transport, target, plan.default_before.as_deref()).await
431 {
432 failed.push(json!({"id": target.source.id, "error": error.message}));
433 return Ok(DeleteOutcome {
434 applied: true,
435 deleted,
436 failed,
437 total: plan.targets.len(),
438 });
439 }
440 match data_views::swap(transport, &target.source.id, &replacement.id).await {
441 Err(error) => failed.push(json!({"id": target.source.id, "error": error.message})),
442 Ok(swap)
443 if !swap.delete_status.delete_performed
444 || swap.delete_status.remaining_refs != 0 =>
445 {
446 failed.push(json!({
447 "id": target.source.id,
448 "error": "reference swap did not delete source or left references"
449 }));
450 }
451 Ok(_) if target.was_default => {
452 match data_views::get_default(transport).await {
453 Err(error) => failed.push(json!({
454 "id": target.source.id,
455 "error": format!("references moved and source deleted; default recheck failed: {}", error.message)
456 })),
457 Ok(Some(current)) if current == target.source.id => {
458 if let Err(error) =
459 data_views::set_default(transport, Some(&replacement.id)).await
460 {
461 failed.push(json!({
462 "id": target.source.id,
463 "error": format!("references moved and source deleted; default update failed: {}", error.message)
464 }));
465 } else {
466 deleted.push(json!({"id": target.source.id}));
467 }
468 }
469 Ok(Some(current)) if current == replacement.id => {
470 deleted.push(json!({"id": target.source.id}));
471 }
472 Ok(_) => failed.push(json!({
473 "id": target.source.id,
474 "error": "references moved and source deleted; default changed before update"
475 })),
476 }
477 }
478 Ok(_) => deleted.push(json!({"id": target.source.id})),
479 }
480 }
481 _ => unreachable!("the public plan validator checked replacement cardinality"),
482 }
483
484 Ok(DeleteOutcome {
485 applied: true,
486 deleted,
487 failed,
488 total: plan.targets.len(),
489 })
490}
491
492async fn check_delete_snapshot(
495 transport: &Transport,
496 target: &DataViewDeleteTarget,
497 default_before: Option<&str>,
498) -> Result<()> {
499 let mut references =
500 data_views::preview_swap(transport, &target.source.id, &target.source.id).await?;
501 normalize_references(&mut references)?;
502 let default = data_views::get_default(transport).await?;
503 if references != target.references {
504 return Err(Error::new(
505 ErrorKind::Conflict,
506 "references changed since preview",
507 ));
508 }
509 if default.as_deref() != default_before {
510 return Err(Error::new(
511 ErrorKind::Conflict,
512 "default data view changed since preview",
513 ));
514 }
515 Ok(())
516}
517
518fn normalize_references(references: &mut Vec<DataViewReference>) -> Result<()> {
519 if references
520 .iter()
521 .any(|reference| reference.id.trim().is_empty() || reference.object_type.trim().is_empty())
522 {
523 return Err(Error::new(
524 ErrorKind::Http,
525 "decoding data view reference swap preview: reference id and type must not be empty",
526 ));
527 }
528 references.sort_by(|left, right| {
529 (left.object_type.as_str(), left.id.as_str())
530 .cmp(&(right.object_type.as_str(), right.id.as_str()))
531 });
532 references.dedup_by(|left, right| left.object_type == right.object_type && left.id == right.id);
533 Ok(())
534}
535
536fn reference_names(references: &[DataViewReference]) -> Vec<String> {
537 references
538 .iter()
539 .map(|reference| format!("{}/{}", reference.object_type, reference.id))
540 .collect()
541}
542
543fn delete_preview(
544 targets: &[DataViewDeleteTarget],
545 replacement: Option<&DataViewSummary>,
546) -> MutationPlan {
547 let preview_details = targets
548 .iter()
549 .flat_map(|target| {
550 let mut details: Vec<_> = reference_names(&target.references)
551 .into_iter()
552 .map(|reference| format!("{} {reference}", target.source.id))
553 .collect();
554 match replacement {
555 None => details.push(format!("{} direct delete", target.source.id)),
556 Some(replacement) if target.was_default => details.push(format!(
557 "{} default -> {}",
558 target.source.id, replacement.id
559 )),
560 Some(_) => {}
561 }
562 details
563 })
564 .collect();
565 MutationPlan {
566 preview_action: match replacement {
567 Some(replacement) => format!(
568 "Replace references and delete {} with {}",
569 targets[0].source.id, replacement.id
570 ),
571 None => format!("Delete {} data view(s)", targets.len()),
572 },
573 preview_details,
574 targets: targets
575 .iter()
576 .map(|target| target.source.id.clone())
577 .collect(),
578 }
579}
580
581fn validate_delete_plan(plan: &DataViewDeletePlan) -> Result<()> {
582 if plan.targets.is_empty() {
583 return invalid_plan("data-view delete plan needs at least one target");
584 }
585 let mut ids = BTreeSet::new();
586 if plan
587 .default_before
588 .as_deref()
589 .is_some_and(|id| id.trim().is_empty())
590 {
591 return invalid_plan("default data-view snapshot id must not be empty");
592 }
593 for target in &plan.targets {
594 if target.source.id.trim().is_empty() || target.source.title.trim().is_empty() {
595 return invalid_plan("data-view delete target identity must not be empty");
596 }
597 if !ids.insert(target.source.id.clone()) {
598 return invalid_plan("data-view delete targets must be unique by id");
599 }
600 if target.was_default != (plan.default_before.as_deref() == Some(target.source.id.as_str()))
601 {
602 return invalid_plan("data-view delete default claims do not match its snapshot");
603 }
604 let mut canonical = target.references.clone();
605 normalize_references(&mut canonical).map_err(|error| {
606 Error::new(
607 ErrorKind::Error,
608 format!("invalid data-view delete plan: {}", error.message),
609 )
610 })?;
611 if canonical != target.references {
612 return invalid_plan("data-view delete references must be sorted and unique");
613 }
614 }
615 if let Some(replacement) = &plan.replacement {
616 if plan.targets.len() != 1 {
617 return invalid_plan("replacement delete plan must have exactly one target");
618 }
619 if replacement.id.trim().is_empty() || replacement.title.trim().is_empty() {
620 return invalid_plan("replacement data-view identity must not be empty");
621 }
622 if replacement.id == plan.targets[0].source.id {
623 return invalid_plan("replacement data view must differ from its source");
624 }
625 } else if plan
626 .targets
627 .iter()
628 .any(|target| target.was_default || !target.references.is_empty())
629 {
630 return invalid_plan("direct delete plan contains default or referenced data view");
631 }
632
633 if plan.preview != delete_preview(&plan.targets, plan.replacement.as_ref()) {
634 return invalid_plan("data-view delete preview does not match guarded targets");
635 }
636 Ok(())
637}
638
639pub fn normalize(data_view: &Value) -> Result<DataViewSpec> {
641 let source = data_view
642 .as_object()
643 .ok_or_else(|| Error::new(ErrorKind::Http, "decoding data view: expected object"))?;
644 let mut portable = Map::new();
645
646 for key in ["id", "title"] {
647 if let Some(value) = source.get(key) {
648 portable.insert(key.to_string(), canonicalize(value));
649 }
650 }
651 for key in [
652 "name",
653 "timeFieldName",
654 "sourceFilters",
655 "fieldFormats",
656 "runtimeFieldMap",
657 "fieldAttrs",
658 "type",
659 ] {
660 if let Some(value) = source.get(key) {
661 portable.insert(key.to_string(), canonicalize(value));
662 }
663 }
664 for key in ["allowNoIndex", "allowHidden"] {
665 portable.insert(
666 key.to_string(),
667 source
668 .get(key)
669 .map(canonicalize)
670 .unwrap_or(Value::Bool(false)),
671 );
672 }
673 if let Some(type_meta) = source.get("typeMeta")
674 && !type_meta.is_null()
675 && !matches!(type_meta, Value::Object(values) if values.is_empty())
676 {
677 portable.insert("typeMeta".to_string(), canonicalize(type_meta));
678 }
679 if let Some(fields) = source.get("fields") {
680 let fields = match fields {
681 Value::Object(fields) => {
682 let mut scripted: Vec<_> = fields
683 .iter()
684 .filter_map(|(name, field)| {
685 (field.get("scripted").and_then(Value::as_bool) == Some(true))
686 .then_some(name.as_str())
687 })
688 .collect();
689 scripted.sort_unstable();
690 if !scripted.is_empty() {
691 return Err(Error::new(
692 ErrorKind::Unsupported,
693 format!(
694 "legacy scripted fields are unsupported: {}",
695 scripted.join(", ")
696 ),
697 ));
698 }
699 Value::Object(Map::new())
700 }
701 other => canonicalize(other),
702 };
703 portable.insert("fields".to_string(), canonicalize(&fields));
704 }
705
706 DataViewSpec::try_from(canonicalize(&Value::Object(portable))).map_err(|error| {
707 Error::new(
708 ErrorKind::Http,
709 format!("decoding data view: {}", error.message),
710 )
711 })
712}
713
714fn canonicalize(value: &Value) -> Value {
716 match value {
717 Value::Object(values) => {
718 let mut keys: Vec<_> = values.keys().collect();
719 keys.sort_unstable();
720 Value::Object(
721 keys.into_iter()
722 .map(|key| (key.clone(), canonicalize(&values[key])))
723 .collect(),
724 )
725 }
726 Value::Array(values) => Value::Array(values.iter().map(canonicalize).collect()),
727 _ => value.clone(),
728 }
729}
730
731pub fn validate(path: &Path) -> Result<Vec<DataViewSpec>> {
733 let body = std::fs::read_to_string(path).map_err(|error| {
734 Error::new(
735 ErrorKind::Error,
736 format!("reading {}: {error}", path.display()),
737 )
738 })?;
739 let mut specs = content_codec::decode_sequence::<DataViewSpec>(
740 &body,
741 ContentFormat::from_path(path),
742 "data view",
743 )?;
744
745 let mut seen = BTreeSet::new();
746 let mut duplicates = BTreeSet::new();
747 for spec in &specs {
748 spec.validate()?;
749 if !seen.insert(spec.id.as_str()) {
750 duplicates.insert(spec.id.as_str());
751 }
752 }
753 if !duplicates.is_empty() {
754 return Err(Error::new(
755 ErrorKind::Error,
756 format!(
757 "duplicate data view ids: {}",
758 duplicates.into_iter().collect::<Vec<_>>().join(", ")
759 ),
760 ));
761 }
762
763 specs.sort_by(|left, right| left.id.cmp(&right.id));
764 Ok(specs)
765}
766
767pub fn build_patch(current: &DataViewSpec, desired: &DataViewSpec) -> Result<DataViewPatch> {
769 current.validate()?;
770 desired.validate()?;
771 if current.id != desired.id {
772 return unsupported("changing data view id is not supported by the data-view update API");
773 }
774 if current.allow_hidden != desired.allow_hidden {
775 return unsupported("changing allowHidden is not supported by the data-view update API");
776 }
777 if current.name.is_some() && desired.name.is_none() {
778 return unsupported("removing name is not supported by the data-view update API");
779 }
780 if current.time_field_name.is_some() && desired.time_field_name.is_none() {
781 return unsupported("removing timeFieldName is not supported by the data-view update API");
782 }
783 if current.view_type.is_some() && desired.view_type.is_none() {
784 return unsupported("removing type is not supported by the data-view update API");
785 }
786 if current.type_meta.is_some() && desired.type_meta.is_none() {
787 return unsupported("removing typeMeta is not supported by the data-view update API");
788 }
789
790 let base = DataViewUpdate {
791 allow_no_index: changed(¤t.allow_no_index, &desired.allow_no_index)
792 .then_some(desired.allow_no_index),
793 field_formats: changed(¤t.field_formats, &desired.field_formats)
794 .then(|| desired.field_formats.clone()),
795 fields: changed(¤t.fields, &desired.fields).then(|| desired.fields.clone()),
796 name: changed(¤t.name, &desired.name)
797 .then(|| desired.name.clone())
798 .flatten(),
799 runtime_field_map: changed(¤t.runtime_field_map, &desired.runtime_field_map)
800 .then(|| desired.runtime_field_map.clone()),
801 source_filters: changed(¤t.source_filters, &desired.source_filters)
802 .then(|| desired.source_filters.clone()),
803 time_field_name: changed(¤t.time_field_name, &desired.time_field_name)
804 .then(|| desired.time_field_name.clone())
805 .flatten(),
806 title: changed(¤t.title, &desired.title).then(|| desired.title.clone()),
807 view_type: changed(¤t.view_type, &desired.view_type)
808 .then(|| desired.view_type.clone())
809 .flatten(),
810 type_meta: changed(¤t.type_meta, &desired.type_meta)
811 .then(|| desired.type_meta.clone())
812 .flatten(),
813 };
814 let base = (!is_empty_update(&base)).then_some(base);
815
816 let mut field_metadata = Map::new();
817 let names: BTreeSet<_> = current
818 .field_attrs
819 .keys()
820 .chain(desired.field_attrs.keys())
821 .collect();
822 for name in names {
823 let current_values = current.field_attrs.get(name).and_then(Value::as_object);
824 let desired_values = desired.field_attrs.get(name).and_then(Value::as_object);
825 let keys: BTreeSet<_> = current_values
826 .into_iter()
827 .flat_map(|values| values.keys())
828 .chain(desired_values.into_iter().flat_map(|values| values.keys()))
829 .collect();
830 let mut delta = Map::new();
831 for key in keys {
832 let before = current_values.and_then(|values| values.get(key));
833 let after = desired_values.and_then(|values| values.get(key));
834 if before != after {
835 delta.insert(key.clone(), after.cloned().unwrap_or(Value::Null));
836 }
837 }
838 if !delta.is_empty() {
839 field_metadata.insert(name.clone(), Value::Object(delta));
840 }
841 }
842 Ok(DataViewPatch {
843 base,
844 field_metadata,
845 })
846}
847
848pub async fn plan_import(
850 transport: Option<&Transport>,
851 path: &Path,
852 overwrite: bool,
853 skip_existing: bool,
854) -> Result<DataViewImportPlan> {
855 let mut specs = validate(path)?;
856 if specs.is_empty() {
857 return Err(Error::new(
858 ErrorKind::Error,
859 "data-view import needs at least one data view",
860 ));
861 }
862 if overwrite && skip_existing {
863 return Err(Error::new(
864 ErrorKind::Error,
865 "--overwrite and --skip-existing cannot be used together",
866 ));
867 }
868 let total = specs.len();
869 let requires_server = overwrite || skip_existing || transport.is_some();
870 let transport = if requires_server { transport } else { None };
871 if (overwrite || skip_existing) && transport.is_none() {
872 return Err(Error::new(
873 ErrorKind::Error,
874 "data-view import conflict mode needs a transport",
875 ));
876 }
877
878 let mut before = BTreeMap::new();
879 let mut skipped = Vec::new();
880 let mut conflicts = Vec::new();
881 if let Some(transport) = transport {
882 for spec in &specs {
883 match read_spec(transport, &spec.id).await {
884 Ok(current) => {
885 if !overwrite && !skip_existing {
886 conflicts.push(spec.id.clone());
887 }
888 before.insert(spec.id.clone(), Some(current));
889 }
890 Err(error) if error.kind == ErrorKind::NotFound => {
891 before.insert(spec.id.clone(), None);
892 }
893 Err(error) => return Err(error),
894 }
895 }
896 } else {
897 before.extend(specs.iter().map(|spec| (spec.id.clone(), None)));
898 }
899 if !conflicts.is_empty() {
900 return Err(Error::new(
901 ErrorKind::Conflict,
902 format!("data views already exist: {}", conflicts.join(", ")),
903 ));
904 }
905 if skip_existing {
906 specs.retain(|spec| match before.get(&spec.id) {
907 Some(Some(_)) => {
908 skipped.push(json!({"id": spec.id, "reason": "exists"}));
909 false
910 }
911 _ => true,
912 });
913 before.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
914 }
915
916 let mut patches = BTreeMap::new();
917 for spec in &specs {
918 match before.get(&spec.id).and_then(Option::as_ref) {
919 Some(current) if current == spec => {
920 patches.insert(spec.id.clone(), DataViewPatch::default());
921 }
922 Some(current) => {
923 let patch = build_patch(current, spec)?;
924 patches.insert(spec.id.clone(), patch);
925 }
926 None => {}
927 }
928 }
929 let preview = MutationPlan {
930 preview_action: format!(
931 "Import {} data view(s) from {}",
932 specs.len(),
933 path.display()
934 ),
935 preview_details: preview_details(&specs, &before),
936 targets: specs.iter().map(|spec| spec.id.clone()).collect(),
937 };
938 Ok(DataViewImportPlan {
939 preview,
940 specs,
941 before,
942 patches,
943 skipped,
944 total,
945 overwrite,
946 })
947}
948
949pub async fn apply_import(
955 transport: &Transport,
956 plan: &DataViewImportPlan,
957) -> Result<DataViewImportReport> {
958 validate_import_plan(plan)?;
959 let mut succeeded = Vec::new();
960 let mut failed = Vec::new();
961
962 for desired in &plan.specs {
963 let before = match plan.before.get(&desired.id) {
964 Some(before) => before,
965 None => {
966 failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
967 continue;
968 }
969 };
970 let current = match read_spec(transport, &desired.id).await {
971 Ok(current) => Some(current),
972 Err(error) if error.kind == ErrorKind::NotFound => None,
973 Err(error) => {
974 failed.push(failed_row(&desired.id, false, error.message));
975 continue;
976 }
977 };
978
979 match (before, current) {
980 (None, Some(_)) => {
981 failed.push(failed_row(
982 &desired.id,
983 false,
984 "data view appeared since preview",
985 ));
986 continue;
987 }
988 (Some(_), None) => {
989 failed.push(failed_row(
990 &desired.id,
991 false,
992 "data view disappeared since preview",
993 ));
994 continue;
995 }
996 (Some(before), Some(current)) if before != ¤t => {
997 failed.push(failed_row(
998 &desired.id,
999 false,
1000 "data view changed since preview",
1001 ));
1002 continue;
1003 }
1004 (None, None) => {
1005 let mut applied = false;
1006 match data_views::create(transport, desired).await {
1007 Ok(_) => applied = true,
1008 Err(error) => {
1009 failed.push(failed_row(&desired.id, applied, error.message));
1010 continue;
1011 }
1012 }
1013 match read_spec(transport, &desired.id).await {
1014 Ok(stored) if stored == *desired => {
1015 succeeded.push(json!({"id": desired.id, "action": "created"}));
1016 }
1017 Ok(_) => failed.push(failed_row(
1018 &desired.id,
1019 applied,
1020 "server stored a different data-view spec",
1021 )),
1022 Err(error) => failed.push(failed_row(&desired.id, applied, error.message)),
1023 }
1024 }
1025 (Some(_), Some(_)) => {
1026 let patch = match plan.patches.get(&desired.id) {
1027 Some(patch) => patch,
1028 None => {
1029 failed.push(failed_row(&desired.id, false, "missing replacement patch"));
1030 continue;
1031 }
1032 };
1033 if patch.base.is_none() && patch.field_metadata.is_empty() {
1034 succeeded.push(json!({"id": desired.id, "action": "unchanged"}));
1035 continue;
1036 }
1037
1038 let mut applied = false;
1039 if let Some(base) = &patch.base {
1040 if let Err(error) = data_views::update(transport, &desired.id, base).await {
1041 failed.push(failed_row(
1042 &desired.id,
1043 applied,
1044 format!("base update failed: {}", error.message),
1045 ));
1046 continue;
1047 }
1048 applied = true;
1049 }
1050 if !patch.field_metadata.is_empty()
1051 && let Err(error) = data_views::update_fields_metadata(
1052 transport,
1053 &desired.id,
1054 &patch.field_metadata,
1055 )
1056 .await
1057 {
1058 let message = if applied {
1059 format!("base updated; field metadata failed: {}", error.message)
1060 } else {
1061 format!("field metadata failed: {}", error.message)
1062 };
1063 failed.push(failed_row(&desired.id, applied, message));
1064 continue;
1065 }
1066 if !patch.field_metadata.is_empty() {
1067 applied = true;
1068 }
1069 match read_spec(transport, &desired.id).await {
1070 Ok(stored) if stored == *desired => {
1071 succeeded.push(json!({"id": desired.id, "action": "replaced"}));
1072 }
1073 Ok(_) => failed.push(failed_row(
1074 &desired.id,
1075 applied,
1076 "server stored a different data-view spec",
1077 )),
1078 Err(error) => failed.push(failed_row(&desired.id, applied, error.message)),
1079 }
1080 }
1081 }
1082 }
1083
1084 Ok(DataViewImportReport {
1085 applied: true,
1086 succeeded,
1087 skipped: plan.skipped.clone(),
1088 failed,
1089 total: plan.total,
1090 })
1091}
1092
1093fn changed<T: PartialEq>(current: &T, desired: &T) -> bool {
1094 current != desired
1095}
1096
1097fn is_empty_update(update: &DataViewUpdate) -> bool {
1098 update.allow_no_index.is_none()
1099 && update.field_formats.is_none()
1100 && update.fields.is_none()
1101 && update.name.is_none()
1102 && update.runtime_field_map.is_none()
1103 && update.source_filters.is_none()
1104 && update.time_field_name.is_none()
1105 && update.title.is_none()
1106 && update.view_type.is_none()
1107 && update.type_meta.is_none()
1108}
1109
1110fn unsupported(message: impl Into<String>) -> Result<DataViewPatch> {
1111 Err(Error::new(ErrorKind::Unsupported, message))
1112}
1113
1114fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
1115 json!({"id": id, "applied": applied, "error": error.into()})
1116}
1117
1118async fn read_spec(transport: &Transport, id: &str) -> Result<DataViewSpec> {
1119 let spec = normalize(&Value::Object(
1120 data_views::get(transport, id).await?.data_view,
1121 ))?;
1122 if spec.id != id {
1123 return Err(Error::new(
1124 ErrorKind::Http,
1125 format!("decoding data view: expected id '{id}', got '{}'", spec.id),
1126 ));
1127 }
1128 Ok(spec)
1129}
1130
1131fn validate_import_plan(plan: &DataViewImportPlan) -> Result<()> {
1132 if plan.total == 0 {
1133 return invalid_plan("total must be greater than zero");
1134 }
1135 if plan.total != plan.specs.len() + plan.skipped.len() {
1136 return invalid_plan("total does not equal pending and skipped data views");
1137 }
1138
1139 let mut pending_ids = Vec::with_capacity(plan.specs.len());
1140 for spec in &plan.specs {
1141 validate_canonical_spec(spec)?;
1142 if pending_ids
1143 .last()
1144 .is_some_and(|previous| previous >= &spec.id)
1145 {
1146 return invalid_plan("pending data views must be unique and sorted by id");
1147 }
1148 pending_ids.push(spec.id.clone());
1149 }
1150 let pending: BTreeSet<_> = pending_ids.iter().cloned().collect();
1151 if !plan.skipped.is_empty() && plan.overwrite {
1152 return invalid_plan("skipped data views require skip-existing mode");
1153 }
1154 let mut skipped_ids = BTreeSet::new();
1155 let mut previous_skipped = None;
1156 for row in &plan.skipped {
1157 let object = row
1158 .as_object()
1159 .filter(|object| object.len() == 2)
1160 .ok_or_else(|| Error::new(ErrorKind::Error, "invalid data-view import skipped row"))?;
1161 if !object.keys().map(String::as_str).eq(["id", "reason"]) {
1162 return invalid_plan("invalid data-view import skipped row order");
1163 }
1164 let id = object
1165 .get("id")
1166 .and_then(Value::as_str)
1167 .filter(|id| !id.trim().is_empty())
1168 .ok_or_else(|| Error::new(ErrorKind::Error, "invalid data-view import skipped row"))?;
1169 if object.get("reason").and_then(Value::as_str) != Some("exists")
1170 || previous_skipped.is_some_and(|previous: &str| previous >= id)
1171 || !skipped_ids.insert(id.to_owned())
1172 || pending.contains(id)
1173 {
1174 return invalid_plan("invalid data-view import skipped rows");
1175 }
1176 previous_skipped = Some(id);
1177 }
1178 if plan.preview.targets != pending_ids {
1179 return invalid_plan("preview targets do not match pending data views");
1180 }
1181 let prefix = format!("Import {} data view(s) from ", plan.specs.len());
1182 if !plan.preview.preview_action.starts_with(&prefix)
1183 || plan.preview.preview_action[prefix.len()..].is_empty()
1184 {
1185 return invalid_plan("preview action does not match pending data views");
1186 }
1187 if plan.preview.preview_details != preview_details(&plan.specs, &plan.before) {
1188 return invalid_plan("preview details do not match pending data views");
1189 }
1190
1191 let before_ids: BTreeSet<_> = plan.before.keys().cloned().collect();
1192 if before_ids != pending {
1193 return invalid_plan("preflight snapshots do not match pending data views");
1194 }
1195 let mut patch_ids = BTreeSet::new();
1196 for spec in &plan.specs {
1197 let before = plan.before.get(&spec.id).ok_or_else(|| {
1198 Error::new(
1199 ErrorKind::Error,
1200 "preflight snapshots do not match pending data views",
1201 )
1202 })?;
1203 match before {
1204 None => {
1205 if plan.patches.contains_key(&spec.id) {
1206 return invalid_plan("planned creates must not carry a replacement patch");
1207 }
1208 }
1209 Some(snapshot) => {
1210 validate_canonical_spec(snapshot)?;
1211 if snapshot.id != spec.id {
1212 return invalid_plan("preflight snapshot id does not match its target");
1213 }
1214 if !plan.overwrite {
1215 return invalid_plan("replacement plan requires overwrite");
1216 }
1217 let expected = build_patch(snapshot, spec)?;
1218 let patch = plan.patches.get(&spec.id).ok_or_else(|| {
1219 Error::new(ErrorKind::Error, "planned replacement is missing its patch")
1220 })?;
1221 if patch != &expected {
1222 return invalid_plan("planned replacement patch does not match its snapshots");
1223 }
1224 patch_ids.insert(spec.id.clone());
1225 }
1226 }
1227 }
1228 if plan.patches.keys().cloned().collect::<BTreeSet<_>>() != patch_ids {
1229 return invalid_plan("replacement patches do not match preflight snapshots");
1230 }
1231 Ok(())
1232}
1233
1234fn validate_canonical_spec(spec: &DataViewSpec) -> Result<()> {
1235 spec.validate()?;
1236 if matches!(spec.type_meta.as_ref(), Some(values) if values.is_empty()) {
1237 return Err(Error::new(
1238 ErrorKind::Error,
1239 "data view typeMeta must not be empty",
1240 ));
1241 }
1242 Ok(())
1243}
1244
1245fn preview_details(
1246 specs: &[DataViewSpec],
1247 before: &BTreeMap<String, Option<DataViewSpec>>,
1248) -> Vec<String> {
1249 specs
1250 .iter()
1251 .filter_map(|spec| match before.get(&spec.id) {
1252 Some(None) => Some(format!("{} create {}", spec.id, spec.title)),
1253 Some(Some(current)) if current == spec => {
1254 Some(format!("{} no-op {}", spec.id, spec.title))
1255 }
1256 Some(Some(current)) => Some(format!(
1257 "{} replace {} -> {}",
1258 spec.id, current.title, spec.title
1259 )),
1260 None => None,
1261 })
1262 .collect()
1263}
1264
1265fn invalid_plan(message: impl Into<String>) -> Result<()> {
1266 Err(Error::new(ErrorKind::Error, message))
1267}
1268
1269pub async fn export(
1271 transport: &Transport,
1272 selectors: &[String],
1273 format: ContentFormat,
1274) -> Result<ExportOutcome> {
1275 let summaries = data_views::list(transport).await?;
1276 let selected = if selectors.is_empty() {
1277 summaries
1278 } else {
1279 selectors
1280 .iter()
1281 .map(|selector| resolve_from_summaries(&summaries, selector))
1282 .collect::<Result<Vec<_>>>()?
1283 };
1284 let selected: BTreeMap<_, _> = selected
1285 .into_iter()
1286 .map(|summary| (summary.id.clone(), summary))
1287 .collect();
1288 let expected = selected.len();
1289 let mut specs = Vec::with_capacity(expected);
1290 for (id, _) in selected {
1291 let detail = data_views::get(transport, &id).await?;
1292 let spec = normalize(&Value::Object(detail.data_view))?;
1293 if spec.id != id {
1294 return Err(Error::new(
1295 ErrorKind::Http,
1296 format!("data view export was short: expected id '{id}'"),
1297 ));
1298 }
1299 specs.push(spec);
1300 }
1301 if specs.len() != expected {
1302 return Err(Error::new(
1303 ErrorKind::Http,
1304 format!(
1305 "data view export was short: expected {expected}, got {}",
1306 specs.len()
1307 ),
1308 ));
1309 }
1310 specs.sort_by(|left, right| left.id.cmp(&right.id));
1311 let body = content_codec::encode_sequence(&specs, format)?;
1312 Ok(ExportOutcome {
1313 body,
1314 exported: specs.len() as u64,
1315 missing: Vec::new(),
1316 })
1317}