Skip to content

Commit

Permalink
Merge 95e6acc into ef36d97
Browse files Browse the repository at this point in the history
  • Loading branch information
monesidn committed Aug 18, 2022
2 parents ef36d97 + 95e6acc commit 7f5ecfd
Show file tree
Hide file tree
Showing 11 changed files with 671 additions and 696 deletions.
698 changes: 30 additions & 668 deletions README.md

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,34 @@ export type Props<T = any> = {
};
export type PropDef = PropSchema | boolean | undefined;

/**
* Define an object exposing a couple of methods that are used
* to discriminate between subschema.
*/
export interface DiscriminatorSpec {
/**
* This method is invoked during deserialization to check if the
* data should be deserialized as the specific type.
* @param src An object to inspect
* @returns `true` if the json matched the discriminator condition.
*/
isActualType(src: any): boolean;

/**
* If available this method is invoked during serialization and is meant to
* add discriminator information to the result json.
* @param result The result of the deserialization
*/
storeDiscriminator?(result: any): void;
}

export interface ModelSchema<T> {
targetClass?: Clazz<any>;
factory: (context: Context) => T;
props: Props<T>;
extends?: ModelSchema<any>;
subSchemas?: ModelSchema<any>[];
discriminator?: DiscriminatorSpec;
}

export type Clazz<T> = new (...args: any[]) => T;
Expand Down
7 changes: 7 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,11 @@ import primitive from "./types/primitive";
*/
export const SKIP = typeof Symbol !== "undefined" ? Symbol("SKIP") : { SKIP: true };

/**
* When using the decorator shorthand we store the given value in
* a specific attribute of the result structure. This constant contains
* the attribute name used in such scenario.
*/
export const DEFAULT_DISCRIMINATOR_ATTR = "_type";

export const _defaultPrimitiveProp = primitive();
22 changes: 19 additions & 3 deletions src/core/deserialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ function deserializeStarProps(
}
}

function identifyActualSchema(json: any, baseSchema: ModelSchema<any>) {
if (baseSchema.subSchemas?.length) {
for (const subSchema of baseSchema.subSchemas) {
if (subSchema.discriminator) {
if (subSchema.discriminator.isActualType(json)) {
return subSchema;
}
}
}
}
// If we can't find a specific schema we go with the base.
return baseSchema;
}

/**
* Deserializes a json structure into an object graph.
*
Expand Down Expand Up @@ -124,16 +138,18 @@ export function deserializeObjectWithSchema(
if (json === null || json === undefined || typeof json !== "object")
return void callback(null, null);

const context = new Context(parentContext, modelSchema, json, callback, customArgs);
const target = modelSchema.factory(context);
const actualSchema = identifyActualSchema(json, modelSchema);

const context = new Context(parentContext, actualSchema, json, callback, customArgs);
const target = actualSchema.factory(context);
// todo async invariant
invariant(!!target, "No object returned from factory");
// TODO: make invariant? invariant(schema.extends ||
// !target.constructor.prototype.constructor.serializeInfo, "object has a serializable
// supertype, but modelschema did not provide extends clause")
context.setTarget(target);
const lock = context.createCallback(GUARDED_NOOP);
deserializePropsWithSchema(context, modelSchema, json, target);
deserializePropsWithSchema(context, actualSchema, json, target);
lock();
return target;
}
Expand Down
48 changes: 25 additions & 23 deletions src/core/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,33 @@ export default function serialize<T>(modelSchema: ClazzOrModelSchema<T>, instanc
export default function serialize<T>(instance: T): any;
export default function serialize<T>(modelSchema: ClazzOrModelSchema<T>, instance: T[]): any;
export default function serialize<T>(instance: T[]): any;
export default function serialize<T>(
modelSchemaOrInstance: ClazzOrModelSchema<T> | T | [],
arg2?: T | []
) {
invariant(
arguments.length === 1 || arguments.length === 2,
"serialize expects one or 2 arguments"
);
const instance = (arg2 ?? modelSchemaOrInstance) as T;
let schema = (arg2 && modelSchemaOrInstance) as ClazzOrModelSchema<T> | undefined;
if (Array.isArray(instance)) {
if (instance.length === 0) return [];
// don't bother finding a schema
else if (!schema) schema = getDefaultModelSchema(instance[0]);
else if (typeof schema !== "object") schema = getDefaultModelSchema(schema);
} else if (!schema) {
schema = getDefaultModelSchema(instance);
export default function serialize<T>(...args: [ClazzOrModelSchema<T>, T | T[]] | [T | T[]]) {
invariant(args.length === 1 || args.length === 2, "serialize expects one or 2 arguments");

let schema: ClazzOrModelSchema<T> | undefined;
let value: T | T[];
if (args.length === 1) {
schema = undefined;
value = args[0];
} else {
[schema, value] = args;
}

if (Array.isArray(value)) {
return value.map((item) => (schema ? serialize(schema, item) : serialize(item)));
}

if (!schema) {
schema = getDefaultModelSchema(value);
} else if (typeof schema !== "object") {
schema = getDefaultModelSchema(schema);
}
const foundSchema = schema;
if (!foundSchema) {

if (!schema) {
// only call modelSchemaOrInstance.toString() on error
invariant(foundSchema, `Failed to find default schema for ${modelSchemaOrInstance}`);
invariant(schema, `Failed to find default schema for ${value}`);
}
if (Array.isArray(instance))
return instance.map((item) => serializeWithSchema(foundSchema, item));
return serializeWithSchema(foundSchema, instance);
return serializeWithSchema(schema, value);
}

function serializeWithSchema<T>(schema: ModelSchema<T>, obj: any): T {
Expand All @@ -69,6 +68,9 @@ function serializeWithSchema<T>(schema: ModelSchema<T>, obj: any): T {
}
res[propDef.jsonname || key] = jsonValue;
});
if (schema.discriminator?.storeDiscriminator) {
schema.discriminator.storeDiscriminator(res);
}
return res;
}

Expand Down
143 changes: 143 additions & 0 deletions src/core/subSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { DEFAULT_DISCRIMINATOR_ATTR } from "../constants";
import { Clazz, ClazzOrModelSchema, DiscriminatorSpec } from "../serializr";
import invariant from "../utils/invariant";
import { getOrCreateSchema } from "../utils/schemas";

/**
* Sometimes, when working with schema hierarchies, we may want to deserialize an object to
* a specific sub-schema. The `subSchema` decorator is used to handle such scenario.
* What schema is picked among those available is decided using a "discriminator". The
* discriminator can be a string (which is added to the serialized object) or a object
* containing callbacks allowing for more complex behaviour.
*
*
* @example
* ```ts
* class Todo {
* @serializable
* id: string;
*
* @serializable
* text: string;
* }
*
* @subSchema("picture")
* class PictureTodo extends Todo {
* @serializable
* pictureUrl: string;
* }
*
* const ser = serialize(Object.assign(new PictureTodo(), {
* id: "pic1",
* text: "Lorem Ipsum",
* pictureUrl:"foobar",
* }));
* // ser now holds an object like the following result
* // {
* // id: "pic1",
* // _type: "picture"
* // text: "Lorem Ipsum",
* // pictureUrl:"foobar",
* // }
* const deser = deserialize(Todo, ser);
* console.log(deser instanceof PictureTodo); // true
* ```
*
* @example
* Using the `parent` argument it's possible to specify the subschema parent instead
* of relying on auto-detention.
* ```ts
* class Todo {
* @serializable
* id: string;
*
* @serializable
* text: string;
* }
*
* @subSchema("picture")
* class PictureTodo extends Todo {
* @serializable
* pictureUrl: string;
* }
*
* @subSchema("betterPicture", Todo)
* class BetterPictureTodo extends PictureTodo {
* @serializable
* altText: string;
* }
*
*
* const ser = serialize(Object.assign(new BetterPictureTodo(), {
* id: "pic1",
* text: "Lorem Ipsum",
* pictureUrl:"foobar",
* altText: "Alt text",
* }));
* // ser now holds an object like the following result
* // {
* // id: "pic1",
* // _type: "betterPicture"
* // text: "Lorem Ipsum",
* // pictureUrl:"foobar",
* // altText: "Alt text",
* // }
* const deser = deserialize(Todo, ser);
* console.log(deser instanceof BetterPictureTodo); // true
* console.log(deser instanceof PictureTodo); // true
*
* const ser2 = serialize(Object.assign(new PictureTodo(), {
* id: "pic2",
* text: "Lorem Ipsum",
* pictureUrl:"foobar",
* }));
* // ser2 now holds an object like the following result
* // {
* // id: "pic2",
* // _type: "picture"
* // text: "Lorem Ipsum",
* // pictureUrl:"foobar",
* // }
* const deser2 = deserialize(Todo, ser2);
* console.log(deser2 instanceof BetterPictureTodo); // false
* console.log(deser2 instanceof PictureTodo); // true
* ```
*
* @param discriminator An object providing the discriminator logic or a string/number
* that will be stored into the `_type` attribute.
* @param parent When there are multiple levels of hierarchy involved you may provide this
* argument to indicate the main schema used for deserialization. When not give the parent
* schema is inferred as the direct parent (the class/schema that is extended).
*
* @returns
*/
export default function subSchema(
discriminator: DiscriminatorSpec | string | number,
parent?: ClazzOrModelSchema<any>
): (clazz: Clazz<any>) => Clazz<any> {
return (target: Clazz<any>): Clazz<any> => {
const childSchema = getOrCreateSchema(target);
invariant(
childSchema?.extends,
"Can not apply subSchema on a schema not extending another one."
);

const parentSchema = getOrCreateSchema(parent || childSchema.extends);
parentSchema.subSchemas = parentSchema.subSchemas ?? [];
parentSchema.subSchemas.push(childSchema);

if (typeof discriminator === "object") {
childSchema.discriminator = discriminator;
} else {
childSchema.discriminator = {
isActualType(src) {
return src[DEFAULT_DISCRIMINATOR_ATTR] === discriminator;
},
storeDiscriminator(result) {
result[DEFAULT_DISCRIMINATOR_ATTR] = discriminator;
},
};
}
return target;
};
}
3 changes: 2 additions & 1 deletion src/serializr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from "./api/types";
*/
export { default as serialize } from "./core/serialize";
export { default as serializeAll } from "./core/serializeAll";
export { default as subSchema } from "./core/subSchema";
export { default as cancelDeserialize } from "./core/cancelDeserialize";
export { default as deserialize } from "./core/deserialize";
export { default as update } from "./core/update";
Expand All @@ -30,4 +31,4 @@ export { default as map } from "./types/map";
export { default as mapAsArray } from "./types/mapAsArray";
export { default as raw } from "./types/raw";

export { SKIP } from "./constants";
export { SKIP, DEFAULT_DISCRIMINATOR_ATTR } from "./constants";
21 changes: 21 additions & 0 deletions src/utils/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import createModelSchema from "../api/createModelSchema";
import getDefaultModelSchema from "../api/getDefaultModelSchema";
import { ClazzOrModelSchema, ModelSchema } from "../api/types";
import { isModelSchema } from "./utils";

/**
* A simple util that retrieve the existing schema or create a default one.
* @param src
* @returns
*/
export const getOrCreateSchema = <T extends object>(src: ClazzOrModelSchema<T>): ModelSchema<T> => {
if (isModelSchema(src)) {
return src;
} else {
let schema = getDefaultModelSchema<T>(src);
if (!schema) {
schema = createModelSchema(src, {});
}
return schema;
}
};
3 changes: 2 additions & 1 deletion src/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AdditionalPropArgs, ModelSchema, PropSchema } from "../api/types";

import invariant from "./invariant";
import { ModelSchema, AdditionalPropArgs, PropSchema } from "../api/types";

export function GUARDED_NOOP(err?: any) {
if (err)
Expand Down
Loading

0 comments on commit 7f5ecfd

Please sign in to comment.