Skip to content

Commit

Permalink
WIP view cacher
Browse files Browse the repository at this point in the history
[no-changelog-required]
  • Loading branch information
airhorns committed Nov 9, 2023
1 parent d9932f3 commit 1766986
Show file tree
Hide file tree
Showing 6 changed files with 289 additions and 30 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"dependencies": {
"lodash.memoize": "^4.1.2",
"mobx": "^6.5.0",
"mobx-state-tree": "^5.3.0",
"mobx-state-tree": "https://codeload.github.com/airhorns/mobx-state-tree/tar.gz/mobx-state-tree-v5.3.1-gitpkg-4b79173e",
"reflect-metadata": "^0.1.13"
},
"devDependencies": {
Expand Down
23 changes: 13 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

152 changes: 152 additions & 0 deletions spec/class-model-cached-views.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { ClassModel, action, cachedView, getSnapshot, register, types } from "../src";

@register
class ViewExample extends ClassModel({ key: types.identifier, name: types.string }) {
@cachedView()
get slug() {
return this.name.toLowerCase().replace(/ /g, "-");
}

@action
setName(name: string) {
this.name = name;
}
}

describe("class model cached views", () => {
test("an observable instance saves the view value in a snapshot when changed", () => {
const instance = ViewExample.create({ key: "1", name: "Test" });
expect(instance.slug).toEqual("test");
let snapshot = getSnapshot(instance);
expect(snapshot).toEqual({ key: "1", name: "Test" }); // no snapshot output as the object hasn't changed yet
instance.setName("New Name");
snapshot = getSnapshot(instance);
expect(snapshot).toEqual({ key: "1", name: "New Name", slug: "new-name" });
});

test("an observable instance updates the saved view when the observed view value changes", () => {
const instance = ViewExample.create({ key: "1", name: "Test" });
instance.setName("New Name");
expect(instance.slug).toEqual("new-name");
const snapshot = getSnapshot(instance);
expect(snapshot).toEqual({ key: "1", name: "New Name", slug: "new-name" });
});

test("an observable instance ignores the input snapshot value as the logic may have changed", () => {
const instance = ViewExample.create({ key: "1", name: "Test", slug: "outdated-cache" } as any);
expect(instance.slug).toEqual("test");
});

test("an readonly instance returns the view value from the snapshot if present", () => {
const instance = ViewExample.createReadOnly({ key: "1", name: "Test", slug: "test" } as any);
expect(instance.slug).toEqual("test");
});

test("an readonly instance doesn't recompute the view value from the snapshot", () => {
const instance = ViewExample.createReadOnly({ key: "1", name: "Test", slug: "whatever" } as any);
expect(instance.slug).toEqual("whatever");
});

test("an readonly instance doesn't call the computed function if given a snapshot value", () => {
const fn = jest.fn();
@register
class Spy extends ClassModel({ name: types.string }) {
@cachedView()
get slug() {
fn();
return this.name.toLowerCase().replace(/ /g, "-");
}
}

const instance = Spy.createReadOnly({ name: "Test", slug: "whatever" } as any);
expect(instance.slug).toEqual("whatever");
expect(fn).not.toHaveBeenCalled();
});

test("an observable instance doesn't call the computed function until snapshotted", () => {
const fn = jest.fn();
@register
class Spy extends ClassModel({ name: types.string }) {
@cachedView()
get slug() {
fn();
return this.name.toLowerCase().replace(/ /g, "-");
}
@action
setName(name: string) {
this.name = name;
}
}

const instance = Spy.create({ name: "Test", slug: "whatever" } as any);
expect(fn).not.toHaveBeenCalled();
getSnapshot(instance);
expect(fn).not.toHaveBeenCalled();

instance.setName("New Name");
expect(fn).toHaveBeenCalled();
});

test("an readonly instance doesn't require the snapshot to include the cache", () => {
const instance = ViewExample.createReadOnly({ key: "1", name: "Test" });
expect(instance.slug).toEqual("test");
});

test("cached views can be passed nested within snapshots", () => {
@register
class Outer extends ClassModel({ examples: types.array(ViewExample) }) {}

const instance = Outer.createReadOnly({
examples: [{ key: "1", name: "Test", slug: "test-foobar" } as any, { key: "2", name: "Test 2", slug: "test-qux" } as any],
});

expect(instance.examples[0].slug).toEqual("test-foobar");
expect(instance.examples[1].slug).toEqual("test-qux");
});

describe("with a hydrator", () => {
@register
class HydrateExample extends ClassModel({ timestamp: types.string }) {
@cachedView<Date>({
getSnapshot(value, snapshot, node) {
expect(snapshot).toBeDefined();
expect(node).toBeDefined();
return value.toISOString();
},
createReadOnly(value, snapshot, node) {
expect(snapshot).toBeDefined();
expect(node).toBeDefined();
return value ? new Date(value) : undefined;
},
})
get startOfMonth() {
const date = new Date(this.timestamp);
date.setDate(0);
return date;
}

@action
setTimestamp(timestamp: string) {
this.timestamp = timestamp;
}
}

test("cached views with processors can be accessed on observable instances", () => {
const instance = HydrateExample.create({ timestamp: "2021-01-02T00:00:00.000Z" });
expect(instance.startOfMonth).toEqual(new Date("2021-01-01T00:00:00.000Z"));
});

test("cached views with processors can be accessed on readonly instances when there's no input data", () => {
const instance = HydrateExample.createReadOnly({ timestamp: "2021-01-02T00:00:00.000Z" });
expect(instance.startOfMonth).toEqual(new Date("2021-01-01T00:00:00.000Z"));
});

test("cached views with processors can be accessed on readonly instances when there is input data", () => {
const instance = HydrateExample.createReadOnly({
timestamp: "2021-01-02T00:00:00.000Z",
startOfMonth: "2021-02-01T00:00:00.000Z",
} as any);
expect(instance.startOfMonth).toEqual(new Date("2021-02-01T00:00:00.000Z"));
});
});
});
2 changes: 1 addition & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export {
unescapeJsonPath,
walk,
} from "mobx-state-tree";
export { ClassModel, action, extend, register, view, volatile, volatileAction } from "./class-model";
export { ClassModel, action, extend, register, view, cachedView, volatile, volatileAction } from "./class-model";
export { getSnapshot } from "./snapshot";

export const isType = (value: any): value is IAnyType => {
Expand Down
85 changes: 78 additions & 7 deletions src/class-model.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import memoize from "lodash.memoize";
import type { IModelType as MSTIModelType, ModelActions } from "mobx-state-tree";
import type { Instance, IModelType as MSTIModelType, ModelActions } from "mobx-state-tree";
import { types as mstTypes } from "mobx-state-tree";
import "reflect-metadata";
import { RegistrationError } from "./errors";
import { buildFastInstantiator } from "./fast-instantiator";
import { InstantiatorBuilder } from "./fast-instantiator";
import { defaultThrowAction, mstPropsFromQuickProps, propsFromModelPropsDeclaration } from "./model";
import {
$env,
Expand All @@ -22,6 +22,7 @@ import {
import type {
Constructor,
ExtendedClassModel,
IAnyClassModelType,
IAnyType,
IClassModelType,
IStateTreeNode,
Expand All @@ -39,12 +40,24 @@ type ActionMetadata = {
volatile: boolean;
};

export interface CachedViewOptions<V, T extends IAnyClassModelType> {
createReadOnly?: (value: V | undefined, snapshot: T["InputType"], node: Instance<T>) => V | undefined;
getSnapshot?: (value: V, snapshot: T["InputType"], node: Instance<T>) => any;
}

/** @internal */
type ViewMetadata = {
export type ViewMetadata = {
type: "view";
property: string;
};

/** @internal */
export type CachedViewMetadata = {
type: "cached-view";
property: string;
cache: CachedViewOptions<any, any>;
};

/** @internal */
export type VolatileMetadata = {
type: "volatile";
Expand All @@ -53,7 +66,7 @@ export type VolatileMetadata = {
};

type VolatileInitializer<T> = (instance: T) => Record<string, any>;
type PropertyMetadata = ActionMetadata | ViewMetadata | VolatileMetadata;
type PropertyMetadata = ActionMetadata | ViewMetadata | CachedViewMetadata | VolatileMetadata;

const metadataPrefix = "mqt:properties";
const viewKeyPrefix = `${metadataPrefix}:view`;
Expand Down Expand Up @@ -158,13 +171,20 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc

for (const metadata of metadatas) {
switch (metadata.type) {
case "cached-view":
case "view": {
const property = metadata.property;
const descriptor = getPropertyDescriptor(klass.prototype, property);
if (!descriptor) {
throw new RegistrationError(`Property ${property} not found on ${klass} prototype, can't register view for class model`);
}

if ("cache" in metadata && !descriptor.get) {
throw new RegistrationError(
`Cached view property ${property} on ${klass} must be a getter -- can't use cached views with views that are functions or take arguments`
);
}

// memoize getters on readonly instances
if (descriptor.get) {
Object.defineProperty(klass.prototype, property, {
Expand All @@ -186,6 +206,7 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
...descriptor,
enumerable: true,
});

break;
}

Expand Down Expand Up @@ -278,17 +299,37 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
});

// create the MST type for not-readonly versions of this using the views and actions extracted from the class
klass.mstType = mstTypes
let mstType = mstTypes
.model(klass.name, mstPropsFromQuickProps(klass.properties))
.views((self) => bindToSelf(self, mstViews))
.actions((self) => bindToSelf(self, mstActions));

if (Object.keys(mstVolatiles).length > 0) {
// define the volatile properties in one shot by running any passed initializers
(klass as any).mstType = (klass as any).mstType.volatile((self: any) => initializeVolatiles({}, self, mstVolatiles));
mstType = mstType.volatile((self: any) => initializeVolatiles({}, self, mstVolatiles));
}

const cachedViews = metadatas.filter((metadata) => metadata.type == "cached-view") as CachedViewMetadata[];
if (cachedViews.length > 0) {
mstType = mstTypes.snapshotProcessor(mstType, {
postProcessor(snapshot, node) {
const stn = node.$treenode!;
if (stn.state == 2 /** NodeLifeCycle.FINALIZED */) {
for (const cachedView of cachedViews) {
let value = node[cachedView.property];
if (cachedView.cache.getSnapshot) {
value = cachedView.cache.getSnapshot(value, snapshot, node);
}
snapshot[cachedView.property] = value;
}
}
return snapshot;
},
}) as any;
}

klass = buildFastInstantiator(klass);
klass.mstType = mstType;
klass = new InstantiatorBuilder(klass, cachedViews).build();
(klass as any)[$registered] = true;

return klass as any;
Expand Down Expand Up @@ -318,6 +359,36 @@ export const view = (target: any, property: string, _descriptor: PropertyDescrip
Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target);
};

/**
* Function decorator for registering MQT cached views within MQT class models. Stores the view's value into the snapshot when an instance is snapshotted, and uses that stored value for readonly instances created from snapshots.
*
* Can be passed an `options` object with a `preProcess` and/or `postProcess` function for transforming the cached value stored in the snapshot to and from the snapshot state.
*
* @example
* class Example extends ClassModel({ name: types.string }) {
* @cachedView
* get slug() {
* return this.name.toLowerCase().replace(/ /g, "-");
* }
* }
*
* @example
* class Example extends ClassModel({ timestamp: types.string }) {
* @cachedView({ preProcess: (value) => new Date(value), postProcess: (value) => value.toISOString() })
* get date() {
* return new Date(timestamp).setTime(0);
* }
* }
*/
export function cachedView<V, T extends IAnyClassModelType = IAnyClassModelType>(
options: CachedViewOptions<V, T> = {}
): (target: any, property: string, _descriptor: PropertyDescriptor) => void {
return (target: any, property: string, _descriptor: PropertyDescriptor) => {
const metadata: CachedViewMetadata = { type: "cached-view", property, cache: options };
Reflect.defineMetadata(`${viewKeyPrefix}:${property}`, metadata, target);
};
}

/**
* A function for defining a volatile
**/
Expand Down
Loading

0 comments on commit 1766986

Please sign in to comment.