Deriving schemas
An entity is rarely the shape every boundary of a program takes: the body that creates a record has no id, the body that updates one has every field optional, the view of it leaves the password out. The @alexify/metaschema/derive subpath makes those schemas from the one that describes the record, so a rule written once (a length, an enum, a reference) holds for all of them.
const { Model } = require('@alexify/metaschema');
const { omit, partial, pick } = require('@alexify/metaschema/derive');
const model = new Model({}, [
['Company', { Entity: {}, name: 'string' }],
[
'Account',
{
Entity: {},
login: { type: 'string', length: { min: 3 } },
password: { type: 'string', length: { min: 8 } },
role: { enum: ['admin', 'user'], default: 'user' },
company: 'Company',
address: { city: 'string', street: 'string' },
byLogin: { unique: ['login'] },
},
],
]);
const Account = model.entities.get('Account');
const CreateAccount = omit(Account, ['password'], { name: 'CreateAccount' });
const UpdateAccount = partial(CreateAccount, { name: 'UpdateAccount' });
const AccountView = pick(Account, ['accountId', 'login', 'role'], { name: 'AccountView' });
UpdateAccount.parse({ role: 'admin' });
// { valid: true, value: { role: 'admin' }, issues: [] }Each function builds a new Schema from the definition the source was built from: the source is never changed, and every rule of a field comes along (its type, length, pattern, enum, aliases, codecs, annotations). The functions take a schema of a struct; anything else is a TypeError with code: 'ERR_INVALID_TARGET'.
pick and omit
pick(schema, keys, options) keeps the fields named, omit(schema, keys, options) every field but those. The keys are field names as the schema has them ('nick', not 'nick?'), a calculated field included; the fields come in the order of the schema, whatever the order of the keys. A name the schema has no field of is a SchemaDefinitionError with code: 'ERR_UNKNOWN_FIELD', located in the source:
pick(Account, ['email']);
// SchemaDefinitionError: No such field in "Account.email"partial and required
partial(schema, options) makes every field optional, required(schema, options) every field required; options.keys names the fields to change instead.
A field made optional loses its default: partial is the shape of a patch, and a key a patch leaves out stays as it is stored, where filling in a default would overwrite it. defaults: true keeps them. required keeps the defaults: check asks for the key, parse fills it.
const Patch = partial(Account, { keys: ['password', 'role'] });
Patch.fields.role.default; // undefined
partial(Account, { defaults: true }).parse({}).value; // { role: 'user' }partial is shallow: a nested struct given is checked whole.
UpdateAccount.check({ address: { city: 'Lviv' } }).errors;
// [ 'Field "UpdateAccount.address.street" is required' ]
partial(CreateAccount, { deep: true }).check({ address: { city: 'Lviv' } }).valid;
// truedeep: true goes into nested structs at every depth, in each form a field holds one (a nested definition, { schema: … }, a Schema as a field). It does not go into the elements of a collection, a tuple, the branches of a union or a referenced entity: a patch replaces a list whole, and an element of it must be a whole record; a referenced entity is its own schema. A nested struct made optional by deep loses its own validate, parse and serialize, which were written for the whole struct.
extend
extend(schema, extension, options) puts fields over the schema's. A field of the same name takes the place of the schema's field, in its position, with its whole definition ('company?' replaces company); the other fields come after.
const Signup = extend(CreateAccount, { 'company?': 'Company', terms: 'boolean' });
Object.keys(Signup.fields);
// [ 'login', 'role', 'company', 'address', 'terms' ]
Signup.fields.company.required; // falseThe extension is an object of fields, or a Schema of a struct, whose fields, indexes and own functions come along (not its kind, nor the id of a stored kind). An extension with a kind as its first key is ERR_INVALID_DEFINITION: the kind of the result is options.metadata.
extend replaces; merge of ./model unifies (a field in both takes the constraints of both, and what no value satisfies is a conflict). Use extend to reshape a schema, merge to combine two descriptions of one value.
keyof
keyof(schema, options) is a schema of the names of the fields a value carries, as an enum: what a sort or a fields parameter of a query takes. The calculated fields and the id of a stored kind are not among them.
const { Schema } = require('@alexify/metaschema');
const { keyof } = require('@alexify/metaschema/derive');
const Query = new Schema('Query', { sort: keyof(Account), 'limit?': 'integer' });
Query.check({ sort: 'email' }).errors;
// [ 'Field "Query.sort" value is not of enum: login, password, role, company, address' ]The kind and the id
A schema derived from a stored kind (an Entity, a Registry, …) or from a Projection is a Struct: a stored kind would add its id field again over the fields derived, and a projection would copy its parent's fields over them. The id field of the stored kind goes with it, unless the call names it (pick(Account, ['accountId', …]) keeps it as the optional string it was, partial(…, { keys: ['accountId'] }) too). The unknown policy stays. A memory kind (Struct, Form, a kind of your own) keeps its kind and all its metadata.
options.metadata puts metadata over that, as merge does: { metadata: { kind: 'entity' } } makes the result a stored kind again, with the id of its own name.
The schema's own functions
The validate, parse, serialize and format of the schema itself were written for the whole value, and may read a field a derived schema no longer has, or has as optional: pick, omit and partial leave them out, required and extend keep them. An extension puts one back: extend(pick(Account, ['login', 'password']), { validate }). The codecs and the validate of a field are the field's own and always come along.
Indexes, names and models
An index of the source stays when every column of it is a field of the result, so a derived schema has no missing-index-field warning; a many field records its relation again by itself. The references and relations are those of the fields that stay.
The name of a derived schema is the source's unless options.name, so its error lines keep the entity's label. A Model takes a Schema only under its own name: give name to put a derived schema in a model.
const api = new Model({}, [['CreateAccount', CreateAccount]]);The namespaces are the source's (and the extension's), so references and the custom types of an isolated registry resolve as they did. A derived schema is an ordinary schema: its snapshot is a Struct with its fields, it compiles with ./compile like any other, and the ./stream functions take it.
Methods
register() installs the six as methods of Schema, not enumerable, so they chain:
const derive = require('@alexify/metaschema/derive');
derive.register();
const AccountPatch = Account.omit(['password']).partial({ name: 'AccountPatch' });Importing the subpath registers nothing; register() runs once per process.
Derive once, where the schemas are built, not in a request handler: every call builds a whole Schema (omit([...]).partial() builds two), and under register({ jit }) of ./compile each of them is compiled by the JIT backend as it is built. The lint is lazy and costs nothing until warnings is read.
TypeScript
Each function is typed by the definition: InferSchema of a derived schema is the value it accepts, a key the schema has no field of is a compile error, and a schema of an unknown definition (model.entities.get('Account')) gives a Schema of an unknown one.
const view = pick(account, ['login', 'nick']);
type View = InferSchema<typeof view>; // { login: string; nick?: string | null | undefined }
pick(account, ['nmae']); // errorThe id of a stored kind is not in the static type (as with Infer): a definition does not carry the entity's name.
Boundaries
- The keys are field names, not paths:
pick(Account, ['address.city'])is an unknown field. Give the nested struct a Schema of its own and derive from that, or extend with a new definition of the field. deepstops at collections, tuples, unions and references (above).- A field replaced by
extendtakes the whole new definition: its old rules, aliases and annotations do not come along. - A partial schema describes a patch; it cannot check what depends on the stored record (a length after a merge, a rule across fields). Check the record after applying the patch.
- MongoDB updates name fields by dotted paths and replace an embedded document whole: a deep partial is the shape of a JSON merge patch, not of
$set.