Schema Compilation
The Schema class is only used for schema definition. They act as a "builder" with a fluent API,
and must be compiled into CompiledSchema instances to be usable for runtime processing.
Compilation recursively processes each schema in the hierarchy:
- Flatten inherited base schemas, resolving named schemas using the
SchemaResolver. - Normalize defaults and values.
- Synthesize implicit union discriminators.
- Synthesize conditions for selectors.
- Convert each handler's value processor specs into functions, resolving named keywords using the
SchemaResolver. - Render value descriptions to metadata.
- Perform consistency checks.
As a "dogfooding" exercise to demonstrate the expressiveness
of the library, compilation is itself implemented as a schema1 that converts an input Schema to an output CompiledSchema!
Compilation Cache
Each schema compiled is cached2 both for efficiency as well as enabling circular definitions (e.g. "a folder is a list of files or folders"). However, because every inherited schema is flattened, two identical-looking properties are separate instances that can't take advantage of the caching:
import { Schema, SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();
function compare(name, schema) {
console.log(`${name} child schemas equal: ${schema.getPropertySchema('src') === schema.getPropertySchema('dst')}`)
}
// src and dst are compiled separately, including the string base schemas:
const schema1 = await resolver.compile(
new Schema('object')
.property('src', new Schema('string').validator('$uuid'))
.property('dst', new Schema('string').validator('$uuid'))
)
compare('schema1', schema1); // -> schema1 child schemas equal: false
// whereas here the common schema is only compiled once:
const uuidSchema = new Schema('string').validator('$uuid');
const schema2 = await resolver.compile(
new Schema('object')
.property('src', uuidSchema)
.property('dst', uuidSchema)
);
compare('schema2', schema2); // -> schema2 child schemas equal: true
// it would be nice if this was equally efficient, but we're back to separate instances:
resolver.registerSchema('my-uuid', uuidSchema);
const schema3 = await resolver.compile(
new Schema('object')
.property('src', new Schema('my-uuid'))
.property('dst', new Schema('my-uuid'))
);
compare('schema3', schema3); // -> schema3 child schemas equal: false
Name Resolution
The SchemaResolver is a key component in the compilation process, resolving both schema names and value processor keywords
from its internal registries. It also handles recursively flattening base classes via its resolve() method.
import { Schema, SchemaResolver } from '@versionzero/schema';
const resolver = new SchemaResolver();
const n = new Schema('number').validator('$positive');
// Here's the simple data representation of a Schema:
console.log( n.toData() );
/*
{ base: 'number', handlers: { validators: [ '$positive' ] } }
*/
// After resolution, the schema has been flattened:
console.log( resolver.resolve(n).toData() );
/*
{
metadata: { valueName: 'number', parserTypeHint: 'number' },
options: { type: 'number' },
handlers: {
validators: [ '$is-number', '$positive' ],
normalizers: [ '$number' ]
}
}
*/
// After compilation, the schema resolves value processors and keywords
// into functions, but it preserves the original names so that the
// CompiledSchema can provide a compatible data shape for use as a
// base schema:
console.log( (await resolver.compile(n)).toData() );
/*
{
metadata: {
valueName: 'number',
parserTypeHint: 'number',
validatorDescription: 'positive',
valueDescription: '[positive]'
},
options: { type: 'number' },
handlers: {
validators: [ '$is-number', '$positive' ],
normalizers: [ '$number' ]
}
}
*/