From 98fbf1d556710a5031eef55f93de98263b1898fe Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Mon, 6 Aug 2018 11:56:37 +0200 Subject: [PATCH] Initial commit --- .eslintignore | 1 + .eslintrc.js | 69 +++++ .gitignore | 1 + index.js | 3 + junk-drawer/test-benchmark.js | 39 +++ junk-drawer/test-benchmark2.js | 118 ++++++++ junk-drawer/test-profiling.js | 26 ++ junk-drawer/test.js | 147 +++++++++ package-lock.json | 296 ++++++++++++++++++ package.json | 35 +++ src/create-trait.js | 61 ++++ src/create-type.js | 95 ++++++ src/errors.js | 7 + src/generate-descriptor.js | 79 +++++ src/generate-validator.js | 170 +++++++++++ src/guard-function.js | 45 +++ src/guarded-collections/map.js | 92 ++++++ src/guarded-collections/set.js | 87 ++++++ src/index.js | 31 ++ src/type-rules.js | 115 +++++++ src/util/function-wrapper.js | 7 + src/util/get-schema-keys.js | 5 + src/util/get-value-type.js | 183 +++++++++++ src/util/is-constructor.js | 6 + src/util/is-named-function.js | 5 + src/util/null-missing-fields.js | 13 + src/validator-functions/boolean.js | 14 + src/validator-functions/function.js | 24 ++ src/validator-functions/nothing.js | 14 + src/validator-functions/null.js | 14 + src/validator-functions/number.js | 48 +++ src/validator-functions/string.js | 28 ++ src/validator-functions/undefined.js | 14 + test/function-guards.js | 67 ++++ test/index.js | 16 + test/models.js | 437 +++++++++++++++++++++++++++ test/traits.js | 143 +++++++++ test/value-descriptions.js | 275 +++++++++++++++++ 38 files changed, 2830 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 index.js create mode 100644 junk-drawer/test-benchmark.js create mode 100644 junk-drawer/test-benchmark2.js create mode 100644 junk-drawer/test-profiling.js create mode 100644 junk-drawer/test.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/create-trait.js create mode 100644 src/create-type.js create mode 100644 src/errors.js create mode 100644 src/generate-descriptor.js create mode 100644 src/generate-validator.js create mode 100644 src/guard-function.js create mode 100644 src/guarded-collections/map.js create mode 100644 src/guarded-collections/set.js create mode 100644 src/index.js create mode 100644 src/type-rules.js create mode 100644 src/util/function-wrapper.js create mode 100644 src/util/get-schema-keys.js create mode 100644 src/util/get-value-type.js create mode 100644 src/util/is-constructor.js create mode 100644 src/util/is-named-function.js create mode 100644 src/util/null-missing-fields.js create mode 100644 src/validator-functions/boolean.js create mode 100644 src/validator-functions/function.js create mode 100644 src/validator-functions/nothing.js create mode 100644 src/validator-functions/null.js create mode 100644 src/validator-functions/number.js create mode 100644 src/validator-functions/string.js create mode 100644 src/validator-functions/undefined.js create mode 100644 test/function-guards.js create mode 100644 test/index.js create mode 100644 test/models.js create mode 100644 test/traits.js create mode 100644 test/value-descriptions.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +test diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..12f1c20 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,69 @@ +module.exports = { + "env": { + "browser": true, + "commonjs": true, + "es6": true, + "node": true + }, + "parserOptions": { + "ecmaVersion": 2018 + }, + "rules": { + /* Things that should effectively be syntax errors. */ + "indent": [ "error", "tab", { + SwitchCase: 1 + }], + "linebreak-style": [ "error", "unix" ], + "semi": [ "error", "always" ], + /* Things that are always mistakes. */ + "getter-return": [ "error" ], + "no-compare-neg-zero": [ "error" ], + "no-dupe-args": [ "error" ], + "no-dupe-keys": [ "error" ], + "no-duplicate-case": [ "error" ], + "no-empty": [ "error" ], + "no-empty-character-class": [ "error" ], + "no-ex-assign": [ "error" ], + "no-extra-semi": [ "error" ], + "no-func-assign": [ "error" ], + "no-invalid-regexp": [ "error" ], + "no-irregular-whitespace": [ "error" ], + "no-obj-calls": [ "error" ], + "no-sparse-arrays": [ "error" ], + "no-undef": [ "error" ], + "no-unreachable": [ "error" ], + "no-unsafe-finally": [ "error" ], + "use-isnan": [ "error" ], + "valid-typeof": [ "error" ], + "curly": [ "error" ], + "no-caller": [ "error" ], + "no-fallthrough": [ "error" ], + "no-extra-bind": [ "error" ], + "no-extra-label": [ "error" ], + "array-callback-return": [ "error" ], + "prefer-promise-reject-errors": [ "error" ], + "no-with": [ "error" ], + "no-useless-concat": [ "error" ], + "no-unused-labels": [ "error" ], + "no-unused-expressions": [ "error" ], + "no-unused-vars": [ "error", { argsIgnorePattern: "^_" } ], + "no-return-assign": [ "error" ], + "no-self-assign": [ "error" ], + "no-new-wrappers": [ "error" ], + "no-redeclare": [ "error" ], + "no-loop-func": [ "error" ], + "no-implicit-globals": [ "error" ], + "strict": [ "error", "global" ], + /* Development code that should be removed before deployment. */ + "no-console": [ "warn" ], + "no-constant-condition": [ "warn" ], + "no-debugger": [ "warn" ], + "no-alert": [ "warn" ], + "no-warning-comments": ["warn", { + terms: ["fixme"] + }], + /* Common mistakes that can *occasionally* be intentional. */ + "no-template-curly-in-string": ["warn"], + "no-unsafe-negation": [ "warn" ], + } +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/index.js b/index.js new file mode 100644 index 0000000..92d3855 --- /dev/null +++ b/index.js @@ -0,0 +1,3 @@ +"use strict"; + +module.exports = require("./src"); diff --git a/junk-drawer/test-benchmark.js b/junk-drawer/test-benchmark.js new file mode 100644 index 0000000..1852a54 --- /dev/null +++ b/junk-drawer/test-benchmark.js @@ -0,0 +1,39 @@ +"use strict"; + +const benchmark = require("benchmark"); + +const s = require("../src"); + +let PersonName = s.createType("PersonName", s.string({ + matches: /^[a-z0-9 '-]+$/ +})); + +let Person = s.createType("Person", { + name: PersonName, + age: s.number(), + isAlive: s.boolean() +}); + +new benchmark.Suite() + .add("normal", () => { + let me = { + name: "somebody", + age: 42, + isAlive: true + }; + + let a = (me.name, me.age, me.isAlive); + }) + .add("strict", () => { + let me = Person({ + name: "somebody", + age: 42, + isAlive: true + }); + + let a = (me.name, me.age, me.isAlive); + }) + .on("cycle", function (event) { + console.log(String(event.target)); + }) + .run(); diff --git a/junk-drawer/test-benchmark2.js b/junk-drawer/test-benchmark2.js new file mode 100644 index 0000000..947a15b --- /dev/null +++ b/junk-drawer/test-benchmark2.js @@ -0,0 +1,118 @@ +"use strict"; + +const benchmark = require("benchmark"); + +const dm = require("../src"); + +let Identity = dm.createType("Identity", { + label: dm.string(), + nickname: dm.string({ + matches: /^[a-z0-9_-]+$/ + }), + emailAddress: dm.string({ + matches: /@/ + }), + xmppAddress: dm.string({ + matches: /@/ + }).optional(), + notes: dm.string().optional() +}); + +let User = dm.createType("User", { + name: dm.string(), + isActive: dm.boolean(), + registrationDate: dm.instanceOf(Date), + misc: dm.either( + dm.string(), + dm.number() + ), + // undefinedValue: dm.undefined(), + // nullValue: dm.null(), + // nothingValue: dm.nothing(), + age: dm.number({ + minimum: 0 + }), + luckyNumbers: dm.setOf(dm.number({ + minimum: 1, + maximum: 42 + })), + identities: dm.setOf(Identity), + mainIdentity: Identity, + codeWords: dm.mapOf(dm.string(), dm.string()) +}); + +new benchmark.Suite() + .add("normal", () => { + let me = { + name: "Sven Slootweg", + isActive: true, + registrationDate: new Date(), + age: 26, + luckyNumbers: new Set([13, 42]), + misc: 42, + identities: new Set([ + { + label: "primary", + nickname: "joepie91", + emailAddress: "admin@cryto.net", + xmppAddress: "joepie91@neko.im", + notes: "Main nickname" + }, + { + label: "bot", + nickname: "botpie91", + emailAddress: "botpie91@cryto.net" + } + ]), + mainIdentity: { + label: "primary", + nickname: "joepie91", + emailAddress: "admin@cryto.net", + xmppAddress: "joepie91@neko.im", + notes: "Main nickname" + }, + codeWords: new Map([ + ["foo", "Foo"], + ["bar", "Bar"] + ]) + }; + }) + .add("strict", () => { + let me = User({ + name: "Sven Slootweg", + isActive: true, + registrationDate: new Date(), + age: 26, + luckyNumbers: new Set([13, 42]), + misc: 42, + identities: new Set([ + Identity({ + label: "primary", + nickname: "joepie91", + emailAddress: "admin@cryto.net", + xmppAddress: "joepie91@neko.im", + notes: "Main nickname" + }), + Identity({ + label: "bot", + nickname: "botpie91", + emailAddress: "botpie91@cryto.net" + }) + ]), + mainIdentity: Identity({ + label: "primary", + nickname: "joepie91", + emailAddress: "admin@cryto.net", + xmppAddress: "joepie91@neko.im", + notes: "Main nickname" + }), + codeWords: new Map([ + ["foo", "Foo"], + ["bar", "Bar"] + ]) + }); + }) + .on("cycle", function (event) { + console.log(String(event.target)); + }) + .run(); diff --git a/junk-drawer/test-profiling.js b/junk-drawer/test-profiling.js new file mode 100644 index 0000000..a70eb10 --- /dev/null +++ b/junk-drawer/test-profiling.js @@ -0,0 +1,26 @@ +"use strict"; + +const s = require("../"); + +let PersonName = s.createType(s.string({ + matches: /^[a-z0-9 '-]+$/ +})); + +let Person = s.createType({ + name: PersonName, + age: s.number(), + isAlive: s.boolean() +}); + + +for (let i = 0; i < 500000; i++) { + let me = Person({ + name: "somebody", + age: 42, + isAlive: true + }); + + let a = (me.name, me.age, me.isAlive); +} + +console.log("Done"); diff --git a/junk-drawer/test.js b/junk-drawer/test.js new file mode 100644 index 0000000..571c525 --- /dev/null +++ b/junk-drawer/test.js @@ -0,0 +1,147 @@ +"use strict"; + +const s = require("../"); + +let PersonName = s.createType("PersonName", s.string({ + matches: /^[a-z0-9 '-]+$/ +})); + +let Nickname = s.createType("Nickname", s.string({ + matches: /^[a-z0-9_]+$/ +})); + +let EmailAddress = s.createType("EmailAddress", s.string({ + matches: /@/ +})); + +// let OnlineIdentity = s.createType("OnlineIdentity", { +// nickname: Nickname, +// active: s.boolean() +// }); + +let OnlineIdentity = s.createTrait("OnlineIdentity", { + encode: s.function([], s.string()) +}); + +let NicknameIdentity = s.createType("NicknameIdentity", { + nickname: Nickname +}).implements(OnlineIdentity, { + encode: s.guard([], s.string(), function () { + return `nickname:${this.nickname}`; + }) +}); + +let EmailIdentity = s.createType("EmailIdentity", { + email: EmailAddress +}).implements(OnlineIdentity, { + encode: s.guard([], s.string(), function () { + return `email:${this.email}`; + }) +}); + +let CustomIdentity = s.createType("CustomIdentity", { + +}).implements(OnlineIdentity, { + encode: s.slot() +}); + +let BullshitIdentity = s.createType("BullshitIdentity", { + nickname: Nickname +}); + +let Person = s.createType("Person", { + name: s.string(), + age: s.number(), + isAlive: s.boolean(), + // identities: s.setOf(OnlineIdentity) + identities: s.mapOf(s.string(), OnlineIdentity), + // friend: s.self().optional(), + friends: s.setOf(s.self()), + getName: function () { + return this.name; + }, + compareName: s.guard([s.self()], s.boolean(), function (otherPerson) { + return (otherPerson.name === this.name); + }) +}); + +// MARKER: implement unique paths + +let me = Person({ + name: "somebody", + age: 42, + isAlive: true, + // identities: new Set([ + // OnlineIdentity({ + // nickname: "foo", + // active: true + // }), + // OnlineIdentity({ + // nickname: "joepie", + // active: false + // }) + // ]) + friends: new Set(), + identities: new Map([ + ["foo", EmailIdentity({ + email: "foo@bar.baz" + })], + ["joepie91", NicknameIdentity({ + nickname: "joepie91" + })], + ["custom_man", CustomIdentity({ + encode: s.guard([], s.string(), function () { + return "custom:foo"; + }) + })], + ]) +}); + +let other = Person({ + name: "foo", + age: 21, + isAlive: false, + friends: new Set(), + identities: new Map(), + // friend: me +}); + +console.log(me.getName(), me.age, me.isAlive, me.identities); +console.log("====="); +for (let [name, identity] of me.identities.entries()) { + console.log(`${name} => ${identity.encode()}`); +} +console.log("====="); +console.log(me.compareName(other)); + +// let C = s.createType("C", {}); +// +// let B = s.createType("B", { +// c: s.setOf(C) +// }); +// +// let A = s.createType("A", { +// b: s.mapOf(s.string(), B) +// }); +// +// let a = A({ +// b: new Map([ +// ["1", B({ +// c: new Set([ +// C({}), +// C({}) +// ]) +// })], +// ["2", B({ +// c: new Set() +// })] +// ]) +// }); +// +// let a2 = A({ +// b: a.b.get("1").c +// // b: "foo" +// }); +// +// console.log(a); +// console.log(a2); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b22fa31 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,296 @@ +{ + "name": "node-data-modeling", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "benchmark": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/benchmark/-/benchmark-2.1.4.tgz", + "integrity": "sha1-CfPeMckWQl1JjMLuVloOvzwqVik=", + "dev": true, + "requires": { + "lodash": "^4.17.4", + "platform": "^1.3.3" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "capitalize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/capitalize/-/capitalize-1.0.0.tgz", + "integrity": "sha1-3IAsWAruEBkpAg0soUtMqKCuRL4=" + }, + "chai": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", + "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" + } + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "create-error": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/create-error/-/create-error-0.3.1.tgz", + "integrity": "sha1-aYECRaYp5lRDK/BDdzYAA6U1GiM=" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "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" + } + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "lodash": { + "version": "4.17.10", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", + "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", + "dev": true + }, + "map-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", + "integrity": "sha1-plzSkIepJZi4eRJXpSPgISIqwfk=" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", + "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", + "dev": true, + "requires": { + "browser-stdout": "1.3.1", + "commander": "2.15.1", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.5", + "he": "1.1.1", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "supports-color": "5.4.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "object-hash": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.0.tgz", + "integrity": "sha512-05KzQ70lSeGSrZJQXE5wNDiTkBJDlUT/myi6RX9dVIvz7a7Qh4oH93BQdiPMn27nldYvVQCKMUaM83AfizZlsQ==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "platform": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.5.tgz", + "integrity": "sha512-TuvHS8AOIZNAlE77WUDiR4rySV/VMptyMfcfeoMgs4P8apaZM3JrnbzBiixKUv+XR6i+BXrQh8WAnjaSPFO65Q==", + "dev": true + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..df2facd --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "node-data-modeling", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "mocha" + }, + "repository": { + "type": "git", + "url": "git@git.cryto.net:joepie91/node-data-modeling.git" + }, + "keywords": [ + "data", + "modeling", + "schema", + "ddl", + "typing", + "validation" + ], + "author": "Sven Slootweg ", + "license": "WTFPL", + "devDependencies": { + "benchmark": "^2.1.4", + "chai": "^4.1.2", + "mocha": "^5.2.0" + }, + "dependencies": { + "capitalize": "^1.0.0", + "create-error": "^0.3.1", + "filter-obj": "^1.1.0", + "map-obj": "^2.0.0", + "object-hash": "^1.3.0" + } +} diff --git a/src/create-trait.js b/src/create-trait.js new file mode 100644 index 0000000..a9327db --- /dev/null +++ b/src/create-trait.js @@ -0,0 +1,61 @@ +"use strict"; + +const mapObj = require("map-obj"); +const filterObj = require("filter-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"); + +/* TODO: Traits implementing traits */ + +module.exports = function createTrait(name, schema) { + let schemaDescriptors = mapObj(schema, (key, rule) => { + return generateDescriptor(rule, key, true); + }); + + let schemaKeys = getSchemaKeys(schema); + + return typeRules._createTypeRule({ + _isTrait: true, + _name: name, + applyImplementation: function (implementation) { + /* FIXME: Verify that there are no extraneous keys or unspecified values */ + let validatedImplementation = Object.create({ + _data: {} + }, schemaDescriptors); + + let implementationWithoutSlots = filterObj(implementation, (key, value) => { + return (value == null || value._isSlotRule !== true); + }); + + 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) => { + if (value != null && value._isSlotRule === true) { + return generateDescriptor(schema[key], key, true); + } else { + return generateDescriptor(value, key); + } + }); + + /* FIXME: Get rid of duplication here somehow? */ + let slotSchemaKeys = Object.keys(implementation).filter((key) => { + let value = implementation[key]; + return (value != null && value._isSlotRule != null); + }); + + return {implementationDescriptors, slotSchemaKeys}; + } + }); +}; diff --git a/src/create-type.js b/src/create-type.js new file mode 100644 index 0000000..e7a88e0 --- /dev/null +++ b/src/create-type.js @@ -0,0 +1,95 @@ +"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"); + +module.exports = function createType(name, schema) { + if (schema._isTypeRule === true) { + return typeRules._createTypeRule({ + _typeName: name, + _isTypeAlias: true, + _alias: schema + }); + } else { + let propertyDescriptors = mapObj(schema, (key, rule) => { + return generateDescriptor(rule, key); + }); + + let protoProperties = { + _typeName: name, + /* 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]; + }) + }; + + let proto = Object.create(protoProperties, propertyDescriptors); + let schemaKeys = getSchemaKeys(schema); + + let factory = function createInstance(instanceProps) { + factory._isUsed = true; + + let instance = Object.create(proto); + instance._data = {}; + + // for (let key of factory._schemaKeys) { + // if (instanceProps[key] != null) { + // instance[key] = instanceProps[key]; + // } else { + // instance[key] = null; + // } + // } + + /* FIXME: Investigate ways to optimize this */ + let nullFields = nullMissingFields(instanceProps, factory._schemaKeys); + Object.assign(instance, instanceProps, nullFields); + + return instance; + }; + + proto._type = factory; + + factory._schemaKeys = schemaKeys; + factory._isCustomType = true; + factory._name = name; + factory.prototype = proto; + factory.description = name; + factory._createValidator = function () { + return function (value) { + return (value instanceof factory); + }; + }; + + proto._selfValidator = factory._createValidator(); + + factory._isUsed = false; + factory._implementedTraits = new Set(); + factory.implements = function (trait, implementation) { + if (factory._isUsed !== false) { + throw new Error("Cannot modify a custom type that has already been instantiated"); + } else { + let {implementationDescriptors, slotSchemaKeys} = trait.applyImplementation(implementation); + + // FIXME: Test this + Object.keys(implementationDescriptors).forEach((key) => { + if (proto.key != null) { + throw new Error(`Trait implementation attempted to define a property '${key}' on the '${name}' type, but that property already exists`); + } + }); + + Object.defineProperties(proto, implementationDescriptors); + factory._implementedTraits.add(trait); + factory._schemaKeys = factory._schemaKeys.concat(slotSchemaKeys); + + return this; + } + }; + + return typeRules._createTypeRule(factory); + } +}; diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..3aee80f --- /dev/null +++ b/src/errors.js @@ -0,0 +1,7 @@ +"use strict"; + +const createError = require("create-error"); + +module.exports = { + ValidationError: createError("ValidationError") +}; diff --git a/src/generate-descriptor.js b/src/generate-descriptor.js new file mode 100644 index 0000000..d70b908 --- /dev/null +++ b/src/generate-descriptor.js @@ -0,0 +1,79 @@ +"use strict"; + +const generateValidator = require("./generate-validator"); +const guardedSet = require("./guarded-collections/set"); +const guardedMap = require("./guarded-collections/map"); + +module.exports = function generateDescriptor(rule, key, allowSlot = false) { + if (rule._isTypeRule === true) { + let validator = generateValidator(rule, key); + + if (rule._collectionType != null) { + let guardedCollectionFactory; + + if (rule._collectionType === "set") { + guardedCollectionFactory = guardedSet; + } else if (rule._collectionType === "map") { + guardedCollectionFactory = guardedMap; + } else { + throw new Error(`Unknown collection type: ${rule._collectionType}`); + } + + let valueGuard = generateValidator(rule._itemType); + let keyGuard = (rule._keyType != null) ? generateValidator(rule._keyType) : null; + + return [key, { + enumerable: true, + get: function () { + return this._data[key]; + }, + set: function (value) { + if (allowSlot && value != null && value._isSlotRule === true) { + this._data[key] = value; + } else { + let validationResult = validator.call(this, value); + + if (validationResult === true) { + if (value == null) { + this._data[key] = value; + } else { + this._data[key] = guardedCollectionFactory(value, valueGuard, keyGuard, this); + } + } else if (validationResult._default !== undefined) { + this._data[key] = guardedCollectionFactory(validationResult._default, valueGuard, keyGuard, this); + } else { + throw validationResult; + } + } + } + }]; + } else { + return [key, { + enumerable: true, + get: function () { + return this._data[key]; + }, + set: function (value) { + if (allowSlot && value != null && value._isSlotRule === true) { + this._data[key] = value; + } else { + let validationResult = validator.call(this, value); + + if (validationResult === true) { + this._data[key] = value; + } else if (validationResult._default !== undefined) { + this._data[key] = validationResult._default; + } else { + throw validationResult; + } + } + } + }]; + } + } else { + /* TODO: Does this cause a slowdown compared to merging in these props with Object.assign later? */ + return [key, { + value: rule + }]; + } +}; diff --git a/src/generate-validator.js b/src/generate-validator.js new file mode 100644 index 0000000..05d1e48 --- /dev/null +++ b/src/generate-validator.js @@ -0,0 +1,170 @@ +"use strict"; + +const util = require("util"); + +const createStringValidator = require("./validator-functions/string"); +const createNumberValidator = require("./validator-functions/number"); +const createBooleanValidator = require("./validator-functions/boolean"); +const createFunctionValidator = require("./validator-functions/function"); +const createNothingValidator = require("./validator-functions/nothing"); +const createNullValidator = require("./validator-functions/null"); +const createUndefinedValidator = require("./validator-functions/undefined"); +const getValueType = require("./util/get-value-type"); +const errors = require("./errors"); + +module.exports = function generateValidator(rule, name = "") { + let baseRule; + + if (rule._baseType != null) { + let validatorFactories = { + string: createStringValidator, + boolean: createBooleanValidator, + number: createNumberValidator, + guardedFunction: createFunctionValidator, + nothing: createNothingValidator, + null: createNullValidator, + undefined: createUndefinedValidator + }; + + let factory = validatorFactories[rule._baseType]; + + if (factory != null) { + baseRule = factory(rule._options, name); + } else { + throw new Error(`Unrecognized base type: ${rule._baseType}`); + } + } else if (rule._collectionType != null) { + baseRule = () => true; /* FIXME */ + } else if (rule._modifierType != null) { + if (rule._modifierType === "either") { + let validators = rule._types.map((type) => generateValidator(type)); + + baseRule = function (value) { + let matched = false; + + for (let validator of validators) { + if (validator.call(this, value) === true) { + matched = true; + break; + } + } + + if (matched === true) { + return true; + } else { + let acceptableTypes = rule._types.map((type) => getValueType(type, false)).join(", "); + return new errors.ValidationError(`Expected one of (${acceptableTypes}), got ${getValueType(value)} instead`); + } + }; + } else { + throw new Error(`Unrecognized modifier: ${rule._modifierType}`); + } + } else if (rule._isTypeAlias === true) { + baseRule = generateValidator(rule._alias, name); + } else if (rule._isCustomType === true) { + + let validator = rule._createValidator(); + baseRule = function (value) { + if (validator.call(this, value) === true) { + return true; + } else { + return new errors.ValidationError(`Expected ${getValueType(rule)}, got ${getValueType(value)} instead`); + } + }; + } else if (rule._isTrait === true) { + baseRule = function (value) { + 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`); + } + }; + } else if (rule._isSelfRule === true) { + baseRule = function (value) { + if (value instanceof this._type) { + return true; + } else { + return new errors.ValidationError(`Expected ${getValueType(this._type)}, got ${getValueType(value)} instead`); + } + }; + } else if (rule._constructorType != null) { + /* This is for handling third-party constructors and their instances. */ + baseRule = function (value) { + if (value instanceof rule._constructorType) { + return true; + } else { + return new errors.ValidationError(`Expected ${getValueType(rule)}, got ${getValueType(value)} instead`); + } + }; + } else { + throw new Error(`Unrecognized rule type for rule ${util.inspect(rule)}`); /* FIXME? */ + } + + let compositeFunction; + + if (rule._constraints.length === 0) { + compositeFunction = function baseRuleWrapper(value) { + if (value === undefined && rule._allowUndefined === true) { + return true; + } else if (value == null) { + return new errors.ValidationError(`Value is required for property '${name}'`, { + property: name + }); + } else { + return baseRule.call(this, value); + } + }; + } else { + let hasDefaultValue = false, defaultValue; + + let filteredConstraints = rule._constraints.filter((constraint) => { + if (constraint.type === "default") { + hasDefaultValue = true; + defaultValue = constraint.value; + return false; + } else { + return true; + } + }); + + let rules = [baseRule].concat(filteredConstraints.map((constraint) => { + if (constraint.type === "validate") { + return constraint.validator; + } else { + throw new Error(`Encountered unrecognized constraint type: ${constraint.type}`); + } + })); + + compositeFunction = function complexRuleWrapper(value) { + if (value == null) { + if (hasDefaultValue) { + return { + _default: defaultValue + }; + } else if (value === undefined && rule._allowUndefined === true) { + return true; + } else { + return new errors.ValidationError(`Value is required for property '${name}'`, { + property: name + }); + } + } else { + /* FIXME: Possibly special-case (for better performance) if the only extra rule is a 'default value' rule? This would avoid a `for` loop in the case where a value is explicitly specified. */ + for (rule of rules) { + let result = rule.call(this, value); + + if (result === true) { + continue; + } else { + return result; + } + } + + return true; + } + }; + } + + compositeFunction._rule = rule; + return compositeFunction; +}; diff --git a/src/guard-function.js b/src/guard-function.js new file mode 100644 index 0000000..9580db5 --- /dev/null +++ b/src/guard-function.js @@ -0,0 +1,45 @@ +"use strict"; + +const generateValidator = require("./generate-validator"); + +module.exports = function guardFunction(args, returnType, func) { + let rules = args.map((arg) => { + return generateValidator(arg); + }); + + let returnValueValidator = generateValidator(returnType); + + let guardedFunction = function (...params) { + let paramsWithDefaults = new Array(params.length); + + /* TODO: Can this be made faster by manually incrementing the index outside of the loop, or even by using forEach? ref https://jsperf.com/for-of-vs-foreach-with-index */ + for (let [i, rule] of rules.entries()) { + let validatorResult = rule.call(this, params[i]); + + if (validatorResult === true) { + paramsWithDefaults[i] = params[i]; + } else if (validatorResult._default !== undefined) { + paramsWithDefaults[i] = validatorResult._default; + } else { + throw validatorResult; + } + } + + let returnValue = func.apply(this, paramsWithDefaults); + let returnValueValidatorResult = returnValueValidator.call(this, returnValue); + + if (returnValueValidatorResult === true) { + return returnValue; + } else if (returnValueValidatorResult._default !== undefined) { + return returnValueValidatorResult.default; + } else { + throw returnValueValidatorResult; + } + }; + + guardedFunction._guardedArgs = args; + guardedFunction._guardedReturnType = returnType; + guardedFunction._guardedFunction = func; + + return guardedFunction; +}; diff --git a/src/guarded-collections/map.js b/src/guarded-collections/map.js new file mode 100644 index 0000000..aa7004f --- /dev/null +++ b/src/guarded-collections/map.js @@ -0,0 +1,92 @@ +"use strict"; + +const util = require("util"); + +const errors = require("../errors"); +const createWrapper = require("../util/function-wrapper"); +const getValueType = require("../util/get-value-type"); + +let proto = { + _source: null, + set: null, + size: 0, + [Symbol.iterator]: createWrapper(Symbol.iterator), + [util.inspect.custom]: function () { + return this.valueOf(); + }, + clear: function () { + this._source.clear(); + this.size = 0; + }, + entries: createWrapper("entries"), + forEach: createWrapper("forEach"), + get: createWrapper("get"), + has: createWrapper("has"), + keys: createWrapper("keys"), + values: createWrapper("values"), + toString: createWrapper("toString"), + valueOf: createWrapper("valueOf"), + delete: function (key) { + let deleted = this._source.delete(key); + this.size = this._source.size; + + if (deleted === false) { + throw new errors.ValidationError("Tried to delete non-existent key from guarded map", { + key: key, + nap: this._source + }); + } + } +}; + +module.exports = function createGuardedMap(map, valueGuard, keyGuard, parent) { + function generateMapSignatureError() { + let wantedSignature = { + _guardedCollectionType: "map", + _keyType: keyGuard._rule, + _itemType: valueGuard._rule + }; + + return new errors.ValidationError(`Expected a Map or ${getValueType(wantedSignature)}, got ${getValueType(map)} instead`); + } + + if (map._guardedCollectionType === "map") { + if (keyGuard === map._keyType && valueGuard === map._itemType) { + return map; + } else { + throw generateMapSignatureError(); + } + } else if (map instanceof Map) { + function check(key, value) { + let keyGuardResult = keyGuard.call(parent, key); + let valueGuardResult = valueGuard.call(parent, value); + + if (keyGuardResult === true && valueGuardResult === true) { + return true; + } else if (keyGuardResult !== true) { + throw keyGuardResult; + } else { + throw valueGuardResult; + } + } + + for (let [key, value] of map.entries()) { + check(key, value); + } + + return Object.assign(Object.create(proto), { + _source: map, + _guardedCollectionType: "map", + _itemType: valueGuard, + _keyType: keyGuard, + set: function (key, value) { + if (check(key, value) === true) { + this._source.set(key, value); + this.size = this._source.size; + } + } + }); + } else { + throw generateMapSignatureError(); + } +}; diff --git a/src/guarded-collections/set.js b/src/guarded-collections/set.js new file mode 100644 index 0000000..8c1e2b2 --- /dev/null +++ b/src/guarded-collections/set.js @@ -0,0 +1,87 @@ +"use strict"; + +const util = require("util"); + +const errors = require("../errors"); +const createWrapper = require("../util/function-wrapper"); +const getValueType = require("../util/get-value-type"); + +let proto = { + _source: null, + description: null, + size: 0, + add: null, + [Symbol.iterator]: createWrapper(Symbol.iterator), + [util.inspect.custom]: function () { + return this.valueOf(); + }, + clear: function () { + this._source.clear(); + this.size = 0; + }, + entries: createWrapper("entries"), + forEach: createWrapper("forEach"), /* FIXME: Prevent mutation from the third argument to forEach (which is the original set); maybe also needed for Map? */ + has: createWrapper("has"), + keys: createWrapper("keys"), + values: createWrapper("values"), + toString: createWrapper("toString"), + valueOf: createWrapper("valueOf"), + delete: function (value) { + let deleted = this._source.delete(value); + this.size = this._source.size; + + if (deleted === false) { + throw new errors.ValidationError("Tried to delete non-existent value from guarded set", { + value: value, + set: this._source + }); + } + } +}; + +module.exports = function createGuardedSet(set, guard, _, parent) { + function generateSetSignatureError() { + let wantedSignature = { + _guardedCollectionType: "set", + _itemType: guard._rule + }; + + return new errors.ValidationError(`Expected a Set or ${getValueType(wantedSignature)}, got ${getValueType(set)} instead`); + } + + if (set._guardedCollectionType === "set") { + if (guard === set._itemType) { + return set; + } else { + throw generateSetSignatureError(); + } + } else if (set instanceof Set) { + function check(value) { + let guardResult = guard.call(parent, value); + + if (guardResult === true) { + return true; + } else { + throw guardResult; + } + } + + for (let value of set.values()) { + check(value); + } + + return Object.assign(Object.create(proto), { + _source: set, + _guardedCollectionType: "set", + _itemType: guard, + add: function (value) { + if (check(value) === true) { + this._source.add(value); + this.size = this._source.size; + } + } + }); + } else { + throw generateSetSignatureError(); + } +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..8d13a0c --- /dev/null +++ b/src/index.js @@ -0,0 +1,31 @@ +"use strict"; + +const typeRules = require("./type-rules"); +const createType = require("./create-type"); +const createTrait = require("./create-trait"); +const guardFunction = require("./guard-function"); +const errors = require("./errors"); + +/* Traits: + - Special 'slot' value; for use in trait implementations to indicate that the specified trait definition property should be filled in by the instance constructor, not by the trait implementation + +Possible field types for a trait: + - Static value [add the value to the type's prototype?] + - Type/validation rule [check against implementation, see below] + +Possible field types for a trait implementation (all of them fill in type/validation rules): + - Static value, including an appropriately guarded function [add the value to the type's prototype?] + - Slot [add the trait rule to the type rules] + +Disallow extra fields in trait implementations! + +TODO: Add full property paths to errors? + +*/ + +module.exports = Object.assign({ + createType: createType, + createTrait: createTrait, + guard: guardFunction, + ValidationError: errors.ValidationError +}, typeRules); diff --git a/src/type-rules.js b/src/type-rules.js new file mode 100644 index 0000000..7dd67c9 --- /dev/null +++ b/src/type-rules.js @@ -0,0 +1,115 @@ +"use strict"; + +/* TODO: Implement .unique */ + +function createTypeRule(props) { + return Object.assign(props, { + _isTypeRule: true, + _constraints: [], + validate: function (validator) { + this._constraints.push({ + type: "validate", + validator: validator + }); + + return this; + }, + optional: function () { + return this.default(null); + }, + default: function (value) { + this._constraints.push({ + type: "default", + value: value + }); + + return this; + } + }); +} + +function createBaseTypeFunction(typeName, allowUndefined = false) { + return function (options = {}) { + return createTypeRule({ + _baseType: typeName, + _options: options, + _allowUndefined: allowUndefined + }); + }; +} + +function createCollectionTypeFunction(typeName, hasKeyType) { + if (hasKeyType) { + return function (keyType, itemType, options = {}) { + if (keyType == null || itemType == null) { + throw new Error("Must specify both a key type and a value type"); + } + return createTypeRule({ + _collectionType: typeName, + _itemType: itemType, + _keyType: keyType, + _options: options + }); + }; + } else { + return function (itemType, options = {}) { + return createTypeRule({ + _collectionType: typeName, + _itemType: itemType, + _options: options + }); + }; + } +} + +module.exports = { + _createTypeRule: createTypeRule, + string: createBaseTypeFunction("string"), + boolean: createBaseTypeFunction("boolean"), + number: createBaseTypeFunction("number"), + nothing: createBaseTypeFunction("nothing", true), + null: createBaseTypeFunction("null"), + undefined: createBaseTypeFunction("undefined", true), + instanceOf: function (constructor) { + /* TEST: Check that constructor is actually a function */ + return createTypeRule({ + _constructorType: constructor + }); + }, + setOf: createCollectionTypeFunction("set", false), + arrayOf: createCollectionTypeFunction("array", false), + mapOf: createCollectionTypeFunction("map", true), + /* TODO: Consider whether objectOf is needed/desirable at all */ + // objectOf: createCollectionTypeFunction("map", true), + function: function (args, returnType) { + if (args == null) { + throw new Error("Must specify argument types when creating a function rule"); + } else if (returnType == null) { + throw new Error("Must specify a return type when creating a function rule"); + } + + return createTypeRule({ + _baseType: "guardedFunction", + _options: { + args: args, + returnType: returnType + } + }); + }, + slot: function () { + return createTypeRule({ + _isSlotRule: true + }); + }, + self: function () { + return createTypeRule({ + _isSelfRule: true + }); + }, + either: function (...types) { + return createTypeRule({ + _modifierType: "either", + _types: types + }); + } +}; diff --git a/src/util/function-wrapper.js b/src/util/function-wrapper.js new file mode 100644 index 0000000..e8e6858 --- /dev/null +++ b/src/util/function-wrapper.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = function createWrapper(func) { + return function (...params) { + return this._source[func](...params); + }; +}; diff --git a/src/util/get-schema-keys.js b/src/util/get-schema-keys.js new file mode 100644 index 0000000..11ec0dc --- /dev/null +++ b/src/util/get-schema-keys.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = function getSchemaKeys(schema) { + return Object.keys(schema).filter((key) => (schema[key]._isTypeRule === true)); +}; diff --git a/src/util/get-value-type.js b/src/util/get-value-type.js new file mode 100644 index 0000000..764d441 --- /dev/null +++ b/src/util/get-value-type.js @@ -0,0 +1,183 @@ +"use strict"; + +const util = require("util"); +const capitalize = require("capitalize"); + +const isConstructor = require("./is-constructor"); +const isNamedFunction = require("./is-named-function"); + +/* TODO: Add property tracking throughout calls, to determine exactly where the wrong value was specified. */ + +module.exports = getValueType; +function getValueType(value, withArticle = true) { + let [article, description] = getValueTypeData(value); + + if (withArticle) { + if (article != null) { + return `${article} ${description}`; + } else { + return description; + } + } else { + return description; + } +} + +function getValueTypeData(value) { + if (value != null && value._isTypeRule) { + /* + _baseType + boolean + string + number + nothing? + null + undefined + function? + guardedFunction + _collectionType + set + map + array + _modifierType + either + _constructorType + (for instanceOf) + _isSlotRule + _isSelfRule + custom type + */ + if (value._isSlotRule) { + return [null, "(slot)"]; + } else if (value._isSelfRule) { + return [null, "(self)"]; + } else if (value._constructorType != null) { + let constructorName; + + if (value._constructorType.name != null) { + constructorName = value._constructorType.name; + } else { + constructorName = "(unnamed type)"; + } + + return ["an", `instance of ${constructorName}`]; + } else if (value._baseType != null) { + if (value._baseType === "guardedFunction") { + let argList = value._options.args + .map((arg) => getValueType(arg, false)) + .join(", "); + + return ["a", `guarded function (${argList}) → ${getValueType(value._options.returnType, false)}`]; + } else { + if (value._baseType === "string") { + return ["a", "string"]; + } else if (value._baseType === "number") { + return ["a", "number"]; + } else if (value._baseType === "boolean") { + return ["a", "boolean"]; + } else { + return [null, value._baseType]; + } + } + } else if (value._modifierType != null) { + if (value._modifierType === "either") { + let typeList = value._types + .map((type) => getValueType(type, false)) + .join(" | "); + + return [null, `Either<${typeList}>`]; + } else { + throw new Error(`Encountered unrecognized modifier type: ${value._modifierType}`); + } + } else if (value._collectionType != null) { + let collectionTypeName = capitalize(value._collectionType); + + if (value._keyType != null) { + return ["a", `${collectionTypeName}<${getValueType(value._keyType, false)} → ${getValueType(value._itemType, false)}>`]; + } else { + return ["a", `${collectionTypeName}<${getValueType(value._itemType, false)}>`]; + } + } else if (value._isCustomType) { + return ["an", `instance of ${value._name}`]; + } else if (value._isTrait) { + return ["an", `instance of a type with the ${value._name} trait`]; + } else { + throw new Error(`Encountered unrecognized type rule: ${util.inspect(value, {breakLength: Infinity})}`); + } + } else { + /* Assume this is a value, rather than a type rule. */ + if (value === undefined) { + return [null, "undefined"]; + } else if (value === null) { + return [null, "null"]; + } else if (typeof value === "boolean") { + return ["a", "boolean"]; + } else if (typeof value === "number") { + if (value === Number.POSITIVE_INFINITY) { + return [null, "Infinity"]; + } else if (value === Number.NEGATIVE_INFINITY) { + return [null, "negative Infinity"]; + } else if (isNaN(value)) { + return [null, "NaN"]; + } else { + return ["a", "number"]; + } + } else if (typeof value === "string") { + return ["a", "string"]; + } else if (typeof value === "function") { + if (value._guardedFunction != null) { + let functionName; + + if (isNamedFunction(value._guardedFunction)) { + functionName = `guarded function[${value._guardedFunction.name}]`; + } else { + functionName = "guarded function"; + } + + /* TODO: Deduplicate this code? */ + let argList = value._guardedArgs + .map((arg) => getValueType(arg, false)) + .join(", "); + + return ["a", `${functionName} (${argList}) → ${getValueType(value._guardedReturnType, false)}`]; + } else if (isConstructor(value)) { + if (isNamedFunction(value)) { + return ["a", `constructor[${value.name}]`]; + } else { + return ["a", "constructor"]; + } + } else { + if (isNamedFunction(value)) { + return ["a", `function[${value.name}]`]; + } else { + return ["a", "function"]; + } + } + } else if (Array.isArray(value)) { + return ["an", "array"]; + } else if (value instanceof RegExp) { + return ["a", "regular expression"]; + } else if (typeof value === "object") { + if (value._guardedCollectionType === "set") { + return ["a", `Set<${getValueType(value._itemType, false)}>`]; + } else if (value._guardedCollectionType === "map") { + return ["a", `Map<${getValueType(value._keyType, false)} → ${getValueType(value._itemType, false)}>`]; + } else if (value instanceof Map) { + return ["a", "Map"]; + } else if (value instanceof Set) { + return ["a", "Set"]; + } else if (value._type != null && value._type._isCustomType != null) { + return ["an", `instance of ${value._type._name}`]; + } else if (value.__proto__.constructor === Object) { + /* Object literal */ + return ["an", "object"]; + } else if (isNamedFunction(value.__proto__.constructor)) { + return ["an", `instance of ${value.__proto__.constructor.name}`]; + } else { + return ["an", "instance of an unknown type"]; + } + } else { + throw new Error("Encountered unrecognized value type"); + } + } +} diff --git a/src/util/is-constructor.js b/src/util/is-constructor.js new file mode 100644 index 0000000..d577758 --- /dev/null +++ b/src/util/is-constructor.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = function isConstructor(value) { + /* TODO: This isn't great. Technically a regular function could have a non-empty prototype; and a constructor could have a prototype that *looks* empty but has a parent prototype that isn't. */ + return (typeof value === "function" && value.prototype != null && Object.keys(value.prototype).length > 0); +}; diff --git a/src/util/is-named-function.js b/src/util/is-named-function.js new file mode 100644 index 0000000..67ff5e6 --- /dev/null +++ b/src/util/is-named-function.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = function isNamedFunction(func) { + return (func.name != null && func.name !== "" && func.name !== "anonymous"); +}; diff --git a/src/util/null-missing-fields.js b/src/util/null-missing-fields.js new file mode 100644 index 0000000..a937272 --- /dev/null +++ b/src/util/null-missing-fields.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = function nullMissingFields(data, schemaKeys) { + let nullFields = {}; + + schemaKeys.forEach((key) => { + if (data[key] === undefined) { + nullFields[key] = null; + } + }); + + return nullFields; +}; diff --git a/src/validator-functions/boolean.js b/src/validator-functions/boolean.js new file mode 100644 index 0000000..eae8d9b --- /dev/null +++ b/src/validator-functions/boolean.js @@ -0,0 +1,14 @@ +"use strict"; + +const errors = require("../errors"); +const getValueType = require("../util/get-value-type"); + +module.exports = function createBooleanValidatorFunction(_options = {}) { + return function validateBoolean(value) { + if (typeof value !== "boolean") { + return new errors.ValidationError(`Expected a boolean, got ${getValueType(value)} instead`); + } else { + return true; + } + }; +}; diff --git a/src/validator-functions/function.js b/src/validator-functions/function.js new file mode 100644 index 0000000..3eb5ab5 --- /dev/null +++ b/src/validator-functions/function.js @@ -0,0 +1,24 @@ +"use strict"; + +const errors = require("../errors"); +const getValueType = require("../util/get-value-type"); + +module.exports = function createFunctionValidatorFunction(options = {}) { + let guardedFunctionDescription = getValueType({ + _isTypeRule: true, + _baseType: "guardedFunction", + _options: options + }); + + // options.args + // options.returnValue + + return function validateFunction(value) { + if (typeof value === "function" && value._guardedFunction != null) { + /* FIXME: Verify that the function signature matches. */ + return true; + } else { + return new errors.ValidationError(`Expected ${guardedFunctionDescription}, got ${getValueType(value)} instead`); + } + }; +}; diff --git a/src/validator-functions/nothing.js b/src/validator-functions/nothing.js new file mode 100644 index 0000000..f23b5b3 --- /dev/null +++ b/src/validator-functions/nothing.js @@ -0,0 +1,14 @@ +"use strict"; + +const errors = require("../errors"); +const getValueType = require("../util/get-value-type"); + +module.exports = function createNothingValidatorFunction(_options = {}) { + return function validateNothing(value) { + if (value != null) { + return new errors.ValidationError(`Expected nothing, got ${getValueType(value)} instead`); + } else { + return true; + } + }; +}; diff --git a/src/validator-functions/null.js b/src/validator-functions/null.js new file mode 100644 index 0000000..abee18b --- /dev/null +++ b/src/validator-functions/null.js @@ -0,0 +1,14 @@ +"use strict"; + +const errors = require("../errors"); +const getValueType = require("../util/get-value-type"); + +module.exports = function createNullValidatorFunction(_options = {}) { + return function validateNull(value) { + if (value !== null) { + return new errors.ValidationError(`Expected null, got ${getValueType(value)} instead`); + } else { + return true; + } + }; +}; diff --git a/src/validator-functions/number.js b/src/validator-functions/number.js new file mode 100644 index 0000000..ef29b2f --- /dev/null +++ b/src/validator-functions/number.js @@ -0,0 +1,48 @@ +"use strict"; + +const errors = require("../errors"); +const getValueType = require("../util/get-value-type"); + +module.exports = function createNumberValidatorFunction(options = {}, name = "") { + let mayBeNaN = (options.mayBeNaN === true); + let mayBeInfinity = (options.mayBeInfinity === true); + let hasMinimum = (options.minimum != null); + let hasMaximum = (options.maximum != null); + + if (hasMinimum && typeof options.minimum !== "number") { + throw new Error("Minimum value for number must be a number"); + } else if (hasMaximum && typeof options.maximum !== "number") { + throw new Error("Maximum value for number must be a number"); + } else if (hasMinimum && hasMaximum && options.minimum > options.maximum) { + throw new Error("Minimum value for number cannot be higher than maximum value"); + } else { + return function validateNumber(value) { + /* TODO: More consistent error message format, with consistent property path metadata? */ + if (typeof value !== "number") { + return new errors.ValidationError(`Expected a number, got ${getValueType(value)} instead`); + // return new errors.ValidationError(`Specified value for property '${name}' is not a number`, { + // value: value, + // property: name + // }); + } else if (!mayBeNaN && isNaN(value)) { + return new errors.ValidationError(`Specified value for property '${name}' is NaN, but this is not allowed`, { + property: name + }); + } else if (!mayBeInfinity && !isFinite(value)) { + return new errors.ValidationError(`Specified value for property '${name}' is an infinite value, but this is not allowed`, { + property: name + }); + } else if (hasMinimum && value < options.minimum) { + return new errors.ValidationError(`Value for property '${name}' must be at least ${options.minimum}`, { + property: name + }); + } else if (hasMaximum && value > options.maximum) { + return new errors.ValidationError(`Value for property '${name}' cannot be higher than ${options.maximum}`, { + property: name + }); + } else { + return true; + } + }; + } +}; diff --git a/src/validator-functions/string.js b/src/validator-functions/string.js new file mode 100644 index 0000000..0b1bd21 --- /dev/null +++ b/src/validator-functions/string.js @@ -0,0 +1,28 @@ +"use strict"; + +const errors = require("../errors"); +const getValueType = require("../util/get-value-type"); + +module.exports = function createStringValidatorFunction(options = {}, name = "") { + let hasMatchCondition = (options.matches != null); + let matchCondition = options.matches; + + return function validateString(value) { + if (typeof value !== "string") { + return new errors.ValidationError(`Expected a string, got ${getValueType(value)} instead`); + // return new errors.ValidationError(`Specified value for property '${name}' is not a string`, { + // value: value, + // property: name + // }); + } else if (hasMatchCondition && !matchCondition.test(value)) { + /* TODO: Improve regex display */ + return new errors.ValidationError(`Value for property '${name}' failed \`matches\` condition`, { + value: value, + property: name, + condition: matchCondition + }); + } else { + return true; + } + }; +}; diff --git a/src/validator-functions/undefined.js b/src/validator-functions/undefined.js new file mode 100644 index 0000000..1010117 --- /dev/null +++ b/src/validator-functions/undefined.js @@ -0,0 +1,14 @@ +"use strict"; + +const errors = require("../errors"); +const getValueType = require("../util/get-value-type"); + +module.exports = function createUndefinedValidatorFunction(_options = {}) { + return function validateUndefined(value) { + if (value !== undefined) { + return new errors.ValidationError(`Expected undefined, got ${getValueType(value)} instead`); + } else { + return true; + } + }; +}; diff --git a/test/function-guards.js b/test/function-guards.js new file mode 100644 index 0000000..0a74b93 --- /dev/null +++ b/test/function-guards.js @@ -0,0 +1,67 @@ +"use strict"; + +const expect = require("chai").expect; + +const dm = require("../src"); + +describe("function guards", () => { + it("should accept a correctly-behaving function", () => { + let guardedFunction = dm.guard([dm.string(), dm.number()], dm.boolean(), function (str, num) { + expect(str).to.equal("hello world"); + expect(num).to.equal(42); + return true; + }); + + guardedFunction("hello world", 42); + }); + + it("should throw on an invalid return value type", () => { + let guardedFunction = dm.guard([dm.string(), dm.number()], dm.boolean(), function (str, num) { + return "not a boolean"; + }); + + expect(() => { + guardedFunction("hello world", 42); + }).to.throw("Expected a boolean, got a string instead"); + }); + + it("should throw on an invalid argument type", () => { + let guardedFunction = dm.guard([dm.string(), dm.number()], dm.boolean(), function (str, num) { + return true; + }); + + expect(() => { + guardedFunction(false, 42); + }).to.throw("Expected a string, got a boolean instead"); + }); + + it("should throw on a missing argument", () => { + let guardedFunction = dm.guard([dm.string(), dm.number()], dm.boolean(), function (str, num) { + return true; + }); + + expect(() => { + guardedFunction("hello world"); + }).to.throw("Value is required for property ''"); + }); + + it(" ... but not when that argument is optional", () => { + let guardedFunction = dm.guard([dm.string(), dm.number().optional()], dm.boolean(), function (str, num) { + expect(str).to.equal("hello world"); + expect(num).to.equal(null); + return true; + }); + + guardedFunction("hello world"); + }); + + it("should correctly handle defaults", () => { + let guardedFunction = dm.guard([dm.string(), dm.number().default(42)], dm.boolean(), function (str, num) { + expect(str).to.equal("hello world"); + expect(num).to.equal(42); + return true; + }); + + guardedFunction("hello world"); + }); +}); diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..435eb1d --- /dev/null +++ b/test/index.js @@ -0,0 +1,16 @@ +/* + +- Types + - Simple field types + - Collection field types + - Instances of constructors + - Custom field types + - Guarded function types +- Traits + - Implementations + - Slots + - Disallow duplicate properties +- Function guards +- Debug descriptions + +*/ diff --git a/test/models.js b/test/models.js new file mode 100644 index 0000000..88a3490 --- /dev/null +++ b/test/models.js @@ -0,0 +1,437 @@ +"use strict"; + +const expect = require("chai").expect; + +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: Registry */ +/* FIXME: Test passing an already-guarded collection into a guarded collection factory - either stand-alone or in the context of a model? */ +let Identity = dm.createType("Identity", { + label: dm.string(), + nickname: dm.string({ + matches: /^[a-z0-9_-]+$/ + }), + emailAddress: dm.string({ + matches: /@/ + }), + xmppAddress: dm.string({ + matches: /@/ + }).optional(), + notes: dm.string().default("(none)") +}); + +let User = dm.createType("User", { + name: dm.string(), + isActive: dm.boolean(), + registrationDate: dm.instanceOf(Date), + misc: dm.either( + dm.string(), + dm.number() + ), + // undefinedValue: dm.undefined(), + // nullValue: dm.null(), + // nothingValue: dm.nothing(), + age: dm.number({ + minimum: 0 + }), + luckyNumbers: dm.setOf(dm.number({ + minimum: 1, + maximum: 42 + })), + identities: dm.setOf(Identity), + mainIdentity: Identity, + alternateUser: dm.self().optional(), + codeWords: dm.mapOf(dm.string(), dm.string()) +}); + +function generateUserData(props = {}) { + return Object.assign({}, { + name: "Sven Slootweg", + isActive: true, + registrationDate: new Date(), + age: 26, + luckyNumbers: new Set([13, 42]), + misc: 42, + // nullValue: null, + // undefinedValue: undefined, + // nothingValue: undefined, + identities: new Set([ + Identity({ + label: "primary", + nickname: "joepie91", + emailAddress: "admin@cryto.net", + xmppAddress: "joepie91@neko.im", + notes: "Main nickname" + }), + Identity({ + label: "bot", + nickname: "botpie91", + emailAddress: "botpie91@cryto.net" + }) + ]), + mainIdentity: Identity({ + label: "primary", + nickname: "joepie91", + emailAddress: "admin@cryto.net", + xmppAddress: "joepie91@neko.im", + notes: "Main nickname" + }), + codeWords: new Map([ + ["foo", "Foo"], + ["bar", "Bar"] + ]) + }, props); +} + +function compareObject(reference, obj) { + if (typeof reference === "object") { + for (let [key, value] of Object.entries(reference)) { + if (value instanceof Set) { + let objValues = Array.from(obj[key]); + let referenceValues = Array.from(value); + + referenceValues.forEach((item, i) => { + compareObject(item, objValues[i]); + }); + } else if (value instanceof Map) { + for (let [mapKey, item] of value.entries()) { + compareObject(item, obj[key].get(mapKey)); + } + } else if (typeof reference === "object") { + compareObject(value, obj[key]); + } else { + expect(obj[key]).to.equal(value); + } + } + } else { + expect(obj).to.equal(reference); + } +} + +describe("models", () => { + describe("valid cases", () => { + let userInstance; + let date = new Date(); + + it("should succeed at creating valid objects", () => { + userInstance = User(generateUserData({ + registrationDate: date + })); + }); + + it("should have the correct properties", () => { + compareObject({ + name: "Sven Slootweg", + isActive: true, + registrationDate: date, + age: 26, + luckyNumbers: new Set([13, 42]), + misc: 42, + identities: new Set([{ + label: "primary", + nickname: "joepie91", + emailAddress: "admin@cryto.net", + xmppAddress: "joepie91@neko.im", + notes: "Main nickname" + }, { + label: "bot", + nickname: "botpie91", + emailAddress: "botpie91@cryto.net", + notes: "(none)" + }]), + mainIdentity: { + label: "primary", + nickname: "joepie91", + emailAddress: "admin@cryto.net", + xmppAddress: "joepie91@neko.im", + notes: "Main nickname" + }, + codeWords: new Map([ + ["foo", "Foo"], + ["bar", "Bar"] + ]) + }, userInstance); + }); + + it("should allow extraneous properties", () => { + let instance = User(generateUserData({ + extraneousProperty: 42 + })); + + expect(instance.extraneousProperty).to.equal(42); + }); + + it("should allow a null-typed value for nothingValue as well", () => { + User(generateUserData({ + nothingValue: null + })); + }); + + it("should allow a passing validation constraint", () => { + let Model = dm.createType("Model", { + value: dm.string().validate((value) => { + return true; + }) + }); + + Model({ + value: "foo" + }); + }); + + // TODO: Tests for allowing NaN, Infinity, -Infinity, etc. + }); + + describe("failure cases", () => { + describe("required properties", () => { + it("should fail on a missing required property", () => { + expect(() => { + let data = generateUserData(); + delete data.name; + User(data); + }).to.throw("Value is required for property 'name'"); + }); + + it("should fail on a required property being null", () => { + expect(() => { + User(generateUserData({ + name: null + })); + }).to.throw("Value is required for property 'name'"); + }); + + it("should fail on a required property being undefined", () => { + expect(() => { + User(generateUserData({ + name: undefined + })); + }).to.throw("Value is required for property 'name'"); + }); + }); + + describe("top-level properties", () => { + it("should require a string-typed name", () => { + expect(() => { + User(generateUserData({ + name: 42 + })); + }).to.throw("Expected a string, got a number instead"); + }); + + it("should require a boolean-typed isActive", () => { + expect(() => { + User(generateUserData({ + isActive: 42 + })); + }).to.throw("Expected a boolean, got a number instead"); + }); + + it("should require a number-typed age", () => { + expect(() => { + User(generateUserData({ + age: "foo" + })); + }).to.throw("Expected a number, got a string instead"); + }); + + it("should require either a number-typed or a string-typed misc value", () => { + expect(() => { + User(generateUserData({ + misc: false + })); + }).to.throw("Expected one of (string, number), got a boolean instead"); + }); + + it("should require a Date-typed registrationDate", () => { + expect(() => { + User(generateUserData({ + registrationDate: 42 + })); + }).to.throw("Expected an instance of Date, got a number instead"); + }); + + it("should require a Set-typed luckyNumbers", () => { + expect(() => { + User(generateUserData({ + luckyNumbers: 42 + })); + }).to.throw("Expected a Set or a Set, got a number instead"); + }); + + it("should require a Map-typed codeWords", () => { + expect(() => { + User(generateUserData({ + codeWords: 42 + })); + }).to.throw("Expected a Map or a Map, got a number instead"); + }); + }); + + describe("collections and nested instances", () => { + /* TODO: Do we need separate tests for guarded Maps/Sets? */ + + it("should require Identity-typed values for its identities Set", () => { + expect(() => { + User(generateUserData({ + identities: new Set([ + 42, 41, 40 + ]) + })); + }).to.throw("Expected an instance of Identity, got a number instead"); + }); + + it("should require string-typed keys for its codeWords Map", () => { + expect(() => { + User(generateUserData({ + codeWords: new Map([ + [42, "foo"], + ["bar", "baz"] + ]) + })); + }).to.throw("Expected a string, got a number instead"); + }); + + it("should require string-typed values for its codeWords Map", () => { + expect(() => { + User(generateUserData({ + codeWords: new Map([ + ["foo", 42], + ["bar", "baz"] + ]) + })); + }).to.throw("Expected a string, got a number instead"); + }); + + it("should reject an instance of the wrong custom type", () => { + expect(() => { + User(generateUserData({ + mainIdentity: User(generateUserData()) + })); + }).to.throw("Expected an instance of Identity, got an instance of User instead"); + }); + + it("should reject an instance of the wrong type for a 'self' rule", () => { + expect(() => { + User(generateUserData({ + alternateUser: Identity({ + label: "bot", + nickname: "botpie91", + emailAddress: "botpie91@cryto.net" + }) + })); + }).to.throw("Expected an instance of User, got an instance of Identity instead"); + }); + + it("should also validate values for a nested instance", () => { + expect(() => { + User(generateUserData({ + mainIdentity: Identity({ + label: "bot", + nickname: {}, + emailAddress: "botpie91@cryto.net" + }) + })); + }).to.throw("Expected a string, got an object instead"); + }); + + it("should also validate values for nested instances in a Set", () => { + expect(() => { + User(generateUserData({ + identities: new Set([ + Identity({ + label: "bot", + nickname: {}, + emailAddress: "botpie91@cryto.net" + }) + ]) + })); + }).to.throw("Expected a string, got an object instead"); + }); + }); + + describe("type options", () => { + describe("numbers", () => { + it("should detect violation of a minimum number", () => { + expect(() => { + User(generateUserData({ + age: -1 + })); + }).to.throw("Value for property 'age' must be at least 0"); + }); + + it("should detect violation of a numeric range, at the lower bound", () => { + expect(() => { + User(generateUserData({ + luckyNumbers: new Set([13, -1]) + })); + }).to.throw("Value for property '' must be at least 1"); + }); + + it("should detect violation of a numeric range, at the upper bound", () => { + expect(() => { + User(generateUserData({ + luckyNumbers: new Set([13, 2000]) + })); + }).to.throw("Value for property '' cannot be higher than 42"); + }); + + it("should reject NaN", () => { + expect(() => { + User(generateUserData({ + age: NaN + })); + }).to.throw("Specified value for property 'age' is NaN, but this is not allowed"); + }); + + it("should reject positive Infinity", () => { + expect(() => { + User(generateUserData({ + age: Infinity + })); + }).to.throw("Specified value for property 'age' is an infinite value, but this is not allowed"); + }); + + it("should reject negative Infinity", () => { + expect(() => { + User(generateUserData({ + age: -Infinity + })); + }).to.throw("Specified value for property 'age' is an infinite value, but this is not allowed"); + }); + }); + + describe("strings", () => { + it("should reject a string that fails the specified 'matches' regex", () => { + expect(() => { + User(generateUserData({ + mainIdentity: Identity({ + label: "bot", + nickname: "botpie91", + emailAddress: "not-an-email-address" + }) + })); + }).to.throw("Value for property 'emailAddress' failed `matches` condition"); + }); + }); + + /* TODO: Function guards */ + }); + + describe("additional constraints", () => { + it("should reject a failing validation constraint", () => { + let Model = dm.createType("Model", { + value: dm.string().validate((value) => { + return new dm.ValidationError("Value failed nonsense validation rule"); + }) + }); + + expect(() => { + Model({ + value: "foo" + }); + }).to.throw("Value failed nonsense validation rule"); + }); + }); + }); +}); diff --git a/test/traits.js b/test/traits.js new file mode 100644 index 0000000..1874a4d --- /dev/null +++ b/test/traits.js @@ -0,0 +1,143 @@ +"use strict"; + +const expect = require("chai").expect; + +const dm = require("../src"); + +/* FIXME: Slot rule testing */ +/* FIXME: Function guard signature matching */ + +let GitSource = dm.createTrait("GitSource", { + stringify: dm.function([], dm.string()) +}); + +let GithubSource, CrytoGitSource, ExternalGitSource, sourceOne, sourceTwo, randomGitSource; + +describe("traits", () => { + it("should allow valid trait implementations", () => { + GithubSource = dm.createType("GithubSource", { + username: dm.string(), + repository: dm.string() + }).implements(GitSource, { + stringify: dm.guard([], dm.string(), function () { + return `https://github.com/${this.username}/${this.repository}.git`; + }) + }); + + CrytoGitSource = dm.createType("CrytoGitSource", { + username: dm.string(), + repository: dm.string() + }).implements(GitSource, { + stringify: dm.guard([], dm.string(), function () { + return `http://git.cryto.net/${this.username}/${this.repository}.git`; + }) + }); + }); + + it("should produce working trait functionality", () => { + sourceOne = GithubSource({ + username: "joepie91", + repository: "node-bhttp" + }); + + expect(sourceOne.stringify()).to.equal("https://github.com/joepie91/node-bhttp.git"); + + sourceTwo = CrytoGitSource({ + username: "joepie91", + repository: "node-bhttp" + }); + + expect(sourceTwo.stringify()).to.equal("http://git.cryto.net/joepie91/node-bhttp.git"); + }); + + let guardedFunc = dm.guard([GitSource], dm.nothing(), function (source) { + expect(source.stringify()).to.be.a("string"); + }); + + it("should allow an instance of any type with the correct trait to be passed into a trait-guarded function", () => { + guardedFunc(sourceOne); + guardedFunc(sourceTwo); + }); + + it("should reject an instance of a type that does not have the correct trait", () => { + let SomeType = dm.createType("SomeType", { + value: dm.string() + }); + + let instance = SomeType({ + value: "foo" + }); + + expect(() => { + guardedFunc(instance); + }).to.throw("Expected object of a type with the GitSource trait, got an instance of SomeType instead"); + }); + + it(" ... even if that type has an otherwise compatible-looking method", () => { + let SomeOtherType = dm.createType("SomeOtherType", { + value: dm.string(), + stringify: dm.function([], dm.string()) + }); + + let instance = SomeOtherType({ + value: "foo", + stringify: dm.guard([], dm.string(), function () { + return "this is not a URL at all"; + }) + }); + + expect(() => { + guardedFunc(instance); + }).to.throw("Expected object of a type with the GitSource trait, got an instance of SomeOtherType instead"); + }); + + it("should reject a trait implementation that does not meet the trait definition", () => { + expect(() => { + let InvalidType = dm.createType("InvalidType", { + value: dm.string() + }).implements(GitSource, { + stringify: "this is not a function" + }); + }).to.throw("Expected a guarded function () → string, got a string instead"); + }); + + it("should reject an empty trait implementation when the trait definition does not allow that", () => { + expect(() => { + let InvalidType = dm.createType("InvalidType", { + value: dm.string() + }).implements(GitSource, { + + }); + }).to.throw("Value is required for property 'stringify'"); + }); + + it("should allow for specifying slot rules", () => { + ExternalGitSource = dm.createType("ExternalGitSource", { + sourceName: dm.string() + }).implements(GitSource, { + stringify: dm.slot() + }); + }); + + it("should allow specifying a value for that slot", () => { + randomGitSource = ExternalGitSource({ + sourceName: "randomGit", + stringify: dm.guard([], dm.string(), function () { + return "https://randomgit.example.com/repo.git" + }) + }); + }); + + it("should have produced a working slot value", () => { + expect(randomGitSource.stringify()).to.equal("https://randomgit.example.com/repo.git"); + }); + + it("should reject an invalid slot value in the instance", () => { + expect(() => { + ExternalGitSource({ + sourceName: "randomGit", + stringify: "foo" + }); + }).to.throw("Expected a guarded function () → string, got a string instead"); + }); +}); diff --git a/test/value-descriptions.js b/test/value-descriptions.js new file mode 100644 index 0000000..34e9388 --- /dev/null +++ b/test/value-descriptions.js @@ -0,0 +1,275 @@ +"use strict"; + +const expect = require("chai").expect; + +const dm = require("../src"); +const getValueType = require("../src/util/get-value-type"); +const generateValidator = require("../src/generate-validator"); +const createGuardedMap = require("../src/guarded-collections/map"); +const createGuardedSet = require("../src/guarded-collections/set"); + +describe("value descriptions", () => { + describe("values", () => { + describe("simple types", () => { + it("should correctly describe a string", () => { + expect(getValueType("some string")).to.equal("a string"); + }); + + it("should correctly describe a number", () => { + expect(getValueType(42)).to.equal("a number"); + }); + + it("should correctly describe a boolean", () => { + expect(getValueType(true)).to.equal("a boolean"); + }); + + it("should correctly describe null", () => { + expect(getValueType(null)).to.equal("null"); + }); + + it("should correctly describe undefined", () => { + expect(getValueType(undefined)).to.equal("undefined"); + }); + + it("should correctly describe positive Infinity", () => { + expect(getValueType(Infinity)).to.equal("Infinity"); + }); + + it("should correctly describe negative Infinity", () => { + expect(getValueType(-Infinity)).to.equal("negative Infinity"); + }); + + it("should correctly describe NaN", () => { + expect(getValueType(NaN)).to.equal("NaN"); + }); + + it("should correctly describe a RegExp", () => { + expect(getValueType(new RegExp())).to.equal("a regular expression"); + }); + + it("should correctly describe a regular expression literal", () => { + expect(getValueType(/foo/)).to.equal("a regular expression"); + }); + }); + + describe("function types", () => { + it("should correctly describe a named function", () => { + function namedFunction() { + // nothing + } + + expect(getValueType(namedFunction)).to.equal("a function[namedFunction]"); + }); + + it("should correctly describe an anonymous function", () => { + expect(getValueType(function(){})).to.equal("a function"); + }); + + it("should correctly describe an arrow function", () => { + expect(getValueType(() => {})).to.equal("a function"); + }); + + it("should correctly describe a function created through a Function object", () => { + expect(getValueType(new Function())).to.equal("a function"); + }); + + it("should correctly describe a named constructor function", () => { + function SomeConstructor() { + // nothing + } + + SomeConstructor.prototype.someProp = 42; + + expect(getValueType(SomeConstructor)).to.equal("a constructor[SomeConstructor]"); + }); + + it("should correctly describe an anonymous constructor function", () => { + /* NOTE: We're doing some weird IIFE wrapping here to defeat the function name inference introduced in ES6; otherwise we can't test anonymous constructors. */ + (function (SomeConstructor) { + SomeConstructor.prototype.someProp = 42; + expect(getValueType(SomeConstructor)).to.equal("a constructor"); + })(function(){}); + }); + + it("should correctly describe a guarded named function", () => { + let func = dm.guard( + [dm.string(), dm.boolean()], + dm.nothing(), + function someNamedFunction(stringArg, booleanArg) { + // nothing + } + ); + + expect(getValueType(func)).to.equal("a guarded function[someNamedFunction] (string, boolean) → nothing") + }); + + it("should correctly describe a guarded anonymous function", () => { + let func = dm.guard( + [dm.string(), dm.boolean()], + dm.nothing(), + function (stringArg, booleanArg) { + // nothing + } + ); + + expect(getValueType(func)).to.equal("a guarded function (string, boolean) → nothing") + }); + }); + + describe("collection types", () => { + it("should correctly describe an array", () => { + expect(getValueType([])).to.equal("an array"); + }); + + it("should correctly describe an object literal", () => { + expect(getValueType({})).to.equal("an object"); + }); + + it("should correctly describe a Map", () => { + expect(getValueType(new Map())).to.equal("a Map"); + }); + + it("should correctly describe a Set", () => { + expect(getValueType(new Set())).to.equal("a Set"); + }); + + /* TODO: Guarded array */ + + it("should correctly describe a guarded Map", () => { + let guardedMap = createGuardedMap(new Map(), dm.boolean(), dm.string(), {}); + expect(getValueType(guardedMap)).to.equal("a Map"); + }); + + it("should correctly describe a guarded Set", () => { + let guardedSet = createGuardedSet(new Set(), dm.boolean(), null, {}); + expect(getValueType(guardedSet)).to.equal("a Set"); + }); + }); + + describe("instance types", () => { + describe("standard types", () => { + it("should correctly describe a Date", () => { + expect(getValueType(new Date())).to.equal("an instance of Date"); + }); + + it("should correctly describe an Error", () => { + expect(getValueType(new Error())).to.equal("an instance of Error"); + }); + }); + + describe("custom types", () => { + it("should correctly describe a type", () => { + let CustomType = dm.createType("CustomType", { + value: dm.string() + }); + + expect(getValueType(CustomType({ + value: "foo" + }))).to.equal("an instance of CustomType"); + }); + }); + }); + }); + + describe("rules", () => { + describe("simple types", () => { + it("should correctly describe a boolean rule", () => { + expect(getValueType(dm.boolean())).to.equal("a boolean"); + }); + + it("should correctly describe a string rule", () => { + expect(getValueType(dm.string())).to.equal("a string"); + }); + + it("should correctly describe a number rule", () => { + expect(getValueType(dm.number())).to.equal("a number"); + }); + + it("should correctly describe a null rule", () => { + expect(getValueType(dm.null())).to.equal("null"); + }); + + it("should correctly describe a undefined rule", () => { + expect(getValueType(dm.undefined())).to.equal("undefined"); + }); + + it("should correctly describe a nothing rule", () => { + expect(getValueType(dm.nothing())).to.equal("nothing"); + }); + + it("should correctly describe an instanceOf rule", () => { + expect(getValueType(dm.instanceOf(Date))).to.equal("an instance of Date"); + }); + }); + + describe("collection types", () => { + it("should correctly describe a setOf rule", () => { + let rule = dm.setOf(dm.boolean()); + expect(getValueType(rule)).to.equal("a Set"); + }); + + it("should correctly describe a mapOf rule", () => { + let rule = dm.mapOf(dm.string(), dm.boolean()); + expect(getValueType(rule)).to.equal("a Map"); + }); + + /* TODO: Guarded arrays */ + }); + + describe("custom types", () => { + it("should correctly describe a type", () => { + let CustomType = dm.createType("CustomType", { + value: dm.string() + }); + + expect(getValueType(CustomType)).to.equal("an instance of CustomType"); + }); + + it("should correctly describe a trait", () => { + let CustomTrait = dm.createTrait("CustomTrait", { + value: dm.string() + }); + + expect(getValueType(CustomTrait)).to.equal("an instance of a type with the CustomTrait trait"); + }); + }); + + describe("modifiers", () => { + it("should correctly describe an either rule", () => { + let rule = dm.either( + dm.string(), + dm.boolean() + ); + + expect(getValueType(rule)).to.equal("Either"); + }); + }); + + describe("special rules", () => { + it("should correctly describe a self rule", () => { + expect(getValueType(dm.self())).to.equal("(self)"); + }); + + it("should correctly describe a slot rule", () => { + expect(getValueType(dm.slot())).to.equal("(slot)"); + }); + }); + + describe("guards", () => { + it("should correctly describe a guarded function rule", () => { + expect(getValueType(dm.function( + [dm.string(), dm.boolean()], + dm.nothing(), + ))).to.equal("a guarded function (string, boolean) → nothing"); + }); + }); + + describe("error conditions", () => { + it("should throw an error when encountering an unrecognized rule", () => { + expect(() => { + getValueType({_isTypeRule: true, _butNotReally: false}); + }).to.throw(); + }); + }); + }); +});