Skip to main content

perspective_migrate/
lib.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! Upgrades persisted Perspective configs — engine `ViewConfig`s,
14//! `<perspective-viewer>` tokens and workspaces — written by any release since
15//! 2.0.0 to the current schema.
16//!
17//! ```
18//! use perspective_migrate::{Kind, Migrator};
19//! use serde_json::json;
20//!
21//! let migrated = Migrator::new()
22//!     .migrate(Kind::View, Some("2.6.0"), json!({
23//!         "row_pivot": ["State"],
24//!         "expressions": ["//profit\n\"Sales\" - \"Cost\""]
25//!     }))
26//!     .unwrap();
27//!
28//! assert_eq!(migrated.value, json!({
29//!     "group_by": ["State"],
30//!     "expressions": {"profit": "\"Sales\" - \"Cost\""}
31//! }));
32//! ```
33
34mod column_style;
35mod css;
36mod error;
37mod json;
38mod notice;
39mod plugin;
40mod plugins;
41mod step;
42mod version;
43mod view;
44mod viewer;
45mod workspace;
46
47pub use error::MigrateError;
48pub use notice::Notice;
49pub use plugin::PluginMigration;
50use serde::{Deserialize, Serialize};
51use serde_json::Value;
52pub use step::{Apply, Step, StepCx};
53use step::{Entry, Layer, Run, panels};
54pub use version::Version;
55
56/// The kind of document a config is.
57#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(rename_all = "lowercase")]
59pub enum Kind {
60    /// An engine `ViewConfig` or `ViewConfigUpdate`.
61    View,
62
63    /// A `<perspective-viewer>` `save()` / `restore()` token.
64    Viewer,
65
66    /// A workspace `save()` / `restoreWorkspace()` token.
67    Workspace,
68}
69
70/// A migrated config and what the migration could not carry over exactly.
71#[derive(Clone, Debug, PartialEq)]
72pub struct Migrated {
73    /// The config in the current schema.
74    pub value: Value,
75
76    /// Information dropped or approximated along the way.
77    pub notices: Vec<Notice>,
78
79    /// The ids of the steps that changed the config, in the order they ran.
80    pub steps: Vec<&'static str>,
81}
82
83/// Upgrades configs through every schema change since their source version.
84pub struct Migrator {
85    plugins: Vec<Box<dyn PluginMigration>>,
86}
87
88impl Default for Migrator {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl Migrator {
95    /// A migrator for the engine, the viewer and the first-party plugins.
96    pub fn new() -> Self {
97        Self {
98            plugins: plugins::first_party(),
99        }
100    }
101
102    /// Add the migrations of a third-party plugin.
103    ///
104    /// # Panics
105    ///
106    /// When `plugin` claims a plugin name another migration already owns.
107    pub fn with_plugin(mut self, plugin: impl PluginMigration + 'static) -> Self {
108        let owned = self
109            .plugins
110            .iter()
111            .flat_map(|x| x.plugins().iter().copied())
112            .collect::<Vec<_>>();
113
114        if let Some(name) = plugin.plugins().iter().find(|x| owned.contains(x)) {
115            panic!("plugin `{name}` already has a migration");
116        }
117
118        self.plugins.push(Box::new(plugin));
119        self
120    }
121
122    /// Upgrade `value`, a `kind` document, to the current schema.
123    ///
124    /// The release that wrote each object is read from its own `version`
125    /// field, falling back to `from` and then to 2.0.0, so re-migrating an
126    /// output is a no-op.
127    ///
128    /// # Arguments
129    ///
130    /// - `kind` - Which document `value` is.
131    /// - `from` - The release that wrote `value`, as `x.y.z`, used only when
132    ///   `value` has no `version` field.
133    /// - `value` - The config, as JSON.
134    pub fn migrate(
135        &self,
136        kind: Kind,
137        from: Option<&str>,
138        value: Value,
139    ) -> Result<Migrated, MigrateError> {
140        let mut value = value;
141        if !value.is_object() {
142            return Err(MigrateError::NotAnObject);
143        }
144
145        let schema = self.schema_version();
146        let from = match value.get("version").and_then(Value::as_str).or(from) {
147            Some(version) => check_version(version, schema)?,
148            None => Version::new(2, 0, 0),
149        };
150
151        let entries = self.entries();
152        let known_plugins = self.plugin_names();
153        let mut run = Run {
154            kind,
155            from,
156            schema,
157            known_plugins: &known_plugins,
158            notices: vec![],
159            steps: vec![],
160        };
161
162        run.apply(&entries, &mut value)?;
163        stamp(kind, &schema.to_string(), &mut value);
164        Ok(Migrated {
165            value,
166            notices: run.notices,
167            steps: run.steps,
168        })
169    }
170
171    /// The id of every step this migrator can run.
172    pub fn step_ids(&self) -> Vec<&'static str> {
173        self.entries().iter().map(|x| x.step.id).collect()
174    }
175
176    /// The newest release any step migrates to.
177    pub fn newest_step_version(&self) -> Version {
178        self.entries()
179            .iter()
180            .map(|x| x.step.at)
181            .max()
182            .unwrap_or_else(Version::crate_version)
183    }
184
185    /// The newest release whose configs this migrator accepts.
186    pub fn schema_version(&self) -> Version {
187        self.newest_step_version().max(Version::crate_version())
188    }
189
190    fn plugin_names(&self) -> Vec<&'static str> {
191        let mut names = plugins::REGISTRATION_ORDER.to_vec();
192        for name in self.plugins.iter().flat_map(|x| x.plugins().iter()) {
193            if !names.contains(name) {
194                names.push(name);
195            }
196        }
197
198        names
199    }
200
201    fn entries(&self) -> Vec<Entry<'_>> {
202        let fixed = [
203            (Layer::View, view::STEPS),
204            (Layer::Viewer, viewer::STEPS),
205            (Layer::ColumnStyle, column_style::STEPS),
206            (Layer::Workspace, workspace::STEPS),
207        ];
208
209        let mut entries = fixed
210            .into_iter()
211            .flat_map(|(layer, steps)| {
212                steps.iter().map(move |step| Entry {
213                    layer,
214                    plugins: &[],
215                    step: *step,
216                })
217            })
218            .collect::<Vec<_>>();
219
220        for plugin in &self.plugins {
221            entries.extend(plugin.steps().into_iter().map(|step| Entry {
222                layer: Layer::Plugin,
223                plugins: plugin.plugins(),
224                step,
225            }));
226        }
227
228        entries.sort_by_key(|x| (x.step.at, x.layer));
229        entries
230    }
231}
232
233pub(crate) fn check_version(version: &str, schema: Version) -> Result<Version, MigrateError> {
234    let parsed = Version::parse(version).ok_or_else(|| MigrateError::InvalidVersion {
235        version: version.to_owned(),
236    })?;
237
238    if parsed < Version::new(2, 0, 0) {
239        Err(MigrateError::Unsupported {
240            version: version.to_owned(),
241        })
242    } else if parsed > schema {
243        Err(MigrateError::FromFuture {
244            version: version.to_owned(),
245        })
246    } else {
247        Ok(parsed)
248    }
249}
250
251fn stamp(kind: Kind, version: &str, value: &mut Value) {
252    let version = Value::from(version);
253    match kind {
254        Kind::View => {},
255        Kind::Viewer => {
256            value["version"] = version;
257        },
258        Kind::Workspace => {
259            for pointer in panels(value) {
260                if let Some(Value::Object(panel)) = value.pointer_mut(&pointer)
261                    && panel.contains_key("version")
262                {
263                    panel.insert("version".to_owned(), version.clone());
264                }
265            }
266
267            value["version"] = version;
268        },
269    }
270}
271
272use wasm_bindgen::prelude::*;
273
274/// Upgrade a persisted Perspective config to the current schema, returning
275/// `{value, notices, steps}`.
276#[wasm_bindgen]
277pub fn migrate(kind: &str, from: Option<String>, value: JsValue) -> Result<JsValue, JsError> {
278    let kind = match kind {
279        "view" => Kind::View,
280        "viewer" => Kind::Viewer,
281        "workspace" => Kind::Workspace,
282        _ => {
283            return Err(JsError::new(&format!(
284                "Unknown config kind `{kind}`; expected `view`, `viewer` or `workspace`"
285            )));
286        },
287    };
288
289    let value: serde_json::Value = serde_wasm_bindgen::from_value(value)?;
290    let migrated = Migrator::new().migrate(kind, from.as_deref(), value)?;
291    let result = serde_json::json!({
292        "value": migrated.value,
293        "notices": migrated.notices,
294        "steps": migrated.steps,
295    });
296
297    Ok(result.serialize(&serde_wasm_bindgen::Serializer::json_compatible())?)
298}