Skip to content

Commit 07db9de

Browse files
committed
Allow union type instantiation to be faster by specifying a discriminator property
Gadget side, we instantiate a lot of unions that have many elements. Both MQT (or MST) have to take an incoming snapshot and figure out which element of the union is the right one to instantiate and return for the union. Both of them have a pretty naive algorithm for doing this: take each element and check the snapshot against it, returning the first union element which does. This means a LOT of `type.is` checks, where each type in the union is compared against the snapshot. These type.is checks basically do a full instantiation under the hood -- there's no fancy fast path for `.is` for a lot of types, there's just running the runtime code that would boot an instance, and seeing if it fails. This works in the general case but has gotten really slow in Gadget, specifically for the FieldConfig enum. It has a lot of elements, and each element's type is complicated-ish such that it takes a little bit of time to check with `.is`. Our snapshots for this type have a really easy to get at `"type": "StringConfig"` property on them though that allows us to discriminate between the union elements super easily. We added this a long time ago for typescript discriminated unions at type type, but, I think we can use this at runtime too for a much faster path for instantiation. MST already has support for a custom union type "dispatcher", that returns the right type to use for instantiation given a snapshot. This PR adds support for that, and then adds a macro of sorts on top that does type dispatching using a discriminating property. We expect all the types in the union to have a value for this property we can inspect at the time the union is defined, and then we build a map from the value of the discriminator to the type. When a snapshot comes in, we look up the value of the snapshot's discriminator property within the map.
1 parent 6e31b89 commit 07db9de

File tree

7 files changed

+296
-52
lines changed

7 files changed

+296
-52
lines changed

spec/simple.spec.ts

+1-40
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getSnapshot, types } from "../src";
1+
import { types } from "../src";
22

33
describe("boolean", () => {
44
test("can create a read-only instance", () => {
@@ -64,45 +64,6 @@ describe("literal", () => {
6464
});
6565
});
6666

67-
describe("union", () => {
68-
const unionType = types.union(types.literal("value 1"), types.literal("value 2"));
69-
70-
test("can create a read-only instance", () => {
71-
expect(unionType.createReadOnly("value 1")).toEqual("value 1");
72-
expect(unionType.createReadOnly("value 2")).toEqual("value 2");
73-
expect(() => unionType.createReadOnly("value 3" as any)).toThrow();
74-
});
75-
76-
test("can create an eager, read-only instance", () => {
77-
const unionType = types.lazyUnion(types.literal("value 1"), types.literal("value 2"));
78-
expect(unionType.createReadOnly("value 1")).toEqual("value 1");
79-
expect(unionType.createReadOnly("value 2")).toEqual("value 2");
80-
expect(() => unionType.createReadOnly("value 3" as any)).toThrow();
81-
});
82-
83-
test("can be verified with is", () => {
84-
expect(unionType.is("value 1")).toEqual(true);
85-
expect(unionType.is("value 2")).toEqual(true);
86-
expect(unionType.is("value 3")).toEqual(false);
87-
expect(unionType.is(null)).toEqual(false);
88-
expect(unionType.is(true)).toEqual(false);
89-
expect(unionType.is({})).toEqual(false);
90-
});
91-
92-
test("can create a union of model types", () => {
93-
const modelTypeA = types.model({ x: types.string });
94-
const modelTypeB = types.model({ y: types.number });
95-
const unionType = types.union(modelTypeA, modelTypeB);
96-
const unionInstance = unionType.createReadOnly({ x: "test" });
97-
98-
expect(getSnapshot(unionInstance)).toEqual(
99-
expect.objectContaining({
100-
x: "test",
101-
})
102-
);
103-
});
104-
});
105-
10667
describe("refinement", () => {
10768
const smallStringsType = types.refinement(types.string, (v: string) => v.length <= 5);
10869

spec/union.spec.ts

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { getSnapshot, types } from "../src";
2+
import { create } from "./helpers";
3+
4+
describe("union", () => {
5+
const unionType = types.union(types.literal("value 1"), types.literal("value 2"));
6+
7+
test("can be verified with is", () => {
8+
expect(unionType.is("value 1")).toEqual(true);
9+
expect(unionType.is("value 2")).toEqual(true);
10+
expect(unionType.is("value 3")).toEqual(false);
11+
expect(unionType.is(null)).toEqual(false);
12+
expect(unionType.is(true)).toEqual(false);
13+
expect(unionType.is({})).toEqual(false);
14+
});
15+
16+
describe.each([
17+
["readonly", true],
18+
["observable", false],
19+
])("%s nodes", (_, readonly) => {
20+
test("can create an instance", () => {
21+
expect(create(unionType, "value 1", readonly)).toEqual("value 1");
22+
expect(create(unionType, "value 2", readonly)).toEqual("value 2");
23+
expect(() => create(unionType, "value 3" as any, readonly)).toThrow();
24+
});
25+
26+
test("can create an eager, read-only instance", () => {
27+
const unionType = types.lazyUnion(types.literal("value 1"), types.literal("value 2"));
28+
expect(create(unionType, "value 1", readonly)).toEqual("value 1");
29+
expect(create(unionType, "value 2", readonly)).toEqual("value 2");
30+
expect(() => create(unionType, "value 3" as any, readonly)).toThrow();
31+
});
32+
33+
test("can create a union of model types", () => {
34+
const modelTypeA = types.model({ x: types.string });
35+
const modelTypeB = types.model({ y: types.number });
36+
const unionType = types.union(modelTypeA, modelTypeB);
37+
const unionInstance = create(unionType, { x: "test" }, readonly);
38+
39+
expect(getSnapshot(unionInstance)).toEqual(
40+
expect.objectContaining({
41+
x: "test",
42+
})
43+
);
44+
});
45+
46+
test("nested unions", () => {
47+
const one = types.union(types.literal("A"), types.literal("B"));
48+
const two = types.union(types.literal("C"), types.literal("D"));
49+
const Union = types.union(one, two);
50+
51+
const aInstance = create(Union, "A", readonly);
52+
expect(aInstance).toEqual("A");
53+
54+
const dInstance = create(Union, "D", readonly);
55+
expect(dInstance).toEqual("D");
56+
});
57+
58+
describe("with an explicit dispatcher option", () => {
59+
test("can use a dispatcher", () => {
60+
const Apple = types.model({ color: types.string });
61+
const Banana = types.model({ ripeness: types.number });
62+
const Union = types.union(
63+
{
64+
dispatcher: (snapshot: any) => {
65+
if (snapshot.color) {
66+
return Apple;
67+
} else {
68+
return Banana;
69+
}
70+
},
71+
},
72+
Apple,
73+
Banana
74+
);
75+
76+
const appleInstance = create(Union, { color: "red" }, readonly);
77+
expect(Apple.is(appleInstance)).toBeTruthy();
78+
expect(Banana.is(appleInstance)).toBeFalsy();
79+
80+
const bananaInstance = create(Union, { ripeness: 2 }, readonly);
81+
expect(Apple.is(bananaInstance)).toBeFalsy();
82+
expect(Banana.is(bananaInstance)).toBeTruthy();
83+
});
84+
});
85+
86+
describe("with an explicit discriminator property", () => {
87+
test("can use a literal discriminator", () => {
88+
const Apple = types.model({ type: types.literal("apple"), color: types.string });
89+
const Banana = types.model({ type: types.literal("banana"), ripeness: types.number });
90+
const Union = types.union({ discriminator: "type" }, Apple, Banana);
91+
92+
const appleInstance = create(Union, { type: "apple", color: "red" }, readonly);
93+
expect(Apple.is(appleInstance)).toBeTruthy();
94+
expect(Banana.is(appleInstance)).toBeFalsy();
95+
96+
const bananaInstance = create(Union, { type: "banana", ripeness: 2 }, readonly);
97+
expect(Apple.is(bananaInstance)).toBeFalsy();
98+
expect(Banana.is(bananaInstance)).toBeTruthy();
99+
});
100+
101+
test("can use an optional literal discriminator", () => {
102+
const Apple = types.model({ type: types.optional(types.literal("apple"), "apple"), color: types.string });
103+
const Banana = types.model({ type: types.optional(types.literal("banana"), "banana"), ripeness: types.number });
104+
const Union = types.union({ discriminator: "type" }, Apple, Banana);
105+
106+
const appleInstance = create(Union, { type: "apple", color: "red" }, readonly);
107+
expect(Apple.is(appleInstance)).toBeTruthy();
108+
expect(Banana.is(appleInstance)).toBeFalsy();
109+
110+
const bananaInstance = create(Union, { type: "banana", ripeness: 2 }, readonly);
111+
expect(Apple.is(bananaInstance)).toBeFalsy();
112+
expect(Banana.is(bananaInstance)).toBeTruthy();
113+
});
114+
115+
test("does not get an error when passing a snapshot that doesn't have a value for the discriminator property", () => {
116+
const Apple = types.model({ type: types.optional(types.literal("apple"), "apple"), color: types.string });
117+
const Banana = types.model({ type: types.optional(types.literal("banana"), "banana"), ripeness: types.number });
118+
const Union = types.union({ discriminator: "type" }, Apple, Banana);
119+
120+
const apple = create(Union, { color: "red" }, readonly);
121+
expect(Apple.is(apple)).toBeTruthy();
122+
});
123+
124+
test("can use an literal discriminators within nested unions", () => {
125+
const Apple = types.model({ type: types.optional(types.literal("apple"), "apple"), color: types.string });
126+
const Banana = types.model({ type: types.optional(types.literal("banana"), "banana"), ripeness: types.number });
127+
const InnerUnion = types.union({ discriminator: "type" }, Apple, Banana);
128+
const Pear = types.model({ type: types.optional(types.literal("pear"), "pear"), species: types.string });
129+
const Union = types.union({ discriminator: "type" }, InnerUnion, Pear);
130+
131+
const appleInstance = create(Union, { type: "apple", color: "red" }, readonly);
132+
expect(Apple.is(appleInstance)).toBeTruthy();
133+
expect(Banana.is(appleInstance)).toBeFalsy();
134+
expect(Pear.is(appleInstance)).toBeFalsy();
135+
136+
const bananaInstance = create(Union, { type: "banana", ripeness: 2 }, readonly);
137+
expect(Apple.is(bananaInstance)).toBeFalsy();
138+
expect(Banana.is(bananaInstance)).toBeTruthy();
139+
expect(Pear.is(bananaInstance)).toBeFalsy();
140+
141+
const pearInstance = create(Union, { type: "pear", species: "anjou" }, readonly);
142+
expect(Apple.is(pearInstance)).toBeFalsy();
143+
expect(Banana.is(pearInstance)).toBeFalsy();
144+
expect(Pear.is(pearInstance)).toBeTruthy();
145+
});
146+
});
147+
});
148+
149+
test("gets an error when passing a snapshot that has an unrecognized value for the discriminator property", () => {
150+
const Apple = types.model({ type: types.optional(types.literal("apple"), "apple"), color: types.string });
151+
const Banana = types.model({ type: types.optional(types.literal("banana"), "banana"), ripeness: types.number });
152+
const Union = types.union({ discriminator: "type" }, Apple, Banana);
153+
154+
expect(() => create(Union, { type: "pear" } as any, true)).toThrowErrorMatchingInlineSnapshot(
155+
`"Discriminator property value \`pear\` for property \`type\` on incoming snapshot didn't correspond to a type. Options: apple, banana. Snapshot was \`{"type":"pear"}\`"`
156+
);
157+
});
158+
});

src/class-model.ts

+4
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,7 @@ function getPropertyDescriptor(obj: any, property: string) {
471471
}
472472
return null;
473473
}
474+
475+
export const isClassModel = (type: IAnyType): type is IClassModelType<any, any, any> => {
476+
return (type as any).isMQTClassModel;
477+
};

src/errors.ts

+3
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ export class CantRunActionError extends Error {}
33

44
/** Thrown when an invalid registration is passed to the class model register function */
55
export class RegistrationError extends Error {}
6+
7+
/** Thrown when a type in a union can't be used for discrimination because the value of the descriminator property can't be determined at runtime */
8+
export class InvalidDiscriminatorError extends Error {}

src/optional.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313

1414
export type DefaultFuncOrValue<T extends IAnyType> = T["InputType"] | T["OutputType"] | (() => CreateTypes<T>);
1515

16-
class OptionalType<T extends IAnyType, OptionalValues extends [ValidOptionalValue, ...ValidOptionalValue[]]> extends BaseType<
16+
export class OptionalType<T extends IAnyType, OptionalValues extends [ValidOptionalValue, ...ValidOptionalValue[]]> extends BaseType<
1717
T["InputType"] | OptionalValues[number],
1818
T["OutputType"],
1919
InstanceWithoutSTNTypeForType<T>

src/simple.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export class NullType extends BaseType<null, null, null> {
8080
}
8181
}
8282

83-
class LiteralType<T extends Primitives> extends SimpleType<T> {
83+
export class LiteralType<T extends Primitives> extends SimpleType<T> {
8484
constructor(readonly value: T) {
8585
super(typeof value, types.literal<T>(value));
8686
}

0 commit comments

Comments
 (0)