Add guarded array support

master
Sven Slootweg 6 years ago
parent d77b042299
commit 8ac9a02d2d

@ -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);

@ -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}`);
}

@ -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();
}
};

@ -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);

@ -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") {

@ -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();

@ -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!");
});
});

@ -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({

@ -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);

Loading…
Cancel
Save