diff --git a/.vscode/settings.json b/.vscode/settings.json index 1275396..93c099b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,5 +24,8 @@ "cmd": "embed-markdown" } ] - } + }, + "cSpell.words": [ + "deepcopy" + ] } diff --git a/README.md b/README.md index 8f3f9a2..158c3a3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ linked docs before continuing. - [Map Schema](#map-schema) - [Media Schema](#media-schema) - [Enumeration](#enumeration) + - [Polymorphic Objects](#polymorphic-objects) - [Validating Data](#validating-data) - [Common Schemas](#common-schemas) - [Getting JSON Schema](#getting-json-schema) @@ -202,6 +203,36 @@ S.str.enum('a', 'b') S.str.enum(Object.keys(someObject)) ``` +## Polymorphic Objects +A polymorphic object's properties depend on its type. It can be declared with +`S.polymorphicObject({ commonProps, typeNameToObj, typeKey })`: +* `commonProps` - Optional. The properties that all objects of this polymorphic + type will contain. This is the same as the argument to `S.obj()`. +* `typeNameToObj` - Required to have at least one key-value pair. Each key is + the name of one of the polymorphic types this object can have. The + corresponding value must be an `S.obj()` or an object that can be passed to + `S.obj()`; it describes the additional properties of this polymorphic type. +* `typeKey` - Optional. The default type property is `type` but can be changed + to anything you'd like. + +You can set `additionalProperties` to `true` on an `S.polymorphicObject` schema +just like an `S.obj` schema. However, the sub-types are all bound by this. + +You can nest polymorphic types inside of each other or other types. + +```javascript + const schema = S.polymorphicObj({ + commonProps: { n: S.int }, + typeNameToObj: { + someType: { x: S.str }, + typeWithNoExtraProps: {} + }, + typeKey: 'kind' + }) + const ok1 = { kind: 'someType', x: 'a', n: 1 } + const ok2 = { kind: 'typeWithNoExtraProps', n: 2 } +``` + ## Validating Data Schema is compiled into a validator that can be used to efficiently validate data. When compiling a schema, a name must be provided. The name should diff --git a/package.json b/package.json index 6fdd9b9..f62b91a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pocketgems/schema", - "version": "0.1.3", + "version": "0.1.4", "main": "src/schema.js", "license": "Apache-2.0", "scripts": { diff --git a/src/schema.js b/src/schema.js index d1f2a4d..89211d8 100644 --- a/src/schema.js +++ b/src/schema.js @@ -272,7 +272,7 @@ class BaseSchema { assert.ok(name, 'name is required') if (!compiler) { if (!ajv) { - ajv = new (require('ajv'))({ + ajv = new (require('ajv/dist/2020'))({ allErrors: true, useDefaults: true, strictSchema: false @@ -441,6 +441,90 @@ class ObjectSchema extends BaseSchema { } } +/** + * A polymorphic type. + * + * The properties it contains will be based on its type. The type is determined + * by a specific property (by default named "type"). + */ +class PolymorphicObjectSchema extends ObjectSchema { + static JSON_SCHEMA_TYPE = 'object' + static MAX_PROP_NAME = 'maxProperties' + static MIN_PROP_NAME = 'minProperties' + + /** + * Creates an object schema object. + * @param {Object} [commonProps={}] Keys must be strings, values must be + * schema objects. Passed directly to the S.obj() constructor. + * @param {Object} [typeNameToObj={}] Keys are strings which are valid type + * names for this polymorphic object. Values are S.obj. + * @param {String} [typeKey='type'] The name of the property which + * determines which type of data this object has (all objects will have + * this property in addition to what's in commonProps). + */ + constructor (commonProps = {}, typeNameToObj, typeKey) { + super(commonProps) + if (typeNameToObj === undefined) { + return this // copy() will set up the rest + } + this.__allowedTypeNames = Object.keys(typeNameToObj) + this.__allowedTypeNames.sort() + assert.ok(this.__allowedTypeNames.length > 0, 'must have at least one type') + this.prop(typeKey, S.str.enum(this.__allowedTypeNames)) + this.typeKey = typeKey + this.typeNameToExtraProps = {} + for (const typeName of this.__allowedTypeNames) { + let objSchema = typeNameToObj[typeName] + // propSchema must be an S.obj (not S.polymorphicObj) or a plain old + // javascript object that can be turned into an S.obj + if (typeof objSchema === 'object') { + objSchema = S.obj(objSchema) + } + assert.ok(Object.getPrototypeOf(objSchema) === ObjectSchema.prototype, + 'polymorphic object sub-types must be S.obj') + objSchema.lock() + const { type, ...relevantProperties } = objSchema.properties() + // additional properties constraint is enforced (or not) by the root + // polymorphic object only (ignored on sub-types) + delete relevantProperties.additionalProperties + if (Object.keys(relevantProperties).length > 0) { + this.typeNameToExtraProps[typeName] = relevantProperties + } + } + } + + copy () { + const ret = super.copy() + ret.__allowedTypeNames = deepcopy(this.__allowedTypeNames) + ret.typeKey = this.typeKey + ret.typeNameToExtraProps = deepcopy(this.typeNameToExtraProps) + return ret + } + + properties () { + const ret = super.properties() + let cur = ret + for (const typeName of this.__allowedTypeNames) { + const extraProps = this.typeNameToExtraProps[typeName] + if (!extraProps) { + continue + } + if (cur.if) { + cur.else = {} + cur = cur.else + } + cur.if = { properties: { [this.typeKey]: { const: typeName } } } + cur.then = extraProps + } + // when using applicator keywords like "if" we need to use + // unevaluatedProperties instead of additionalProperties (because the check + // needs to happen after the applicators have finalized the properties) + ret.unevaluatedProperties = ret.additionalProperties + delete ret.additionalProperties + return ret + } +} + /** * The ArraySchema class. */ @@ -751,7 +835,7 @@ class JSONSchemaExporter { export (schema) { const ret = deepcopy(schema.export(this)) - ret.$schema = 'http://json-schema.org/draft-07/schema#' + ret.$schema = 'https://json-schema.org/draft/2020-12/schema' return ret } } @@ -768,6 +852,14 @@ class S { */ static obj (object) { return new ObjectSchema(object) } + /** + * @param {Object} object See {@link ObjectSchema#constructor} + * @return A new ObjectSchema object. + */ + static polymorphicObj ({ commonProps = {}, typeNameToObj = {}, typeKey = 'type' } = {}) { + return new PolymorphicObjectSchema(commonProps, typeNameToObj, typeKey) + } + /** * @param {BaseSchema} schema See {@link ArraySchema#constructor} * @return A new ArraySchema object. diff --git a/test/unit-test-schema.js b/test/unit-test-schema.js index 409233e..c8a5795 100644 --- a/test/unit-test-schema.js +++ b/test/unit-test-schema.js @@ -1,7 +1,7 @@ const assert = require('assert') const { BaseTest, runTests } = require('@pocketgems/unit-test') -const ajv = new (require('ajv'))({ allErrors: true }) +const ajv = new (require('ajv/dist/2020'))({ allErrors: true }) const FS = require('fluent-schema') const S = require('../src/schema') @@ -110,7 +110,9 @@ class ProxySchema { } verify () { - expect(this.fs.valueOf()).toStrictEqual(this.s.jsonSchema()) + const fsValue = this.fs.valueOf() + fsValue.$schema = 'https://json-schema.org/draft/2020-12/schema' + expect(fsValue).toStrictEqual(this.s.jsonSchema()) } } @@ -262,6 +264,16 @@ class ValidationTest extends BaseTest { }).toThrow(/must be an integer/) } + testPolymorphicObject () { + expect(() => { + S.polymorphicObj() + }).toThrow(/must have at least one type/) + + expect(() => { + S.polymorphicObj({ typeNameToObj: { x: 1 } }) + }).toThrow(/object sub-types must be S.obj/) + } + testArray () { expect(() => { S.arr().min(0.2) @@ -334,7 +346,7 @@ class TypedNumberTest extends BaseTest { * for ajv compiler */ testFloatExplicitCompiler () { - const ajv = new (require('ajv'))({ allErrors: true, useDefaults: true }) + const ajv = new (require('ajv/dist/2020'))({ allErrors: true, useDefaults: true }) const obj = S.obj({ float: S.double.asFloat() }) @@ -774,4 +786,183 @@ into **one** string`) } } -runTests(FeatureParityTest, TypedNumberTest, ValidationTest, NewFeatureTest) +class PolymorphicObjectTest extends BaseTest { + testGeneratedSchemaAndValidator () { + const schema = S.polymorphicObj({ + commonProps: { + c1: S.int, + c2: S.str + }, + typeNameToObj: { + someType: { x: S.str }, + anotherType: { x: S.int, y: S.str } + } + }) + const jsonSchema = schema.jsonSchema() + delete jsonSchema.$schema + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + c1: { type: 'integer' }, + c2: { type: 'string' }, + type: { type: 'string', enum: ['anotherType', 'someType'] } + }, + required: ['c1', 'c2', 'type'], + unevaluatedProperties: false, + if: { + properties: { type: { const: 'anotherType' } } + }, + then: { + properties: { x: { type: 'integer' }, y: { type: 'string' } }, + required: ['x', 'y'] + }, + else: { + if: { + properties: { type: { const: 'someType' } } + }, + then: { + properties: { x: { type: 'string' } }, + required: ['x'] + } + } + }) + + // check validating + const validate = schema.compile('testGeneratedSchemaAndValidator') + const someObj = { c1: 1, c2: 'x', type: 'someType', x: 'a' } + expect(validate(someObj)) + const anotherObj = { c1: 1, c2: 'x', type: 'anotherType', x: 1, y: 'xx' } + expect(validate(anotherObj)) + const bad = { ...anotherObj, x: 'a' } // nope, needs to be an int here + expect(() => validate(bad)).toThrow(S.ValidationError) + + // can't use an unknown sub-type + const unknownType = { ...someObj, type: 'notSomeType' } + expect(() => validate(unknownType)).toThrow(S.ValidationError) + + // copy should work too + const schemaCopy = schema.copy() + expect(schema.jsonSchema()).toEqual(schemaCopy.jsonSchema()) + const validateCopy = schemaCopy.compile( + 'copy of testGeneratedSchemaAndValidator') + validateCopy(someObj) + } + + testSubTypeWithNoExtraProperties () { + const schema = S.polymorphicObj({ + commonProps: { c1: S.int }, + typeNameToObj: { + someType: { x: S.str }, + typeWithNoExtraProps: {} + } + }) + const jsonSchema = schema.jsonSchema() + delete jsonSchema.$schema + expect(jsonSchema).toEqual({ + type: 'object', + properties: { + c1: { type: 'integer' }, + type: { type: 'string', enum: ['someType', 'typeWithNoExtraProps'] } + }, + required: ['c1', 'type'], + unevaluatedProperties: false, + if: { + properties: { type: { const: 'someType' } } + }, + then: { + properties: { x: { type: 'string' } }, + required: ['x'] + } + }) + + // check validating + const validate = schema.compile('testSubTypeWithNoExtraProperties') + const someObj = { c1: 1, type: 'someType', x: 'a' } + expect(validate(someObj)) + const anotherObj = { c1: 1, type: 'typeWithNoExtraProps' } + expect(validate(anotherObj)) + const bad = { ...anotherObj, x: 'a' } // nope, needs to be an int here + expect(() => validate(bad)).toThrow(S.ValidationError) + + // copy should work too + const schemaCopy = schema.copy() + expect(schema.jsonSchema()).toEqual(schemaCopy.jsonSchema()) + const validateCopy = schemaCopy.compile( + 'copy of testSubTypeWithNoExtraProperties') + validateCopy(someObj) + } + + testNoCommonProps () { + const schema = S.polymorphicObj({ + typeNameToObj: { + someType: { x: S.str }, + typeWithNoExtraProps: {} + } + }) + + const validate = schema.compile('testNoCommonProps') + const someObj = { type: 'someType', x: 'a' } + expect(validate(someObj)) + const anotherObj = { type: 'typeWithNoExtraProps' } + expect(validate(anotherObj)) + const bad = { ...anotherObj, x: 'a' } // nope, needs to be an int here + expect(() => validate(bad)).toThrow(S.ValidationError) + } + + testCustomTypeField () { + // exPolymorphicObj start + const schema = S.polymorphicObj({ + commonProps: { n: S.int }, + typeNameToObj: { + someType: { x: S.str }, + typeWithNoExtraProps: {} + }, + typeKey: 'kind' + }) + const ok1 = { kind: 'someType', x: 'a', n: 1 } + const ok2 = { kind: 'typeWithNoExtraProps', n: 2 } + // exPolymorphicObj end + + const validate = schema.compile('testNoCommonProps') + expect(validate(ok1)) + expect(validate(ok2)) + const bad = { ...ok2, x: 'a' } // nope, needs to be an int here + expect(() => validate(bad)).toThrow(S.ValidationError) + } + + testNestedPolymorphism () { + const schema = S.polymorphicObj({ + commonProps: { c: S.bool }, + typeNameToObj: { + someType: { + x: S.str, + listOfStuff: S.arr(S.polymorphicObj({ + commonProps: { c1: S.int }, + typeNameToObj: { + nested1: { x: S.int }, + nested2: { y: S.bool } + } + })) + }, + typeWithNoExtraProps: {} + } + }) + + const validate = schema.compile('testNestedPolymorphism') + const anotherObj = { type: 'typeWithNoExtraProps', c: true } + expect(validate(anotherObj)) + + const someObj = { type: 'someType', x: 'a', listOfStuff: [], c: false } + expect(validate(someObj)) + someObj.listOfStuff.push({ c1: 0, type: 'nested1', x: 1 }) + expect(validate(someObj)) + someObj.listOfStuff.push({ c1: 0, type: 'nested2', y: true }) + expect(validate(someObj)) + + someObj.listOfStuff.push({ c1: 0, type: 'nested2' }) + expect(() => validate(someObj)).toThrow(S.ValidationError) + } +} + +runTests(FeatureParityTest, TypedNumberTest, ValidationTest, NewFeatureTest, + PolymorphicObjectTest)