From d0079c6bacbf2d52d4103226b0af99b5ae039f5b Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Fri, 17 Aug 2018 22:08:59 +0200 Subject: [PATCH] Initial serialization implementation --- package-lock.json | 60 +++++--- package.json | 2 + src/create-trait.js | 33 ++--- src/create-type.js | 71 ++++++++-- src/generate-validator.js | 62 +++++---- src/json-stringify-pre-replacer/index.js | 49 +++++++ src/registry.js | 14 ++ src/serialization/deserialize.js | 151 +++++++++++++++++++++ src/serialization/placeholder-manager.js | 94 +++++++++++++ src/serialization/serialize.js | 52 +++++++ src/type-hash.js | 166 +++++++++++++++++++++++ src/util/filter-type-rules.js | 9 ++ src/util/is-placeholder.js | 5 + test/registry.js | 1 + test/serialization.js | 94 +++++++++++++ 15 files changed, 793 insertions(+), 70 deletions(-) create mode 100644 src/json-stringify-pre-replacer/index.js create mode 100644 src/serialization/deserialize.js create mode 100644 src/serialization/placeholder-manager.js create mode 100644 src/serialization/serialize.js create mode 100644 src/type-hash.js create mode 100644 src/util/filter-type-rules.js create mode 100644 src/util/is-placeholder.js create mode 100644 test/serialization.js diff --git a/package-lock.json b/package-lock.json index b22fa31..9f61445 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,8 @@ "integrity": "sha1-CfPeMckWQl1JjMLuVloOvzwqVik=", "dev": true, "requires": { - "lodash": "^4.17.4", - "platform": "^1.3.3" + "lodash": "4.17.10", + "platform": "1.3.5" } }, "brace-expansion": { @@ -32,7 +32,7 @@ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "requires": { - "balanced-match": "^1.0.0", + "balanced-match": "1.0.0", "concat-map": "0.0.1" } }, @@ -53,12 +53,12 @@ "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", "dev": true, "requires": { - "assertion-error": "^1.0.1", - "check-error": "^1.0.1", - "deep-eql": "^3.0.0", - "get-func-name": "^2.0.0", - "pathval": "^1.0.0", - "type-detect": "^4.0.0" + "assertion-error": "1.1.0", + "check-error": "1.0.2", + "deep-eql": "3.0.1", + "get-func-name": "2.0.0", + "pathval": "1.1.0", + "type-detect": "4.0.8" } }, "check-error": { @@ -99,7 +99,15 @@ "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", "dev": true, "requires": { - "type-detect": "^4.0.0" + "type-detect": "4.0.8" + } + }, + "default-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-value/-/default-value-1.0.0.tgz", + "integrity": "sha1-jG9SpaEZP+eP3J+G63HRbJdXyDo=", + "requires": { + "es6-promise-try": "0.0.1" } }, "diff": { @@ -108,6 +116,11 @@ "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", "dev": true }, + "es6-promise-try": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/es6-promise-try/-/es6-promise-try-0.0.1.tgz", + "integrity": "sha1-EPFA2tJ0Wc75SZc+XSGgh/cnSyA=" + }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -137,12 +150,12 @@ "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", "dev": true, "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" } }, "growl": { @@ -169,8 +182,8 @@ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "requires": { - "once": "^1.3.0", - "wrappy": "1" + "once": "1.4.0", + "wrappy": "1.0.2" } }, "inherits": { @@ -196,7 +209,7 @@ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "requires": { - "brace-expansion": "^1.1.7" + "brace-expansion": "1.1.11" } }, "minimist": { @@ -239,6 +252,11 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true }, + "nanoid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-1.2.0.tgz", + "integrity": "sha512-rJvd0q5Bq375+jrMAJh8vZk+0Q4lnHyuqZL2fbrc9moYy4DCld5VSycYLXvwFHbbut1+UcjA+fm0bq4ADVBYQg==" + }, "object-hash": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.0.tgz", @@ -250,7 +268,7 @@ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "requires": { - "wrappy": "1" + "wrappy": "1.0.2" } }, "path-is-absolute": { @@ -277,7 +295,7 @@ "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", "dev": true, "requires": { - "has-flag": "^3.0.0" + "has-flag": "3.0.0" } }, "type-detect": { diff --git a/package.json b/package.json index df2facd..342fa58 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,10 @@ "dependencies": { "capitalize": "^1.0.0", "create-error": "^0.3.1", + "default-value": "^1.0.0", "filter-obj": "^1.1.0", "map-obj": "^2.0.0", + "nanoid": "^1.2.0", "object-hash": "^1.3.0" } } diff --git a/src/create-trait.js b/src/create-trait.js index 28ed9ad..18720ed 100644 --- a/src/create-trait.js +++ b/src/create-trait.js @@ -20,6 +20,7 @@ module.exports = function createTrait(name, schema, options = {}) { return typeRules._createTypeRule({ _isTrait: true, _name: name, + _schema: schema, applyImplementation: function (implementation) { /* FIXME: Verify that there are no extraneous keys or unspecified values */ let validatedImplementation = Object.create({ @@ -33,29 +34,29 @@ module.exports = function createTrait(name, schema, options = {}) { let nullFields = nullMissingFields(implementation, schemaKeys); Object.assign(validatedImplementation, implementationWithoutSlots, nullFields); - // let slotProperties = filterObj(implementation, (key, value) => { - // return (value != null && value._isSlotRule === true); - // }); - // - // let slotDescriptors = mapObj(slotProperties, (key, value) => { - // return generateDescriptor(schema[key], key); - // }); - - let implementationDescriptors = mapObj(implementation, (key, value) => { + let implementationSchema = mapObj(implementation, (key, value) => { if (value != null && value._isSlotRule === true) { - return generateDescriptor(schema[key], key, true); + return [key, { + isSlot: true, + rule: schema[key] + }]; } else { - return generateDescriptor(value, key, false); + return [key, { + isSlot: false, + rule: value + }]; } }); - /* FIXME: Get rid of duplication here somehow? */ - let slotSchemaKeys = Object.keys(implementation).filter((key) => { - let value = implementation[key]; - return (value != null && value._isSlotRule != null); + let implementationDescriptors = mapObj(implementationSchema, (key, value) => { + return generateDescriptor(value.rule, key, value.isSlot); }); - return {implementationDescriptors, slotSchemaKeys}; + let slotSchemaKeys = Object.keys(filterObj(implementationSchema, (key, value) => { + return value.isSlot; + })); + + return {implementationSchema, implementationDescriptors, slotSchemaKeys}; } }); }; diff --git a/src/create-type.js b/src/create-type.js index e9a8f27..d8538e5 100644 --- a/src/create-type.js +++ b/src/create-type.js @@ -1,12 +1,28 @@ "use strict"; -const objectHash = require("object-hash"); const mapObj = require("map-obj"); const typeRules = require("./type-rules"); const generateDescriptor = require("./generate-descriptor"); const getSchemaKeys = require("./util/get-schema-keys"); const nullMissingFields = require("./util/null-missing-fields"); +const generateTypeHash = require("./type-hash"); +const serialize = require("./serialization/serialize"); +const deserialize = require("./serialization/deserialize"); + +let registeredTypes = new Set(); +let registeredTypesDirty = false; +let typeMap = new Map(); + +function hashAllTypes() { + if (registeredTypesDirty) { + registeredTypesDirty = false; + + for (let type of registeredTypes) { + type._ensureHash(); + } + } +} module.exports = function createType(name, schema) { if (schema._isTypeRule === true) { @@ -25,7 +41,13 @@ module.exports = function createType(name, schema) { /* We pre-define this here so that instances can be monomorphic; this provides a 4x performance improvement. */ _data: mapObj(schema, (key, _value) => { return [key, undefined]; - }) + }), + /* FIXME: Disallow overriding this. */ + serialize: function (serializer) { + return serialize(this, { + serializer: serializer + }); + } }; let proto = Object.create(protoProperties, propertyDescriptors); @@ -55,24 +77,40 @@ module.exports = function createType(name, schema) { proto._type = factory; factory._schemaKeys = schemaKeys; + factory._cumulativeSchema = Object.assign({}, schema); + factory._isUsed = false; + factory._typeHash = null; factory._isCustomType = true; factory._name = name; factory.prototype = proto; factory.description = name; + factory._validator = function (value) { return (value instanceof factory); }; + factory._ensureHash = function () { + if (this._typeHash == null) { + this._isUsed = true; + this._typeHash = generateTypeHash(this); + typeMap.set(this._typeHash, this); + } + }; + /* FIXME: Is the below actually necessary? */ proto._selfValidator = factory._validator; - factory._isUsed = false; factory._implementedTraits = new Set(); factory.implements = function (trait, implementation) { - if (factory._isUsed !== false) { + /* FIXME: Deregister old hash from global hash registry after mutating through trait implementation */ + if (this._isUsed !== false) { throw new Error("Cannot modify a custom type that has already been instantiated"); } else { - let {implementationDescriptors, slotSchemaKeys} = trait.applyImplementation(implementation); + let {implementationDescriptors, slotSchemaKeys, implementationSchema} = trait.applyImplementation(implementation); + + let plainImplementationSchema = mapObj(implementationSchema, (key, value) => { + return [key, value.rule]; + }); // FIXME: Test this Object.keys(implementationDescriptors).forEach((key) => { @@ -82,13 +120,30 @@ module.exports = function createType(name, schema) { }); Object.defineProperties(proto, implementationDescriptors); - factory._implementedTraits.add(trait); - factory._schemaKeys = factory._schemaKeys.concat(slotSchemaKeys); + this._implementedTraits.add(trait); + this._schemaKeys = this._schemaKeys.concat(slotSchemaKeys); + Object.assign(this._cumulativeSchema, plainImplementationSchema); + + /* TODO: Verify that this produces the expected performance benefit for trait-having typesw. */ + for (let key of slotSchemaKeys) { + protoProperties._data[key] = null; + } return this; } }; - return typeRules._createTypeRule(factory); + factory.deserialize = function (data, deserializer) { + hashAllTypes(); + + return deserialize(this, JSON.parse(data), typeMap, { + deserializer: deserializer + }); + }; + + let rule = typeRules._createTypeRule(factory); + registeredTypes.add(rule); + registeredTypesDirty = true; + return rule; } }; diff --git a/src/generate-validator.js b/src/generate-validator.js index 9243dab..23ca272 100644 --- a/src/generate-validator.js +++ b/src/generate-validator.js @@ -65,7 +65,9 @@ module.exports = function generateValidator(rule, name = "") { let validator = rule._validator; baseRule = function (value) { - if (validator.call(this, value) === true) { + if (value._isDeserializationPlaceholder === true) { + return true; + } else if (validator.call(this, value) === true) { return true; } else { return new errors.ValidationError(`Expected ${getValueType(rule)}, got ${getValueType(value)} instead`); @@ -77,33 +79,39 @@ module.exports = function generateValidator(rule, name = "") { baseRule = function (value) { /* FIXME: Better error when the type is unknown */ - let actualType = rule._registry._types.get(rule._name); + if (value._isDeserializationPlaceholder === true) { + return true; + } else { + let actualType = rule._registry._getType(rule._name); - if (actualType._isCustomType) { - if (actualType._validator.call(this, value) === true) { - return true; + if (actualType._isCustomType) { + if (actualType._validator.call(this, value) === true) { + return true; + } else { + return new errors.ValidationError(`Expected ${getValueType(rule)}, got ${getValueType(value)} instead`); + } } else { - return new errors.ValidationError(`Expected ${getValueType(rule)}, got ${getValueType(value)} instead`); - } - } else { - if (aliasValidator == null) { - aliasValidator = generateValidator(actualType._alias, name); - } + if (aliasValidator == null) { + aliasValidator = generateValidator(actualType._alias, name); + } - let validatorResult = aliasValidator.call(this, value); + let validatorResult = aliasValidator.call(this, value); - if (validatorResult === true) { - return true; - } else { - // return new errors.ValidationError(`Expected ${getValueType(actualType._alias)}, got ${getValueType(value)} instead`); - /* FIXME: Possible this is done wrong elsewhere too, swallowing the actual validation error? */ - return validatorResult; + if (validatorResult === true) { + return true; + } else { + // return new errors.ValidationError(`Expected ${getValueType(actualType._alias)}, got ${getValueType(value)} instead`); + /* FIXME: Possible this is done wrong elsewhere too, swallowing the actual validation error? */ + return validatorResult; + } } } }; } else if (rule._isTrait === true) { baseRule = function (value) { - if (value._type != null && value._type._implementedTraits != null && value._type._implementedTraits.has(rule)) { + if (value._isDeserializationPlaceholder === true) { + return true; + } else if (value._type != null && value._type._implementedTraits != null && value._type._implementedTraits.has(rule)) { return true; } else { return new errors.ValidationError(`Expected object of a type with the ${rule._name} trait, got ${getValueType(value)} instead`); @@ -111,14 +119,18 @@ module.exports = function generateValidator(rule, name = "") { }; } else if (rule._isRegistryTrait === true) { baseRule = function (value) { - /* TODO: The below approach requires that traits be defined before the types that use them, and disallows trait registry references in `.implements` calls, due to _implementedTraits always needing to contain actual trait definitions; in the future, a better approach needs to be found for this such that trait registry references can be used everywhere. */ - let actualRule = rule._registry._traits.get(rule._name); - - /* FIXME: Better error when the trait is unknown */ - if (value._type != null && value._type._implementedTraits != null && value._type._implementedTraits.has(actualRule)) { + if (value._isDeserializationPlaceholder === true) { return true; } else { - return new errors.ValidationError(`Expected object of a type with the ${rule._name} trait, got ${getValueType(value)} instead`); + /* TODO: The below approach requires that traits be defined before the types that use them, and disallows trait registry references in `.implements` calls, due to _implementedTraits always needing to contain actual trait definitions; in the future, a better approach needs to be found for this such that trait registry references can be used everywhere. */ + let actualRule = rule._registry._getTrait(rule._name); + + /* FIXME: Better error when the trait is unknown */ + if (value._type != null && value._type._implementedTraits != null && value._type._implementedTraits.has(actualRule)) { + return true; + } else { + return new errors.ValidationError(`Expected object of a type with the ${rule._name} trait, got ${getValueType(value)} instead`); + } } }; } else if (rule._isSelfRule === true) { diff --git a/src/json-stringify-pre-replacer/index.js b/src/json-stringify-pre-replacer/index.js new file mode 100644 index 0000000..190db2d --- /dev/null +++ b/src/json-stringify-pre-replacer/index.js @@ -0,0 +1,49 @@ +"use strict"; + +module.exports = function jsonStringifyPreReplacer(data, replacer = ((key, value) => value)) { + function stringifyPiece(parent, key, node, level) { + let replacedNode = replacer(key, node); + + let serializedNode; + + if (replacedNode != null && typeof replacedNode.toJSON === "function") { + serializedNode = replacedNode.toJSON(); + } else { + serializedNode = replacedNode; + } + + if (serializedNode === undefined) { + return; + } else if (typeof serializedNode !== "object" || serializedNode === null) { + return JSON.stringify(serializedNode); + } else if (Array.isArray(serializedNode)) { + let stringifiedItems = serializedNode.map((item, i) => { + let stringifiedItem = stringifyPiece(serializedNode, i, item, level + 1); + + if (stringifiedItem === undefined) { + return null; + } else { + return stringifiedItem; + } + }); + + return `[${stringifiedItems.join(",")}]`; + } else { + let stringifiedItems = Object.entries(serializedNode).map(([key, value]) => { + let stringifiedValue = stringifyPiece(serializedNode, key, value, level + 1); + + if (stringifiedValue !== undefined) { + return `${JSON.stringify(key)}:${stringifiedValue}`; + } else { + return undefined; + } + }).filter((item) => { + return (item !== undefined); + }); + + return `{${stringifiedItems.join(",")}}`; + } + } + + return stringifyPiece({"": data}, "", data, 0); +}; diff --git a/src/registry.js b/src/registry.js index 4b24935..2876067 100644 --- a/src/registry.js +++ b/src/registry.js @@ -9,6 +9,20 @@ module.exports = function createRegistry() { return Object.assign({}, moduleAPI, { _types: new Map(), _traits: new Map(), + _getType: function (name) { + if (this._types.has(name)) { + return this._types.get(name); + } else { + throw new Error(`No type named ${name} was found in the registry`); + } + }, + _getTrait: function (name) { + if (this._traits.has(name)) { + return this._traits.get(name); + } else { + throw new Error(`No trait named ${name} was found in the registry`); + } + }, createType: function (name, schema, options = {}) { if (!this._types.has(name)) { let type = moduleAPI.createType(name, schema, options); diff --git a/src/serialization/deserialize.js b/src/serialization/deserialize.js new file mode 100644 index 0000000..3fdcd0e --- /dev/null +++ b/src/serialization/deserialize.js @@ -0,0 +1,151 @@ +"use strict"; + +const defaultValue = require("default-value"); +const mapObj = require("map-obj"); + +const isPlaceholder = require("../util/is-placeholder"); +const createPlaceholderManager = require("./placeholder-manager"); + +function isReference(serializedData) { + return (serializedData != null && serializedData._d_sT === "cTR"); +} + +module.exports = function createDeserializer(topLevelType, topLevelData, typeMap, options = {}) { + let seen = new Map(); + let placeholders = createPlaceholderManager(); + + let customDeserializer = defaultValue(options.deserializer, (value) => value); + + function deserializeSet(rule, entries) { + let seenPlaceholder = false; + + let set = new Set(entries.map((item) => { + let deserializedValue = deserializeEntry(rule._itemType, item); + + if (isPlaceholder(deserializedValue)) { + seenPlaceholder = true; + } + + return deserializedValue; + })); + + if (seenPlaceholder) { + placeholders.markSet(set); + } + + return set; + } + + function deserializeMap(rule, entries) { + let seenPlaceholder = false; + + let map = new Map(entries.map(([itemKey, itemValue]) => { + let deserializedKey = deserializeEntry(rule._keyType, itemKey); + let deserializedValue = deserializeEntry(rule._itemType, itemValue); + + if (isPlaceholder(deserializedKey) || isPlaceholder(deserializedValue)) { + seenPlaceholder = true; + } + + return [ + deserializedKey, + deserializedValue + ]; + })); + + if (seenPlaceholder) { + placeholders.markMap(map); + } + + return map; + } + + function deserializeEntry(rule, serializedData) { + if (rule._isCustomType) { + return deserializeInstanceOrReference(rule, serializedData); + } else if (rule._isCustomRegistryType) { + return deserializeInstanceOrReference(rule._registry._getType(rule._name), serializedData); + } else if (rule._isRegistryTrait || rule._isTrait) { + return deserializeInstanceFuzzy(serializedData); + } else if (rule._collectionType === "set") { + if (serializedData._d_sT === "gS") { + return deserializeSet(rule, serializedData.entries); + } else { + /* FIXME: Clearer error message */ + throw new Error("Expected guarded set, but got something else instead"); + } + } else if (rule._collectionType === "map") { + if (serializedData._d_sT === "gM") { + return deserializeMap(rule, serializedData.entries); + } else { + /* FIXME: Clearer error message */ + throw new Error("Expected guarded map, but got something else instead"); + } + } else { + /* This also covers `either` rules where the serializedData is a type instance or reference. */ + return deserializeEntryFuzzy(serializedData); + } + } + + function deserializeEntryFuzzy(serializedData) { + if (serializedData != null && (serializedData._d_sT === "cT" || serializedData._d_sT === "cTR")) { + return deserializeInstanceFuzzy(serializedData); + } else { + return customDeserializer(serializedData); + } + } + + function deserializeInstance(typeFactory, serializedData) { + let placeholderProperties = []; + + let deserializedData = mapObj(serializedData.data, (key, value) => { + let rule = typeFactory._cumulativeSchema[key]; + let deserializedValue = deserializeEntry(rule, value); + + if (isPlaceholder(deserializedValue)) { + placeholderProperties.push(key); + } + + return [key, deserializedValue]; + }); + + let instance = typeFactory(deserializedData); + + for (let property of placeholderProperties) { + placeholders.markProperty(instance, property); + } + + seen.set(serializedData.id, instance); + + return instance; + } + + function deserializeReference(serializedData) { + return { + _isDeserializationPlaceholder: true, + _deserializationId: serializedData.id + }; + } + + function deserializeInstanceOrReference(typeFactory, serializedData) { + if (isReference(serializedData)) { + return deserializeReference(serializedData); + } else { + return deserializeInstance(typeFactory, serializedData); + } + } + + function deserializeInstanceFuzzy(serializedData) { + if (isReference(serializedData)) { + return deserializeReference(serializedData); + } else if (typeMap.has(serializedData.type)) { + return deserializeInstance(typeMap.get(serializedData.type), serializedData); + } else { + throw new Error(`Encountered instance of an unknown type with hash ${serializedData.type}`); + } + } + + let topLevelInstance = deserializeInstance(topLevelType, topLevelData); + placeholders.fillInPlaceholders(seen); + return topLevelInstance; +}; diff --git a/src/serialization/placeholder-manager.js b/src/serialization/placeholder-manager.js new file mode 100644 index 0000000..d4ccd94 --- /dev/null +++ b/src/serialization/placeholder-manager.js @@ -0,0 +1,94 @@ +"use strict"; + +const isPlaceholder = require("../util/is-placeholder"); + +module.exports = function createPlaceholderManager() { + let placeholders = []; + + return { + markProperty: function (object, property) { + placeholders.push({ + type: "property", + object: object, + property: property + }); + }, + markSet: function (set) { + placeholders.push({ + type: "set", + set: set + }); + }, + markMap: function (map) { + placeholders.push({ + type: "map", + map: map + }); + }, + fillInPlaceholders: function (seen) { + function getSeenItem(placeholder) { + if (seen.has(placeholder._deserializationId)) { + return seen.get(placeholder._deserializationId); + } else { + throw new Error(`Did not encounter an object with the ID ${placeholder._deserializationId}`); + } + } + + function fillInSetPlaceholders(set) { + let originalItems = Array.from(set.values()); + set.clear(); + + for (let item of originalItems) { + if (isPlaceholder(item)) { + set.add(getSeenItem(item)); + } else { + set.add(item); + } + } + } + + function fillInMapPlaceholders(map) { + let originalItems = Array.from(map.entries()); + map.clear(); + + for (let [key, value] of originalItems) { + let newKey, newValue; + + if (isPlaceholder(key)) { + newKey = getSeenItem(key); + } else { + newKey = key; + } + + if (isPlaceholder(value)) { + newValue = getSeenItem(value); + } else { + newValue = value; + } + + map.set(newKey, newValue); + } + } + + function fillInPlaceholderProperty(object, property) { + object[property] = getSeenItem(object[property]); + } + + placeholders.forEach((marker) => { + /* NOTE: The Set/Map implementations below are probably slow. Maybe there's a faster implementation? */ + if (marker.type === "set") { + fillInSetPlaceholders(marker.set); + } else if (marker.type === "map") { + fillInMapPlaceholders(marker.map); + } else if (marker.type === "property") { + fillInPlaceholderProperty(marker.object, marker.property); + } else { + throw new Error(`Unrecognized placeholder marker type: ${marker.type}`); + } + }); + + /* To ensure that any accidental future invocations of this function will fail loudly. */ + placeholders = null; + } + }; +}; diff --git a/src/serialization/serialize.js b/src/serialization/serialize.js new file mode 100644 index 0000000..a5831e8 --- /dev/null +++ b/src/serialization/serialize.js @@ -0,0 +1,52 @@ +"use strict"; + +const defaultValue = require("default-value"); +const nanoid = require("nanoid"); +const jsonStringifyPreReplacer = require("../json-stringify-pre-replacer"); + +module.exports = function serialize(instance, options = {}) { + let customSerializer = defaultValue(options.serializer, (value) => value); + + /* FIXME: Remove `serializer` argument and let this be specified on fields / type aliases instead? */ + let seenItems = new Map(); + + let data = jsonStringifyPreReplacer(instance, (key, value) => { + if (value == null) { + return value; + } else if (value._guardedCollectionType === "set") { + return { + _d_sT: "gS", + entries: Array.from(value._source.values()) + }; + } else if (value._guardedCollectionType === "map") { + return { + _d_sT: "gM", + entries: Array.from(value._source.entries()) + }; + } else if (value._type != null && value._type._isCustomType === true) { + if (!seenItems.has(value)) { + let objectID = nanoid(); + + seenItems.set(value, objectID); + + value._type._ensureHash(); + + return { + _d_sT: "cT", + type: value._type._typeHash, + data: value._data, + id: objectID + }; + } else { + return { + _d_sT: "cTR", + id: seenItems.get(value) + }; + } + } else { + return customSerializer(value); + } + }); + + return data; +}; diff --git a/src/type-hash.js b/src/type-hash.js new file mode 100644 index 0000000..0aaf62c --- /dev/null +++ b/src/type-hash.js @@ -0,0 +1,166 @@ +"use strict"; + +// tag +/* FIXME: How are validator callbacks handled? */ + +const mapObj = require("map-obj"); +const objectHash = require("object-hash"); + +const filterTypeRules = require("./util/filter-type-rules"); + +function hashType(type, level = 0, seenTypes = new Map()) { + /* Type hashes should always be fully local; that is, they are calculated entirely from the point of view of the top-level type being hashed, with respect to how circular type references are handled. This means that we may effectively be calculating a hash for a type more than once, but it's necessary to ensure determinism; if we didn't do this and globally cached hashes for each type definition, then switching around the definition order of types could cause rippling hash changes throughout every referenced type, because circular type references are handled by recording the relative location of the first-seen instance of a type. */ + + function canonicalRule(rule, level) { + let props; + + if (rule == null) { + return rule; + } else if (rule._baseType != null) { + let options; + + if (rule._baseType === "guardedFunction") { + options = { + args: rule._options.args.map((arg) => { + return canonicalRule(arg, level + 1); + }), + returnType: canonicalRule(rule._options.returnType, level + 1) + }; + } else { + options = rule._options; + } + + props = { + ruleType: "baseType", + baseType: rule._baseType, + options: options + }; + } else if (rule._collectionType != null) { + props = { + ruleType: "collection", + keyType: canonicalRule(rule._keyType, level + 1), + itemType: canonicalRule(rule._itemType, level + 1), + options: rule._options + }; + } else if (rule._modifierType != null) { + if (rule._modifierType === "either") { + props = { + ruleType: "either", + types: rule._types.map((type) => { + return canonicalRule(type, level + 1); + }) + }; + } else { + throw new Error(`Encountered unexpected modifier: ${rule._modifierType}`); + } + } else if (rule._isSlotRule) { + props = { + ruleType: "slot" + }; + } else if (rule._isSelfRule) { + props = { + ruleType: "self" + }; + } else if (rule._constructorType != null) { + props = { + ruleType: "instance", + constructor: rule._constructorType + }; + } else if (rule._isCustomType || rule._isCustomRegistryType) { + let actualType; + + if (rule._isCustomRegistryType) { + actualType = rule._registry._getType(rule._name); + } else { + actualType = rule; + } + + let seenLevel = seenTypes.get(actualType); + + /* We ignore same-level seen types, because those are sibling properties on the same type. Duplication there is acceptable. */ + if (seenLevel != null && seenLevel < level) { + props = { + ruleType: "typeReference", + levels: level - seenTypes.get(actualType) + }; + } else { + seenTypes.set(actualType, level); + + props = { + ruleType: "type", + typeHash: hashType(actualType, level + 1, seenTypes) + }; + } + } else if (rule._isTrait || rule._isRegistryTrait) { + let actualTrait; + + if (rule._isRegistryTrait) { + actualTrait = rule._registry._getTrait(rule._name); + } else { + actualTrait = rule; + } + + let seenLevel = seenTypes.get(actualTrait); + + if (seenLevel != null && seenLevel < level) { + props = { + ruleType: "traitReference", + levels: level - seenTypes.get(actualTrait) + }; + } else { + seenTypes.set(actualTrait, level); + + props = { + ruleType: "trait", + typeHash: hashType(actualTrait, level + 1, seenTypes) + }; + } + } else { + throw new Error("Foo"); + } + + for (let [seenRule, seenLevel] of seenTypes) { + if (seenLevel > level) { + seenTypes.delete(seenRule); + } + } + + return Object.assign(props, { + /* FIXME: Do processing to allow instances of types as default value? */ + constraints: rule._constraints + }); + // return Object.assign(props, { + // constraints: rule._constraints.map((constraint) => { + // return canonicalRule(constraint, level + 1); + // }) + // }); + } + + seenTypes.set(type, level); + + /* FIXME: Constraints for custom types / traits? */ + if (type._isCustomType) { + return mapObj(filterTypeRules(type._cumulativeSchema), (key, value) => { + return [key, canonicalRule(value, level)]; + }); + } else if (type._isTrait) { + return mapObj(filterTypeRules(type._schema), (key, value) => { + return [key, canonicalRule(value, level)]; + }); + } else if (type._isTypeAlias) { + return { + _ruleType: "typeAlias", + type: canonicalRule(type._alias) + }; + } else { + throw new Error("Can only hash types and traits"); + } +} + +module.exports = function generateTypeHash(type) { + return objectHash(hashType(type), { + algorithm: "sha256", + respectFunctionNames: false, + respectFunctionProperties: false + }).slice(0, 6); +}; diff --git a/src/util/filter-type-rules.js b/src/util/filter-type-rules.js new file mode 100644 index 0000000..c393b79 --- /dev/null +++ b/src/util/filter-type-rules.js @@ -0,0 +1,9 @@ +"use strict"; + +const filterObj = require("filter-obj"); + +module.exports = function filterTypeRules(schema) { + return filterObj(schema, (key, value) => { + return (value != null && value._isTypeRule === true); + }); +}; diff --git a/src/util/is-placeholder.js b/src/util/is-placeholder.js new file mode 100644 index 0000000..fe0a234 --- /dev/null +++ b/src/util/is-placeholder.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = function isPlaceholder(value) { + return (value != null && value._isDeserializationPlaceholder); +}; diff --git a/test/registry.js b/test/registry.js index f65de26..3496c31 100644 --- a/test/registry.js +++ b/test/registry.js @@ -5,6 +5,7 @@ const expect = require("chai").expect; const dm = require("../src"); /* FIXME: setOf/mapOf? */ +/* FIXME: Type aliases? Both producing and consuming */ describe("registry", () => { let registry = dm.createRegistry(); diff --git a/test/serialization.js b/test/serialization.js new file mode 100644 index 0000000..62a87e4 --- /dev/null +++ b/test/serialization.js @@ -0,0 +1,94 @@ +"use strict"; + +const expect = require("chai").expect; + +const dm = require("../src"); +const generateTypeHash = require("../src/type-hash"); + +function customSerializer(value) { + if (value instanceof Date) { + return { + _d_sT: "date", + date: Math.round(value.getTime() / 1000) + }; + } else { + return value; + } +} + +function customDeserializer(value) { + if (value != null && value._d_sT === "date") { + let date = new Date(); + date.setTime(value.date); + return date; + } else { + return value; + } +} + +let registry = dm.createRegistry(); + +let User = registry.createType("User", { + name: dm.string(), + messages: dm.setOf(registry.trait("Addressable")) +}); + +let Addressable = registry.createTrait("Addressable", { + to: registry.type("User"), + from: registry.type("User"), +}); + +let Message = registry.createType("Message", { + body: dm.string(), + date: dm.instanceOf(Date) +}).implements(Addressable, { + to: dm.slot(), + from: dm.slot() +}); + +describe("serialization", () => { + it("should serialize correctly", () => { + let joe = User({ + name: "Joe", + messages: new Set() + }); + + let jane = User({ + name: "Jane", + messages: new Set() + }); + + let messageOne = Message({ + to: joe, + from: jane, + body: "Hello world!", + date: new Date() + }); + + joe.messages.add(messageOne); + + let messageTwo = Message({ + to: jane, + from: joe, + body: "Hello you!", + date: new Date() + }); + + jane.messages.add(messageTwo); + + let serializedJoe = joe.serialize(customSerializer); + let deserializedJoe = User.deserialize(serializedJoe, customDeserializer); + + expect(joe.name).to.equal(deserializedJoe.name); + expect(Array.from(joe.messages)[0].body).to.equal(Array.from(deserializedJoe.messages)[0].body); + expect(Array.from(joe.messages)[0].from.name).to.equal(Array.from(deserializedJoe.messages)[0].from.name); + + let serializedMessageTwo = messageTwo.serialize(customSerializer); + let deserializedMessageTwo = Message.deserialize(serializedMessageTwo, customDeserializer); + + expect(messageTwo.body).to.equal(deserializedMessageTwo.body); + expect(messageTwo.to.name).to.equal(deserializedMessageTwo.to.name); + expect(messageTwo.from.name).to.equal(deserializedMessageTwo.from.name); + expect(Array.from(messageTwo.from.messages)[0].body).to.equal(Array.from(deserializedMessageTwo.from.messages)[0].body); + }); +});