Add data versioning utilities (#292)

This commit adds the `src/util/compat` module, which is responsible for
data compatibility. It provides two methods: `toCompat` and
`fromCompat`.

`toCompat` allows an object to be versioned with a type and version
string. Doing so wraps the object in an array whose first element has
`version` and `type` fields.

`fromCompat` allows loading compatibilized data. It takes a `type` and
`version` expectation, as well as optional processors for various data
versions. It throws an error if the data was not compatibilized, or if
it is the wrong type, or if it has an unsupported version.

The versioning is designed to be composable; see the final test block
for an example of loading a structure with nested versioning.

Test plan: Carefully inspect the included unit tests, and feel free to
propose more.

For context, see #280.
This commit is contained in:
Dandelion Mané 2018-05-21 11:21:05 -07:00 committed by GitHub
parent 180c3454af
commit 1fc860bd56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 213 additions and 0 deletions

45
src/util/compat.js Normal file
View File

@ -0,0 +1,45 @@
// @flow
export opaque type Compatible<T> = [CompatInfo, T];
type CompatInfo = {|
+type: string,
+version: string,
|};
export function toCompat<T>(compatInfo: CompatInfo, obj: T): Compatible<T> {
return [compatInfo, obj];
}
/**
* Load an object from compatibilized state created by `toCompat`.
* The object has an expected type and version, and may optionally have
* handler functions for transforming previous versions into a canonical state.
* If a handler is present for the current version, it will be applied.
* Throws an error if the compatibilized object is the wrong type, or if its version
* is not current and there was no handler for its version.
*/
export function fromCompat<T>(
expectedCompatInfo: CompatInfo,
obj: Compatible<any>,
handlers: ?{[version: string]: (any) => T}
): T {
if (!Array.isArray(obj) || obj.length !== 2) {
throw new Error(
"Tried to load object that didn't have compatibility defined"
);
}
const {type, version} = obj[0];
let result = obj[1];
const {type: expectedType, version: expectedVersion} = expectedCompatInfo;
if (type !== expectedType) {
throw new Error(`Expected type to be ${expectedType} but got ${type}`);
}
if (handlers != null && handlers[version] != null) {
result = handlers[version](result);
} else if (version !== expectedVersion) {
throw new Error(`${type}: tried to load unsupported version ${version}`);
}
return result;
}

168
src/util/compat.test.js Normal file
View File

@ -0,0 +1,168 @@
// @flow
import {toCompat, fromCompat} from "./compat";
import type {Compatible} from "./compat";
describe("compat utilities", () => {
type FooV1 = {foo: number};
type FooV2 = {bar: {foo: number}};
const type = "Foo Plugin's Experiment";
const v1 = "v1";
const v2 = "v2";
function fooV1ToV2(x: FooV1): FooV2 {
return {bar: x};
}
const example = () => {
const dataV1: FooV1 = {foo: 1};
const dataV2: FooV2 = {bar: {foo: 1}};
const compatV1 = toCompat({type, version: v1}, dataV1);
const compatV2 = toCompat({type, version: v2}, dataV2);
return {dataV1, dataV2, compatV1, compatV2};
};
it("toCompat doesn't fails on primitives", () => {
expect(
fromCompat({type, version: v1}, toCompat({type, version: v1}, 3))
).toBe(3);
});
it("toCompat -> fromCompat is identity", () => {
const {dataV1, compatV1} = example();
expect(fromCompat({type, version: v1}, compatV1)).toEqual(dataV1);
});
it("fromCompat fails if compatibility undefined", () => {
const dataV1: any = example().dataV1;
expect(() => fromCompat({type, version: v1}, dataV1)).toThrowError(
"didn't have compatibility defined"
);
});
it("fromCompat fails if type is wrong", () => {
const {compatV1} = example();
expect(() =>
fromCompat({type: "who is Foo?", version: v1}, compatV1)
).toThrowError("Expected type");
});
it("fromCompat fails if version is wrong", () => {
const {compatV1} = example();
expect(() => fromCompat({type, version: v2}, compatV1)).toThrowError(
"unsupported version"
);
});
it("handlers can load older versions", () => {
const {compatV1, dataV2} = example();
const handlers = {[v1]: fooV1ToV2};
expect(fromCompat({type: type, version: v2}, compatV1, handlers)).toEqual(
dataV2
);
});
it("handlers activate even on current version", () => {
const {compatV1} = example();
const handlers = {
[v1]: () => ({
hello: "world",
}),
};
expect(fromCompat({type, version: v1}, compatV1, handlers)).toEqual({
hello: "world",
});
});
describe("composable versioning", () => {
class InnerV1 {
x: number;
constructor(x: number) {
this.x = x;
}
toJSON(): Compatible<FooV1> {
return toCompat({type: "inner", version: v1}, {foo: this.x});
}
static fromJSON(json): InnerV1 {
const from: FooV1 = fromCompat({type: "inner", version: v1}, json);
return new InnerV1(from.foo);
}
}
class InnerV2 {
x: number;
constructor(x: number) {
this.x = x;
}
toJSON(): Compatible<FooV2> {
return toCompat({type: "inner", version: v2}, {bar: {foo: this.x}});
}
static fromJSON(json): InnerV2 {
const from: FooV2 = fromCompat({type: "inner", version: v2}, json, {
[v1]: fooV1ToV2,
});
return new InnerV2(from.bar.foo);
}
}
class OuterV1 {
platypus: InnerV1 | InnerV2;
constructor(i: InnerV1 | InnerV2) {
this.platypus = i;
}
toJSON() {
return toCompat(
{type: "outer", version: v1},
{platypus: this.platypus.toJSON()}
);
}
fromJSON(json: any): OuterV1 {
return fromCompat({type: "outer", version: v1}, json, {
[v1]: function(x) {
return new OuterV1(InnerV2.fromJSON(x.platypus));
},
});
}
}
class OuterV2 {
// Naming this property "platypus" in the previous version was silly
inner: InnerV1 | InnerV2;
constructor(i: InnerV1 | InnerV2) {
this.inner = i;
}
toJSON() {
return toCompat(
{type: "outer", version: v2},
{inner: this.inner.toJSON()}
);
}
static fromJSON(json: any): OuterV2 {
return fromCompat({type: "outer", version: v2}, json, {
[v1]: function(x) {
return new OuterV2(InnerV2.fromJSON(x.platypus));
},
[v2]: function(x) {
return new OuterV2(InnerV2.fromJSON(x.inner));
},
});
}
}
const canonical = () => new OuterV2(new InnerV2(1));
it("loads OuterV1<InnerV1>", () => {
const json = new OuterV1(new InnerV1(1)).toJSON();
expect(OuterV2.fromJSON(json)).toEqual(canonical());
});
it("loads OuterV1<InnerV2>", () => {
const json = new OuterV1(new InnerV2(1)).toJSON();
expect(OuterV2.fromJSON(json)).toEqual(canonical());
});
it("loads OuterV2<InnerV1>", () => {
const json = new OuterV2(new InnerV1(1)).toJSON();
expect(OuterV2.fromJSON(json)).toEqual(canonical());
});
it("loads OuterV2<InnerV2>", () => {
const json = new OuterV2(new InnerV2(1)).toJSON();
expect(OuterV2.fromJSON(json)).toEqual(canonical());
});
});
});