From 8ac9a02d2de018ef4941b2d81183dae36f3247e4 Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Sun, 19 Aug 2018 18:19:58 +0200 Subject: [PATCH] Add guarded array support --- junk-drawer/delete.js | 17 +++ src/generate-descriptor.js | 3 + src/guarded-collections/array.js | 136 +++++++++++++++++++++++ src/serialization/deserialize.js | 35 +++++- src/serialization/placeholder-manager.js | 16 +++ src/serialization/serialize.js | 6 + test/guarded-array.js | 46 ++++++++ test/models.js | 28 +++++ test/serialization.js | 18 ++- 9 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 junk-drawer/delete.js create mode 100644 src/guarded-collections/array.js create mode 100644 test/guarded-array.js diff --git a/junk-drawer/delete.js b/junk-drawer/delete.js new file mode 100644 index 0000000..3bdaaff --- /dev/null +++ b/junk-drawer/delete.js @@ -0,0 +1,17 @@ +"use strict"; + +const dm = require("../"); + +let Thing = dm.createType("Thing", { + value: dm.string() +}); + +let item = Thing({ + value: "foo" +}); + +console.log(item); + +delete item.value; + +console.log(item); diff --git a/src/generate-descriptor.js b/src/generate-descriptor.js index d70b908..70ebf91 100644 --- a/src/generate-descriptor.js +++ b/src/generate-descriptor.js @@ -3,6 +3,7 @@ const generateValidator = require("./generate-validator"); const guardedSet = require("./guarded-collections/set"); const guardedMap = require("./guarded-collections/map"); +const guardedArray = require("./guarded-collections/array"); module.exports = function generateDescriptor(rule, key, allowSlot = false) { if (rule._isTypeRule === true) { @@ -15,6 +16,8 @@ module.exports = function generateDescriptor(rule, key, allowSlot = false) { guardedCollectionFactory = guardedSet; } else if (rule._collectionType === "map") { guardedCollectionFactory = guardedMap; + } else if (rule._collectionType === "array") { + guardedCollectionFactory = guardedArray; } else { throw new Error(`Unknown collection type: ${rule._collectionType}`); } diff --git a/src/guarded-collections/array.js b/src/guarded-collections/array.js new file mode 100644 index 0000000..5291908 --- /dev/null +++ b/src/guarded-collections/array.js @@ -0,0 +1,136 @@ +"use strict"; + +const errors = require("../errors"); +const getValueType = require("../util/get-value-type"); + +let indexRegex = /^[0-9]+$/; + +module.exports = function createGuardedArray(array, guard, _, parent) { + function generateArraySignatureError() { + let wantedSignature = { + _guardedCollectionType: "array", + _itemType: guard._rule + }; + + return new errors.ValidationError(`Expected an array or ${getValueType(wantedSignature)}, got ${getValueType(array)} instead`); + } + + if (array._guardedCollectionType === "array") { + if (guard === array._itemType) { + return array; + } else { + throw generateArraySignatureError(); + } + } else if (Array.isArray(array)) { + for (let value of array) { + check(value); + } + + /* We shallow-clone the array here, to keep users from accidentally mutating the source array outside the purview of the guards specified here. */ + let targetArray = array.slice(); + + function check(value) { + let guardResult = guard.call(parent, value); + + if (guardResult === true) { + return true; + } else { + throw guardResult; + } + } + + let guardedForEach = guardedIterationMethod.bind(undefined, "forEach"); + let guardedFilter = guardedIterationMethod.bind(undefined, "filter"); + let guardedMap = guardedIterationMethod.bind(undefined, "map"); + let guardedSome = guardedIterationMethod.bind(undefined, "some"); + let guardedEvery = guardedIterationMethod.bind(undefined, "every"); + let guardedFind = guardedIterationMethod.bind(undefined, "find"); + + let proxyAPI = { + _guardedCollectionType: "array", + _itemType: guard, + push: guardedPush, + reduce: guardedReduce, + reduceRight: guardedReduceRight, + filter: guardedFilter, + map: guardedMap, + forEach: guardedForEach, + some: guardedSome, + every: guardedEvery, + find: guardedFind, + unshift: guardedUnshift, + fill: guardedFill, + splice: guardedSplice + }; + + let proxy = new Proxy(targetArray, { + get: function (target, property) { + if (proxyAPI[property] != null) { + return proxyAPI[property]; + } else { + return target[property]; + } + }, + set: function (target, property, value) { + if (indexRegex.test(property)) { + check(value); + } + + target[property] = value; + return true; + } + }); + + function guardedUnshift(...items) { + for (let item of items) { + check(item); + } + + return targetArray.unshift(...items); + } + + function guardedPush(value) { + if (check(value) === true) { + return targetArray.push(value); + } + } + + function guardedFill(value) { + if (check(value) === true) { + return targetArray.fill(value); + } + } + + function guardedSplice(start, deleteCount, ...items) { + for (let item of items) { + check(item); + } + + return targetArray.splice(start, deleteCount, ...items); + } + + /* We provide the proxy instead of the original array as the 'array' argument for iteration functions, to prevent direct access to the underlying array; otherwise the user could accidentally mutate the underlying array from within a forEach callback, bypassing the validation requirements. */ + + function guardedIterationMethod(method, callback, thisArg) { + return targetArray[method]((value, index, _array) => { + return callback(value, index, proxy); + }, thisArg); + } + + function guardedReduce(callback, initialValue) { + return targetArray.reduce((accumulator, value, index, _array) => { + return callback(accumulator, value, index, proxy); + }, initialValue); + } + + function guardedReduceRight(callback, initialValue) { + return targetArray.reduceRight((accumulator, value, index, _array) => { + return callback(accumulator, value, index, proxy); + }, initialValue); + } + + return proxy; + } else { + throw generateArraySignatureError(); + } +}; diff --git a/src/serialization/deserialize.js b/src/serialization/deserialize.js index ff72ab0..bffd2c1 100644 --- a/src/serialization/deserialize.js +++ b/src/serialization/deserialize.js @@ -18,18 +18,28 @@ module.exports = function createDeserializer(topLevelType, topLevelData, typeMap let customDeserializer = defaultValue(options.deserializer, (value) => value); - function deserializeSet(rule, entries) { + function deserializeSequenceItems(itemRule, entries) { let seenPlaceholder = false; - let set = new Set(entries.map((item) => { - let deserializedValue = deserializeEntry(rule._itemType, item); + let items = entries.map((item) => { + let deserializedValue = deserializeEntry(itemRule, item); if (isPlaceholder(deserializedValue)) { seenPlaceholder = true; } return deserializedValue; - })); + }); + + return { + items: items, + seenPlaceholder: seenPlaceholder + }; + } + + function deserializeSet(rule, entries) { + let {items, seenPlaceholder} = deserializeSequenceItems(rule._itemType, entries); + let set = new Set(items); if (seenPlaceholder) { placeholders.markSet(set); @@ -38,6 +48,16 @@ module.exports = function createDeserializer(topLevelType, topLevelData, typeMap return set; } + function deserializeArray(rule, entries) { + let {items, seenPlaceholder} = deserializeSequenceItems(rule._itemType, entries); + + if (seenPlaceholder) { + placeholders.markArray(items); + } + + return items; + } + function deserializeMap(rule, entries) { let seenPlaceholder = false; @@ -85,6 +105,13 @@ module.exports = function createDeserializer(topLevelType, topLevelData, typeMap /* FIXME: Clearer error message */ throw new Error("Expected guarded map, but got something else instead"); } + } else if (rule._collectionType === "array") { + if (serializedData._d_sT === "gA") { + return deserializeArray(rule, serializedData.entries); + } else { + /* FIXME: Clearer error message */ + throw new Error("Expected guarded array, but got something else instead"); + } } else { /* This also covers `either` rules where the serializedData is a type instance or reference. */ return deserializeEntryFuzzy(serializedData); diff --git a/src/serialization/placeholder-manager.js b/src/serialization/placeholder-manager.js index d4ccd94..c4ab8d9 100644 --- a/src/serialization/placeholder-manager.js +++ b/src/serialization/placeholder-manager.js @@ -19,6 +19,12 @@ module.exports = function createPlaceholderManager() { set: set }); }, + markArray: function (array) { + placeholders.push({ + type: "array", + array: array + }); + }, markMap: function (map) { placeholders.push({ type: "map", @@ -47,6 +53,14 @@ module.exports = function createPlaceholderManager() { } } + function fillInArrayPlaceholders(array) { + array.forEach((item, i) => { + if (isPlaceholder(item)) { + array[i] = getSeenItem(item); + } + }); + } + function fillInMapPlaceholders(map) { let originalItems = Array.from(map.entries()); map.clear(); @@ -78,6 +92,8 @@ module.exports = function createPlaceholderManager() { /* 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 === "array") { + fillInArrayPlaceholders(marker.array); } else if (marker.type === "map") { fillInMapPlaceholders(marker.map); } else if (marker.type === "property") { diff --git a/src/serialization/serialize.js b/src/serialization/serialize.js index a5831e8..4e219c3 100644 --- a/src/serialization/serialize.js +++ b/src/serialization/serialize.js @@ -23,6 +23,12 @@ module.exports = function serialize(instance, options = {}) { _d_sT: "gM", entries: Array.from(value._source.entries()) }; + } else if (value._guardedCollectionType === "array") { + return { + _d_sT: "gA", + /* We shallow-clone the array here for now, because otherwise the stringifier will infinitely recurse into `entries` properties; shallow-cloning the array results in a *real* array, that doesn't have the `_guardedCollectionType` property. */ + entries: value.slice() + }; } else if (value._type != null && value._type._isCustomType === true) { if (!seenItems.has(value)) { let objectID = nanoid(); diff --git a/test/guarded-array.js b/test/guarded-array.js new file mode 100644 index 0000000..e26ab00 --- /dev/null +++ b/test/guarded-array.js @@ -0,0 +1,46 @@ +"use strict"; + +const expect = require("chai").expect; + +const dm = require("../src"); + +let User = dm.createType("User", { + messages: dm.arrayOf(dm.string()) +}); + +describe("guarded arrays", () => { + let joe = User({ + messages: [] + }); + + it("should accept valid values", () => { + joe.messages.push("Hello world!"); + expect(joe.messages[0]).to.equal("Hello world!"); + + joe.messages.splice(0, 0, "Hello earth!") + expect(joe.messages[0]).to.equal("Hello earth!"); + expect(joe.messages[1]).to.equal("Hello world!"); + + joe.messages[0] = "Hello moon!"; + console.log(joe.messages); + expect(joe.messages[0]).to.equal("Hello moon!"); + expect(joe.messages[1]).to.equal("Hello world!"); + }); + + it("should reject invalid values", () => { + expect(() => { + joe.messages.push(42); + }).to.throw("Expected a string, got a number instead"); + + expect(() => { + joe.messages.splice(0, 0, 43); + }).to.throw("Expected a string, got a number instead"); + + expect(() => { + joe.messages[0] = 44; + }).to.throw("Expected a string, got a number instead"); + + expect(joe.messages[0]).to.equal("Hello moon!"); + expect(joe.messages[1]).to.equal("Hello world!"); + }); +}); diff --git a/test/models.js b/test/models.js index 80b1f97..486a081 100644 --- a/test/models.js +++ b/test/models.js @@ -6,6 +6,11 @@ const dm = require("../src"); /* FIXME: Disallow null/nothing/undefined in model definitions, as they make no semantic sense? But allow them for eg. function guards. */ /* FIXME: Test passing an already-guarded collection into a guarded collection factory - either stand-alone or in the context of a model? */ + +let Event = dm.createType("Event", { + description: dm.string() +}); + let Identity = dm.createType("Identity", { label: dm.string(), nickname: dm.string({ @@ -38,6 +43,7 @@ let User = dm.createType("User", { minimum: 1, maximum: 42 })), + events: dm.arrayOf(Event), identities: dm.setOf(Identity), mainIdentity: Identity, alternateUser: dm.self().optional(), @@ -55,6 +61,11 @@ function generateUserData(props = {}) { // nullValue: null, // undefinedValue: undefined, // nothingValue: undefined, + events: [ + Event({ + description: "The user was created" + }) + ], identities: new Set([ Identity({ label: "primary", @@ -97,6 +108,10 @@ function compareObject(reference, obj) { for (let [mapKey, item] of value.entries()) { compareObject(item, obj[key].get(mapKey)); } + } else if (Array.isArray(value)) { + value.forEach((item, i) => { + compareObject(item, obj[key][i]); + }); } else if (typeof reference === "object") { compareObject(value, obj[key]); } else { @@ -127,6 +142,9 @@ describe("models", () => { age: 26, luckyNumbers: new Set([13, 42]), misc: 42, + events: [{ + description: "The user was created" + }], identities: new Set([{ label: "primary", nickname: "joepie91", @@ -270,6 +288,16 @@ describe("models", () => { describe("collections and nested instances", () => { /* TODO: Do we need separate tests for guarded Maps/Sets? */ + it("should require Event-typed values for its events array", () => { + expect(() => { + User(generateUserData({ + events: [ + 42, 41, 40 + ] + })); + }).to.throw("Expected an instance of Event, got a number instead"); + }); + it("should require Identity-typed values for its identities Set", () => { expect(() => { User(generateUserData({ diff --git a/test/serialization.js b/test/serialization.js index 62a87e4..0f1fec2 100644 --- a/test/serialization.js +++ b/test/serialization.js @@ -30,7 +30,8 @@ let registry = dm.createRegistry(); let User = registry.createType("User", { name: dm.string(), - messages: dm.setOf(registry.trait("Addressable")) + messages: dm.setOf(registry.trait("Addressable")), + events: dm.arrayOf(registry.type("Event")) }); let Addressable = registry.createTrait("Addressable", { @@ -46,16 +47,26 @@ let Message = registry.createType("Message", { from: dm.slot() }); +let UserEvent = registry.createType("Event", { + description: dm.string() +}); + describe("serialization", () => { it("should serialize correctly", () => { let joe = User({ name: "Joe", - messages: new Set() + messages: new Set(), + events: [ + UserEvent({ + description: "User created" + }) + ] }); let jane = User({ name: "Jane", - messages: new Set() + messages: new Set(), + events: [] }); let messageOne = Message({ @@ -82,6 +93,7 @@ describe("serialization", () => { 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); + expect(joe.events[0].description).to.equal(deserializedJoe.events[0].description); let serializedMessageTwo = messageTwo.serialize(customSerializer); let deserializedMessageTwo = Message.deserialize(serializedMessageTwo, customDeserializer);