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 validate;
43mod version;
44mod view;
45mod viewer;
46mod workspace;
47
48pub use error::MigrateError;
49pub use notice::Notice;
50pub use plugin::PluginMigration;
51use serde::{Deserialize, Serialize};
52use serde_json::Value;
53pub use step::{Apply, Step, StepCx};
54use step::{Entry, Layer, Run, panels};
55pub use validate::{Form, ValidateError, validate};
56pub use version::Version;
57
58/// The kind of document a config is.
59#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Kind {
62    /// An engine `ViewConfig` or `ViewConfigUpdate`.
63    View,
64
65    /// A `<perspective-viewer>` `save()` / `restore()` token.
66    Viewer,
67
68    /// One workspace panel: a `panels` entry, with no `settings` and a
69    /// required `table` name.
70    Panel,
71
72    /// A workspace `save()` / `restoreWorkspace()` token.
73    Workspace,
74}
75
76impl std::str::FromStr for Kind {
77    type Err = String;
78
79    fn from_str(kind: &str) -> Result<Self, Self::Err> {
80        match kind {
81            "view" => Ok(Kind::View),
82            "viewer" => Ok(Kind::Viewer),
83            "panel" => Ok(Kind::Panel),
84            "workspace" => Ok(Kind::Workspace),
85            _ => Err(format!(
86                "Unknown config kind `{kind}`; expected `view`, `viewer`, `panel` or `workspace`"
87            )),
88        }
89    }
90}
91
92/// A migrated config and what the migration could not carry over exactly.
93#[derive(Clone, Debug, PartialEq)]
94pub struct Migrated {
95    /// The config in the current schema.
96    pub value: Value,
97
98    /// Information dropped or approximated along the way.
99    pub notices: Vec<Notice>,
100
101    /// The ids of the steps that changed the config, in the order they ran.
102    pub steps: Vec<&'static str>,
103}
104
105/// Upgrades configs through every schema change since their source version.
106pub struct Migrator {
107    plugins: Vec<Box<dyn PluginMigration>>,
108}
109
110impl Default for Migrator {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl Migrator {
117    /// A migrator for the engine, the viewer and the first-party plugins.
118    pub fn new() -> Self {
119        Self {
120            plugins: plugins::first_party(),
121        }
122    }
123
124    /// Add the migrations of a third-party plugin.
125    ///
126    /// # Panics
127    ///
128    /// When `plugin` claims a plugin name another migration already owns.
129    pub fn with_plugin(mut self, plugin: impl PluginMigration + 'static) -> Self {
130        let owned = self
131            .plugins
132            .iter()
133            .flat_map(|x| x.plugins().iter().copied())
134            .collect::<Vec<_>>();
135
136        if let Some(name) = plugin.plugins().iter().find(|x| owned.contains(x)) {
137            panic!("plugin `{name}` already has a migration");
138        }
139
140        self.plugins.push(Box::new(plugin));
141        self
142    }
143
144    /// Upgrade `value`, a `kind` document, to the current schema.
145    ///
146    /// The release that wrote each object is read from its own `version`
147    /// field, falling back to `from` and then to 2.0.0, so re-migrating an
148    /// output is a no-op.
149    ///
150    /// # Arguments
151    ///
152    /// - `kind` - Which document `value` is.
153    /// - `from` - The release that wrote `value`, as `x.y.z`, used only when
154    ///   `value` has no `version` field.
155    /// - `value` - The config, as JSON.
156    pub fn migrate(
157        &self,
158        kind: Kind,
159        from: Option<&str>,
160        value: Value,
161    ) -> Result<Migrated, MigrateError> {
162        let mut value = value;
163        if !value.is_object() {
164            return Err(MigrateError::NotAnObject);
165        }
166
167        let schema = self.schema_version();
168        let from = match value.get("version").and_then(Value::as_str).or(from) {
169            Some(version) => check_version(version, schema)?,
170            None => Version::new(2, 0, 0),
171        };
172
173        let entries = self.entries();
174        let known_plugins = self.plugin_names();
175        let mut run = Run {
176            kind,
177            from,
178            schema,
179            known_plugins: &known_plugins,
180            notices: vec![],
181            steps: vec![],
182        };
183
184        run.apply(&entries, &mut value)?;
185        stamp(kind, &schema.to_string(), &mut value);
186        validate(kind, Form::Update, &value)?;
187        Ok(Migrated {
188            value,
189            notices: run.notices,
190            steps: run.steps,
191        })
192    }
193
194    /// The id of every step this migrator can run.
195    pub fn step_ids(&self) -> Vec<&'static str> {
196        self.entries().iter().map(|x| x.step.id).collect()
197    }
198
199    /// The newest release any step migrates to.
200    pub fn newest_step_version(&self) -> Version {
201        self.entries()
202            .iter()
203            .map(|x| x.step.at)
204            .max()
205            .unwrap_or_else(Version::crate_version)
206    }
207
208    /// The newest release whose configs this migrator accepts.
209    pub fn schema_version(&self) -> Version {
210        self.newest_step_version().max(Version::crate_version())
211    }
212
213    fn plugin_names(&self) -> Vec<&'static str> {
214        let mut names = plugins::REGISTRATION_ORDER.to_vec();
215        for name in self.plugins.iter().flat_map(|x| x.plugins().iter()) {
216            if !names.contains(name) {
217                names.push(name);
218            }
219        }
220
221        names
222    }
223
224    fn entries(&self) -> Vec<Entry<'_>> {
225        let fixed = [
226            (Layer::View, view::STEPS),
227            (Layer::Viewer, viewer::STEPS),
228            (Layer::ColumnStyle, column_style::STEPS),
229            (Layer::Workspace, workspace::STEPS),
230        ];
231
232        let mut entries = fixed
233            .into_iter()
234            .flat_map(|(layer, steps)| {
235                steps.iter().map(move |step| Entry {
236                    layer,
237                    plugins: &[],
238                    step: *step,
239                })
240            })
241            .collect::<Vec<_>>();
242
243        for plugin in &self.plugins {
244            entries.extend(plugin.steps().into_iter().map(|step| Entry {
245                layer: Layer::Plugin,
246                plugins: plugin.plugins(),
247                step,
248            }));
249        }
250
251        entries.sort_by_key(|x| (x.step.at, x.layer));
252        entries
253    }
254}
255
256pub(crate) fn check_version(version: &str, schema: Version) -> Result<Version, MigrateError> {
257    let parsed = Version::parse(version).ok_or_else(|| MigrateError::InvalidVersion {
258        version: version.to_owned(),
259    })?;
260
261    if parsed < Version::new(2, 0, 0) {
262        Err(MigrateError::Unsupported {
263            version: version.to_owned(),
264        })
265    } else if parsed > schema {
266        Err(MigrateError::FromFuture {
267            version: version.to_owned(),
268        })
269    } else {
270        Ok(parsed)
271    }
272}
273
274fn stamp(kind: Kind, version: &str, value: &mut Value) {
275    let version = Value::from(version);
276    match kind {
277        Kind::View => {},
278        Kind::Viewer | Kind::Panel => {
279            value["version"] = version;
280        },
281        Kind::Workspace => {
282            for pointer in panels(value) {
283                if let Some(Value::Object(panel)) = value.pointer_mut(&pointer)
284                    && panel.contains_key("version")
285                {
286                    panel.insert("version".to_owned(), version.clone());
287                }
288            }
289
290            value["version"] = version;
291        },
292    }
293}
294
295use wasm_bindgen::prelude::*;
296
297/// Upgrade a persisted Perspective config to the current schema, returning
298/// `{value, notices, steps}`.
299#[wasm_bindgen]
300pub fn migrate(kind: &str, from: Option<String>, value: JsValue) -> Result<JsValue, JsError> {
301    let kind: Kind = kind.parse().map_err(|e: String| JsError::new(&e))?;
302    let value: serde_json::Value = serde_wasm_bindgen::from_value(value)?;
303    let migrated = Migrator::new().migrate(kind, from.as_deref(), value)?;
304    let result = serde_json::json!({
305        "value": migrated.value,
306        "notices": migrated.notices,
307        "steps": migrated.steps,
308    });
309
310    Ok(result.serialize(&serde_wasm_bindgen::Serializer::json_compatible())?)
311}
312
313/// Check that a config is valid for the current schema without migrating it,
314/// where `strict` requires the complete `save()` form.
315#[wasm_bindgen]
316pub fn validate_config(kind: &str, value: JsValue, strict: Option<bool>) -> Result<(), JsError> {
317    let kind: Kind = kind.parse().map_err(|e: String| JsError::new(&e))?;
318    let form = if strict.unwrap_or(false) {
319        Form::Complete
320    } else {
321        Form::Update
322    };
323
324    let value: serde_json::Value = serde_wasm_bindgen::from_value(value)?;
325    validate(kind, form, &value).map_err(|e| JsError::new(&e.to_string()))
326}