diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d2b3f..e3ca60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,24 @@ ## [Unreleased][unreleased] - +- FS utilities [issue](https://github.com/astrohelm/astroplan/issues/9) +- Plan generation from sample [issue](https://github.com/astrohelm/astroplan/issues/10) +- DOCS & Typings & JSDoc [issue](https://github.com/astrohelm/astroplan/issues/11) --> + +## [0.3.0][] - 2023-10-19 + +- Schema child method, now you can update only schema or only options for new schemas +- Metadata fetcher, now you can gather all metadata by schema.meta +- Fixed case when schema returns only methods without metadata, now it returns prototypes key with + all metadata +- Preprocess & postprocess for objects properties mutations +- New prototype enum +- Code cleanup & tests -## [0.2.0][] - 2023-10-1\* +## [0.2.0][] - 2023-10-18 - Renamed astroplan -> metaforge - Meta information & Builded tree export [issue](https://github.com/astrohelm/astroplan/issues/8) @@ -29,6 +38,7 @@ - Default exotic types: Any, Undefined, JSON - Custom Errors -[unreleased]: https://github.com/astrohelm/workspace/compare/v0.2.0...HEAD -[0.2.0]: https://github.com/astrohelm/workspace/releases/tag/v0.2.0 -[0.1.0]: https://github.com/astrohelm/workspace/releases/tag/release +[unreleased]: https://github.com/astrohelm/metaforge/compare/v0.2.0...HEAD +[0.3.0]: https://github.com/astrohelm/metaforge/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/astrohelm/metaforge/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/astrohelm/metaforge/releases/tag/v0.1.0 diff --git a/README.md b/README.md index c414915..885b72a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

MetaForge v0.2.0 🕵️

+

MetaForge v0.3.0 🕵️

Copyright © 2023 Astrohelm contributors. diff --git a/lib/builder/index.js b/lib/builder/index.js index 9e25ff1..674c5e8 100644 --- a/lib/builder/index.js +++ b/lib/builder/index.js @@ -1,28 +1,21 @@ 'use strict'; const preprocess = require('./preprocess'); + +//? [Preprocess] Purpose: find, join and build prototype by plan module.exports = (types, tools, plan) => { const prototypes = preprocess(types, tools, plan); if (prototypes.length === 1) return prototypes[0]; if (!prototypes.length) return { test: () => [], ts: () => 'unknown', debug: 'Check warnings' }; - const { condition = 'anyof' } = plan; - // TODO: assign Meta information + const { condition: conditionName = 'anyof' } = plan; + return { + prototypes, type: () => 'object', test: (sample, path = 'root') => { - const handler = tools.conditions(condition, prototypes.length - 1); - const errors = []; - for (let i = 0; i < prototypes.length; ++i) { - const result = prototypes[i].test(sample, path); - const [toDo, err] = handler(result, i); - if (err) { - if (result.length) errors.push(...result); - else errors.push(`[${path}] => ${err}: ${JSON.stringify(sample)}`); - } - if (toDo === 'skip' || toDo === 'break') break; - if (toDo === 'continue') continue; - } - return errors; + const createError = cause => new tools.Error({ cause, path, sample }); + const condition = tools.getCondition(conditionName, prototypes.length - 1); + return tools.runCondition(condition, { path, sample, createError, prototypes }); }, }; }; diff --git a/lib/builder/preprocess.js b/lib/builder/preprocess.js index f5c6719..605000e 100644 --- a/lib/builder/preprocess.js +++ b/lib/builder/preprocess.js @@ -1,26 +1,26 @@ 'use strict'; const path = 'PREPROCESS'; -const { string } = require('astropack'); +const { string: astropack } = require('astropack'); const { typeOf, isShorthand } = require('../schema/tools'); +const ERR_SHORTHAND = 'Shorthand usage with non-scalar schema'; +const ERR_MISSING_PROTO = 'Missing prototype'; +const ERR_MISSING_SCHEMA = 'Missing or wrong schema at namespace'; module.exports = ({ types, namespace }, tools, schema) => { const [prototypes, { warn }] = [[], tools]; const signal = (cause, sample, sampleType) => warn({ sample, sampleType, path, cause }); for (const type of typeOf(schema)) { - if (string.case.isFirstUpper(type)) { + if (astropack.case.isFirstUpper(type)) { const prototype = namespace.get(type); if (prototype && prototype.test) prototypes.push(prototype); - else signal('Missing or wrong schema at namespace', namespace, type); + else signal(ERR_MISSING_SCHEMA, namespace, type); continue; } const Type = types.get(type); - if (!Type) { - signal('Missing prototype', schema, type); - continue; - } - if (Type.kind !== 'scalar' && isShorthand(schema)) { - signal('Shorthand usage with non-scalar schema', schema, type); + if (!Type || ((Type.kind !== 'scalar' || type === 'enum') && isShorthand(schema))) { + if (!Type) signal(ERR_MISSING_PROTO, schema, type); + else signal(ERR_SHORTHAND, schema, type); continue; } const prototype = new Type(schema, tools); @@ -28,5 +28,3 @@ module.exports = ({ types, namespace }, tools, schema) => { } return prototypes; }; - -// sql, input, runtime check, core, rpc protocol diff --git a/lib/parser/index.js b/lib/parser/index.js new file mode 100644 index 0000000..e25ebe0 --- /dev/null +++ b/lib/parser/index.js @@ -0,0 +1,19 @@ +'use strict'; + +const SKIP = ['undefined', 'function', 'symbol']; +const ParseStorage = require('./store'); + +const createParser = store => { + const parser = sample => { + const type = Array.isArray(sample) ? 'array' : typeof sample; + if (SKIP.includes(type) || (type === 'object' && !sample)) return '?unknown'; + for (const [, parse] of store[type]) { + const result = parse(sample, parser); + if (result) return result; + } + return '?unknown'; + }; + return parser; +}; + +module.exports = { createParser, ParseStorage }; diff --git a/lib/parser/store.js b/lib/parser/store.js new file mode 100644 index 0000000..5fdb037 --- /dev/null +++ b/lib/parser/store.js @@ -0,0 +1,39 @@ +'use strict'; + +const STANDARD = ['string', 'number', 'bigint', 'boolean', 'object', 'array']; +function TypedParseStorage(name, prototype) { + const handlers = new Map(); + this.toJSON = () => handlers; + this.toString = () => JSON.stringify(handlers); + this[Symbol.iterator] = function* () { + yield* handlers.entries(); + yield [name, prototype]; + }; + + return new Proxy(this, { + ownKeys: () => handlers.keys(), + has: (_, prop) => handlers.has(prop), + set: (_, prop, value) => handlers.set(prop, value), + deleteProperty: (_, prop) => handlers.delete(prop), + get: (target, prop) => { + if (prop === Symbol.iterator) return this[Symbol.iterator].bind(target); + return handlers.get(prop); + }, + }); +} + +module.exports = function ParseStorage(argTypes) { + const types = new Map([...argTypes]); + const store = STANDARD.reduce((acc, name) => { + acc[name] = new TypedParseStorage(name, types.get(name).parse); + types.delete(name); + return acc; + }, {}); + + for (const [name, Type] of types.entries()) { + if (!Type.parse) continue; + for (const type of Type.parse.targets) store[type][name] = Type.parse; + } + + return types.clear(), Object.freeze(Object.assign(this, store)); +}; diff --git a/lib/proto/arrays.js b/lib/proto/arrays.js deleted file mode 100644 index dd888d5..0000000 --- a/lib/proto/arrays.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; - -const MISS = 'Data type missconfiguration, expexted: '; -const EMPTY = 'Empty list reviced, but required'; -const struct = isInstance => ({ - meta: { kind: 'struct', subtype: 'array' }, - construct(plan, tools) { - const { Error, build, conditions } = tools; - this.required = plan.required ?? true; - this.items = plan.items.map(v => build(v)); - this.condition = plan.condition ?? 'anyof'; - this.type = plan.type; - - this.ts = () => 'object'; - this.test = (sample, path) => { - const err = cause => new Error({ path, sample, plan, cause }); - if (!isInstance(sample)) return [err(MISS + this.type)]; - const entries = [...sample]; - if (!entries.length && this.required) return [err(EMPTY)]; - const errors = []; - point: for (let i = 0; i < entries.length; ++i) { - const handler = conditions(this.condition, this.items.length - 1); - const err = cause => new Error({ path: `[${path}[${i}]]`, sample, plan, cause }); - for (let j = 0; j < this.items.length; ++j) { - const result = this.items[j].test(entries[i], `${path}[${i}]`); - const [toDo, error] = handler(result, j); - if (error) { - if (result.length) errors.push(...result); - else errors.push(err(error + `: ${JSON.stringify(entries[i])}`)); - } - if (toDo === 'break') break; - if (toDo === 'continue') continue; - if (toDo === 'skip') continue point; - } - } - return errors; - }; - }, -}); - -module.exports = { - array: struct(value => Array.isArray(value)), - set: struct(value => value?.constructor?.name === 'Set'), -}; diff --git a/lib/proto/arrays/index.js b/lib/proto/arrays/index.js new file mode 100644 index 0000000..9a163e0 --- /dev/null +++ b/lib/proto/arrays/index.js @@ -0,0 +1,22 @@ +'use strict'; + +const astropack = require('astropack'); +const struct = require('./struct'); + +const parse = (sample, parse) => { + if (!Array.isArray(sample)) return null; + const items = sample.reduce((acc, sample) => { + const plan = parse(sample); + for (const saved of acc) if (astropack.utils.equals(saved, plan)) return acc; + return acc.push(plan), acc; + }, []); + if (items.length === 0) return { type: 'array', items: ['unknown'] }; + if (items.length === 1) return { type: 'array', items: [items[0]] }; + return { type: 'array', items }; +}; + +module.exports = { + array: struct(value => Array.isArray(value)), + set: struct(value => value?.constructor?.name === 'Set'), +}; +module.exports.array.parse = Object.assign(parse, { targets: ['object'] }); diff --git a/lib/proto/arrays/struct.js b/lib/proto/arrays/struct.js new file mode 100644 index 0000000..93f26af --- /dev/null +++ b/lib/proto/arrays/struct.js @@ -0,0 +1,31 @@ +'use strict'; + +const ERR_MISS = 'Data type missconfiguration, expected: '; +const ERR_EMPTY = 'Empty list recived, but required'; +module.exports = isInstance => ({ + meta: { kind: 'struct', origin: 'default' }, + construct(plan, tools) { + const { Error, build, getCondition } = tools; + this.required = plan.required ?? true; + this.items = plan.items.map(v => build(v)); + this.condition = plan.condition ?? 'anyof'; + + this.ts = () => 'object'; + this.test = (sample, path) => { + const createError = cause => new Error({ path, sample, plan, cause }); + if (!isInstance(sample)) return [createError(ERR_MISS + this.type)]; + const entries = [...sample]; + if (!entries.length && this.required) return [createError(ERR_EMPTY)]; + const errors = []; + for (let i = 0; i < entries.length; ++i) { + const condition = getCondition(this.condition, this.items.length - 1); + const suboption = { path: `${path}[${i}]`, sample: entries[i] }; + const createError = cause => new tools.Error({ cause, plan, ...suboption }); + const option = { createError, prototypes: this.items, ...suboption }; + const result = tools.runCondition(condition, option); + if (result.length) errors.push(...result); + } + return errors; + }; + }, +}); diff --git a/lib/proto/constructor/index.js b/lib/proto/constructor/index.js new file mode 100644 index 0000000..7d543e1 --- /dev/null +++ b/lib/proto/constructor/index.js @@ -0,0 +1,35 @@ +'use strict'; + +const defaultTest = () => []; +const INVALID = 'Invalid prototype, missing construct method'; +const TEST_FAIL = `Didn't pass test`; +const RULE_FAIL = `Didn't pass rule: `; +const { parseResult: parse, setDefault } = require('./utils'); + +//? [Pre-preprocess] Purpose: Prototype wrapper and tester +module.exports = (name, proto, defaultMeta) => { + if (!proto?.construct || typeof proto.construct !== 'function') throw new Error(INVALID); + const meta = { type: name }; + function Type(plan, tools) { + Object.assign(this, meta); + if (plan.meta) Object.assign(this, plan.meta); + proto.construct.call(this, plan, tools), setDefault(this, plan, tools); + + const [test, rules] = [this.test ?? defaultTest, plan.rules ?? []]; + this.test = (sample, path = 'root') => { + const options = { sample, path, tools, plan }; + const errors = parse(test(sample, path), options, TEST_FAIL); + if (rules.length === 0) return errors; + for (let i = 0; i < rules.length; ++i) { + const result = parse(rules[i](sample), options, RULE_FAIL + i); + if (result.length > 0) errors.push(...result); + } + return errors; + }; + return Object.freeze(this); + } + if (proto.meta) Object.assign(meta, proto.meta); + if (defaultMeta) Object.assign(meta, defaultMeta); + if (proto.parse && Array.isArray(proto.parse.targets)) Type.parse = proto.parse; + return Object.assign(Type, meta); +}; diff --git a/lib/proto/constructor/utils.js b/lib/proto/constructor/utils.js new file mode 100644 index 0000000..f560f8e --- /dev/null +++ b/lib/proto/constructor/utils.js @@ -0,0 +1,23 @@ +'use strict'; + +const parseResult = (res, options, cause) => { + const { sample, path, plan, tools } = options; + if (typeof res === 'object' && Array.isArray(res)) { + if (!res.length || res[0] instanceof tools.Error) return res; + return res.map(v => parseResult(v, options)).flat(2); + } + if (typeof res === 'boolean' && !res) return [new tools.Error({ sample, path, plan, cause })]; + if (typeof res === 'string') return [new tools.Error({ sample, path, plan, cause: res })]; + if (res instanceof tools.Error) return [res]; + return []; +}; + +const setDefault = (ctx, plan, tools) => { + if (typeof plan.preprocess === 'function') ctx.preprocess = plan.preprocess; + if (typeof plan.postprocess === 'function') ctx.postprocess = plan.postprocess; + if (!('required' in ctx)) ctx.required = tools.isRequired(plan); + if (!ctx.origin) ctx.origin = 'custom'; + if (!ctx.type) ctx.type = 'unknown'; +}; + +module.exports = { parseResult, setDefault }; diff --git a/lib/proto/exotic.js b/lib/proto/exotic.js index 8085e94..f4721c4 100644 --- a/lib/proto/exotic.js +++ b/lib/proto/exotic.js @@ -1,33 +1,33 @@ 'use strict'; -const REQUIRED = 'Value is required'; -const any = type => ({ - meta: { kind: 'scalar', subtype: 'exotic' }, +const ERR_REQUIRED = 'Value is required'; +const ERR_MISS = 'Not of expected type: object'; +const META = { kind: 'scalar', origin: 'default' }; +const any = { + meta: META, construct(plan, tools) { const { isRequired, Error } = tools; - [this.type, this.required] = [type, isRequired(plan)]; + this.required = isRequired(plan); this.ts = () => this.type; this.test = (sample, path) => { if (!(!this.required && sample === undefined)) return []; - return [new Error({ path, sample, plan, cause: REQUIRED })]; + return [new Error({ path, sample, plan, cause: ERR_REQUIRED })]; }; }, -}); +}; const json = { - meta: { kind: 'struct', subtype: 'exotic' }, + meta: { kind: 'struct', origin: 'default' }, construct(plan, tools) { const { isRequired, Error } = tools; - [this.type, this.required] = ['json', isRequired(plan)]; + this.required = isRequired(plan); this.ts = () => 'object'; this.test = (sample, path) => { - if (!this.required && sample === undefined) return []; if (typeof sample === 'object' && sample) return []; - const err = cause => new Error({ path, sample, plan, cause }); - if (this.required && sample === undefined) return [err(REQUIRED)]; - return [err('Not of expected type: object')]; + if (!this.required && sample === undefined) return []; + return [new Error({ path, sample, plan, cause: ERR_MISS })]; }; }, }; -module.exports = { any: any('any'), unknown: any('unknown'), json }; +module.exports = { any, unknown: any, json }; diff --git a/lib/proto/index.js b/lib/proto/index.js index f927c9b..8873a30 100644 --- a/lib/proto/index.js +++ b/lib/proto/index.js @@ -1,58 +1,15 @@ 'use strict'; -const defaultTest = () => []; -const INVALID = 'Invalid prototype, missing construct method'; - -const parse = (res, options, cause) => { - const { sample, path, plan, tools } = options; - if (typeof res === 'object' && Array.isArray(res)) { - if (!res.length || res[0] instanceof tools.Error) return res; - return res.map(v => parse(v, options)).flat(2); - } - if (typeof res === 'boolean' && !res) return [new tools.Error({ sample, path, plan, cause })]; - if (typeof res === 'string') return [new tools.Error({ sample, path, plan, cause: res })]; - if (res instanceof tools.Error) return [res]; - return []; -}; - -const createType = (proto, defaultMeta) => { - if (!proto?.construct || typeof proto.construct !== 'function') throw new Error(INVALID); - const meta = {}; - if (proto.meta) Object.assign(meta, proto.meta); - if (defaultMeta) Object.assign(meta, defaultMeta); - function Type(plan, tools) { - proto.construct.call(this, plan, tools); - Object.assign(this, meta); - if (plan.meta) Object.assign(this, plan.meta); - if (!this.type) this.type = 'unknown'; - const test = this.test ?? defaultTest; - const rules = plan.rules ?? []; - this.test = (sample, path = 'root') => { - const options = { sample, path, tools, plan }; - const errors = parse(test(sample, path), options, `Didn't pass test`); - if (rules.length === 0) return errors; - for (let i = 0; i < rules.length; ++i) { - const result = parse(rules[i](sample), options, `Didn't pass rule[${i}]`); - if (result.length > 0) { - const err = result.map(cause => new tools.Error({ sample, path, cause, plan })); - errors.push(...err); - } - } - return errors; - }; - return Object.freeze(this); - } - return Object.assign(Type, meta); -}; +const createType = require('./constructor'); +const prototypes = Object.entries({ + ...require('./scalars'), + ...require('./objects/index.js'), + ...require('./arrays'), + ...require('./exotic'), +}); module.exports = { createType, - proto: new Map( - Object.entries({ - ...require('./scalars'), - ...require('./objects'), - ...require('./arrays'), - ...require('./exotic'), - }), - ), + deffaultPrototypes: new Map(prototypes), + defaultTypes: new Map(prototypes.map(([k, v]) => [k, createType(k, v)])), }; diff --git a/lib/proto/objects.js b/lib/proto/objects.js deleted file mode 100644 index 47cc496..0000000 --- a/lib/proto/objects.js +++ /dev/null @@ -1,71 +0,0 @@ -'use strict'; - -const MISS = 'Data type missconfiguration, expexted: '; -const EMPTY = 'Empty object reviced, but required'; -const search = (schema, prop) => { - if (!schema.properties) return [null, null]; - if (prop in schema.properties) return [schema.properties[prop], null]; - if (!schema.patternProperties) return [null, null]; - const entries = Object.entries(schema.patternProperties); - if (typeof prop !== 'string') prop = String(prop); - for (const [pattern, value] of entries) if (prop.match(pattern)) return [value, pattern]; - return [null, null]; -}; - -const struct = ({ isInstance, getEntries }) => ({ - meta: { kind: 'struct', subtype: 'hashmap' }, - construct(plan, tools) { - const { Error, isRequired, build } = tools; - const allowExotic = plan.allowExotic ?? false; - - const requires = new Map(); - const builded = { properties: {}, patternProperties: {} }; - for (const propType in builded) { - if (!plan[propType]) continue; - const entries = Object.entries(plan[propType]); - for (const [key, value] of entries) { - builded[propType][key] = build(value); - const required = isRequired(value); - if (required) requires.set(key); - } - this[propType] = Object.assign({}, builded[propType]); - } - - this.type = plan.type; - this.required = plan.required ?? true; - this.ts = () => 'object'; - this.test = (sample, path) => { - const err = cause => new Error({ path, sample, plan, cause }); - if (!this.required && sample === undefined) return []; - if (this.required && !sample) return [err(`Is required`)]; - if (!isInstance(sample)) return [err(MISS + this.type)]; - const entries = getEntries(sample); - if (!entries.length && this.required) return [err(EMPTY)]; - const errors = []; - for (const [prop, value] of entries) { - const err = cause => new Error({ path: `${path}.${prop}`, sample, plan, cause }); - const [prototype, schemaProp] = search(this, prop); - if (!prototype) { - if (!allowExotic) errors.push(err(`Exotic property`)); - continue; - } - requires.delete(schemaProp ?? prop); - const result = prototype.test(value, `${path}.${prop}`); - if (result.length) errors.push(...result); - } - if (requires.size) errors.push(err(`Missing fields "${[...requires.keys()].join()}"`)); - return errors; - }; - }, -}); - -module.exports = { - object: struct({ - isInstance: v => typeof v === 'object', - getEntries: v => Object.entries(v), - }), - map: struct({ - isInstance: v => v?.constructor?.name === 'Map', - getEntries: v => v.entries(), - }), -}; diff --git a/lib/proto/objects/index.js b/lib/proto/objects/index.js new file mode 100644 index 0000000..f077d58 --- /dev/null +++ b/lib/proto/objects/index.js @@ -0,0 +1,18 @@ +'use strict'; + +const struct = require('./struct'); + +module.exports = { + object: struct({ + isInstance: v => typeof v === 'object', + getKeys: v => Object.keys(v), + getValue: (k, target) => target[k], + setValue: (k, v, target) => (target[k] = v), + }), + map: struct({ + isInstance: v => v?.constructor?.name === 'Map', + getKeys: v => [...v.keys()], + getValue: (k, target) => target.get(k), + setValue: (k, v, target) => target.set(k, v), + }), +}; diff --git a/lib/proto/objects/struct.js b/lib/proto/objects/struct.js new file mode 100644 index 0000000..b9daa7d --- /dev/null +++ b/lib/proto/objects/struct.js @@ -0,0 +1,49 @@ +'use strict'; + +const { process, search, parser, buildProps } = require('./utils'); +const ERR_MISS = 'Data type missconfiguration, expexted: '; +const ERR_EMPTY = 'Empty object reviced, but required'; +const ERR_REQUIRED = `Properties and value required`; + +module.exports = protoTools => ({ + meta: { kind: 'struct', origin: 'default' }, + parse: Object.assign(parser(protoTools), { targets: ['object'] }), + construct(plan, tools) { + const { Error } = tools; + const allowExotic = plan.allowExotic ?? false; + const requires = buildProps(this, plan, tools); + + this.required = plan.required ?? true; + this.ts = () => 'object'; + this.test = (sample, path) => { + const createError = cause => new Error({ path, sample, plan, cause }); + if (!this.required && sample === undefined) return []; + if (this.required && sample === undefined) return [createError(ERR_REQUIRED)]; + if (!protoTools.isInstance(sample)) return [createError(ERR_MISS + this.type)]; + const keys = protoTools.getKeys(sample); + if (!keys.length && this.required) return [createError(ERR_EMPTY)]; + const [errors, test] = [[], process(protoTools.getValue, protoTools.setValue, sample)]; + for (const prop of keys) { + const createError = cause => new Error({ path: `${path}.${prop}`, sample, plan, cause }); + const [prototype, schemaProp] = search(this, prop); + if (!prototype) { + if (!allowExotic) errors.push(createError(`Exotic property`)); + continue; + } + requires.delete(schemaProp ?? prop); + const result = test(prototype, prop, `${path}.${prop}`); + if (result.length) errors.push(...result); + } + if (requires.size) { + for (const prop of requires.keys()) { + const prototype = this.properties[prop] ?? this.patternProperties[prop]; + if (!prototype.preprocess) continue; + const result = (requires.delete(prop), test(prototype, prop, `${path}.${prop}`)); + if (result.length) errors.push(...result); + } + requires.size && errors.push(createError(`Missing "${[...requires.keys()].join()}"`)); + } + return errors; + }; + }, +}); diff --git a/lib/proto/objects/utils.js b/lib/proto/objects/utils.js new file mode 100644 index 0000000..b18bc12 --- /dev/null +++ b/lib/proto/objects/utils.js @@ -0,0 +1,49 @@ +'use strict'; + +const parser = tools => { + const parse = (sample, parse) => { + if (!tools.isInstance(sample) || !sample) return null; + const properties = tools.getKeys(sample).reduce((acc, key) => { + acc[key] = parse(tools.getValue(key, sample), parse); + return acc; + }, {}); + return { type: 'object', properties }; + }; + return parse; +}; + +const search = (schema, prop) => { + if (!schema.properties) return [null, null]; + if (prop in schema.properties) return [schema.properties[prop], null]; + if (!schema.patternProperties) return [null, null]; + const entries = Object.entries(schema.patternProperties); + if (typeof prop !== 'string') prop = String(prop); + for (const [pattern, value] of entries) if (prop.match(pattern)) return [value, pattern]; + return [null, null]; +}; + +const process = (get, set, target) => (proto, prop, path) => { + const { preprocess, postprocess } = proto; + if (preprocess) set(prop, preprocess(get(prop, target)), target); + const result = proto.test(get(prop, target), path); + if (postprocess) set(prop, postprocess(get(prop, target), result), target); + return result; +}; + +const buildProps = (ctx, plan, tools) => { + const requires = new Map(); + const builded = { properties: {}, patternProperties: {} }; + for (const propType in builded) { + if (!plan[propType]) continue; + const entries = Object.entries(plan[propType]); + for (const [key, value] of entries) { + builded[propType][key] = tools.build(value); + const required = tools.isRequired(value); + if (required) requires.set(key); + } + ctx[propType] = Object.assign({}, builded[propType]); + } + return requires; +}; + +module.exports = { process, parser, search, buildProps }; diff --git a/lib/proto/scalars.js b/lib/proto/scalars.js index 72a2939..75985ab 100644 --- a/lib/proto/scalars.js +++ b/lib/proto/scalars.js @@ -1,23 +1,41 @@ 'use strict'; -const MISS = 'Type missconfiguration, expected type: '; +const ACCEPTED = ['string', 'boolean', 'number', 'bigint']; +const META = { kind: 'scalar', origin: 'default' }; +const ERR_ENUM = `Enum doesn't contain this value, enum: `; +const ERR_MISS = 'Type missconfiguration, expected type: '; + const scalar = type => ({ - meta: { kind: 'scalar', subtype: 'default' }, - construct(plan, tools) { - const { isRequired, Error } = tools; - [this.type, this.required] = [type, isRequired(plan)]; + meta: META, + parse: Object.assign(() => type, { targets: [type] }), + construct(plan, { isRequired, Error }) { + this.required = isRequired(plan); this.ts = () => type; this.test = (sample, path) => { if (typeof sample === type) return []; if (sample === undefined && !this.required) return []; - return [new Error({ path, sample, plan, cause: MISS + type })]; + return [new Error({ path, sample, plan, cause: ERR_MISS + type })]; }; }, }); +const enumerable = { + meta: META, + construct(plan, { isRequired, Error }) { + this.required = isRequired(plan); + this.enum = plan.enum?.filter(el => ACCEPTED.includes(typeof el)); + this.ts = () => 'unknown'; + this.test = (sample, path) => { + if (this.enum.includes(sample)) return []; + return new Error({ path, sample, plan, cause: ERR_ENUM + this.enum.join(', ') }); + }; + }, +}; + module.exports = { string: scalar('string'), bigint: scalar('bigint'), number: scalar('number'), boolean: scalar('boolean'), + enum: enumerable, }; diff --git a/lib/schema/error.js b/lib/schema/error.js index 0f5992f..7a7df1b 100644 --- a/lib/schema/error.js +++ b/lib/schema/error.js @@ -1,30 +1,23 @@ 'use strict'; -const MAP = { s: 'sample', c: 'cause', p: 'path' }; -module.exports = (options = {}) => { - const { pattern = '[P] => C' } = options; - const format = dict => pattern.toLowerCase().replace(/[scp]/g, char => dict[MAP[char]]); +const define = (ctx, name, value) => Object.defineProperty(ctx, name, { enumerable: false, value }); +const defaultFormat = dict => `[${dict.path}] => ${dict.cause}`; +module.exports = (options = {}) => { + const { format = defaultFormat } = options; return function SchemaError({ path, sample, plan, cause, sampleType }) { [this.count, this.path, this.cause] = [0, path, '']; if (sample) [this.sample, this.sampleType] = [sample, sampleType ?? typeof sample]; - if (cause) [this.cause, this.count] = [cause, this.count + 1]; + if (cause) this.cause = (++this.count, cause); if (plan) this.plan = plan; - const toString = () => this.message; - const toJSON = () => ({ sample, path, cause: this.cause, count: this.count }); - const add = cause => { - cause = this.cause ? `${this.cause}, ${cause}` : cause; - [this.count, this.cause] = [this.count + 1, cause]; - this.message = format(this); + this.message = format(this); + define(this, 'toString', () => this.message); + define(this, 'toJSON', () => ({ sample, path, cause: this.cause, count: this.count })); + define(this, 'message', format(this)); + define(this, 'add', cause => { + this.cause = this.cause ? `${this.cause}, ${cause}` : cause; + this.message = (++this.count, format(this)); return this; - }; - Object.defineProperty(this, 'add', { enumerable: false, value: add }); - Object.defineProperty(this, 'toJSON', { enumerable: false, value: toJSON }); - Object.defineProperty(this, 'toString', { enumerable: false, value: toString }); - Object.defineProperty(this, 'message', { - enumerable: false, - writable: true, - value: format(this), }); }; }; diff --git a/lib/schema/index.js b/lib/schema/index.js index c40e7f8..6614755 100644 --- a/lib/schema/index.js +++ b/lib/schema/index.js @@ -1,32 +1,39 @@ 'use strict'; const [build, createError] = [require('../builder'), require('./error')]; -const { proto: prepared, createType } = require('../proto'); +const { defaultTypes, deffaultPrototypes, createType } = require('../proto'); const tooling = { Error: createError(), ...require('./tools') }; +const { ParseStorage, createParser } = require('../parser'); -module.exports = function Schema(schema, params = {}) { - const { errorPattern, types: custom = new Map(), ...options } = params; - const [types, customPrototypes] = [new Map(), custom.entries()]; +function Schema(plan, params = {}) { + if (plan instanceof Schema) return plan.child(null, params); //? Just updating parameters + const { errorFormat, types: custom, namespace = new Map() } = params; const tools = { ...tooling, build: null, warn: null }; - tools.build = build.bind(null, { types, ...options }, tools); + const types = new Map([...defaultTypes]); //? Copy default types links + tools.build = build.bind(null, { types, namespace }, tools); tools.warn = options => { const err = new tooling.Error(options); return this.warnings.push(err), err; }; - if (errorPattern) tools.Error = createError({ pattern: errorPattern }); - for (const [name, proto] of customPrototypes) { - const prototype = prepared.get(name) ?? proto; - types.set(name, createType(prototype, proto.meta)); - } - for (const [name, proto] of prepared) { - if (types.has(name)) continue; - types.set(name, createType(proto)); + if (errorFormat) tools.Error = createError({ format: errorFormat }); + if (custom && custom instanceof Map) { + const entries = custom.entries(); + for (const [name, proto] of entries) { + const prototype = deffaultPrototypes.get(name) ?? proto; + types.set(name, createType(name, prototype, proto.meta)); + } } this.warnings = []; Object.freeze(tools); - Object.assign(this, tools.build(schema)); + Object.defineProperty(this, 'meta', { get: tools.exportMeta.bind(null, this) }); + this.parse = createParser(new ParseStorage(types)); + this.child = (p, o) => new Schema(p || plan, o ? { ...params, ...o } : params); + if (plan) Object.assign(this, tools.build(plan)); //? Gathering test, ts methods and metadata return Object.freeze(this); -}; -// this.update = schema => new Schema(schema,) +} + +Schema.parse = createParser(new ParseStorage(defaultTypes)); +Schema.from = (plan, params) => new Schema(plan, params); +module.exports = Schema; diff --git a/lib/schema/tools.js b/lib/schema/tools.js index d201983..0aa433e 100644 --- a/lib/schema/tools.js +++ b/lib/schema/tools.js @@ -1,15 +1,15 @@ 'use strict'; -const conditions = (condition, max) => { +const getCondition = (name, max) => { let flag = false; - if (condition === 'allof') { + if (name === 'allof') { return (result, i) => { if (result.length > 0) return ['break', 'Item did not pass one of schema']; if (i === max) return ['skip']; return ['continue']; }; } - if (condition === 'oneof') { + if (name === 'oneof') { return (result, i) => { if (flag && result.length === 0) return ['break', 'Item passed more than one schema']; if (result.length === 0) flag = true; @@ -25,6 +25,21 @@ const conditions = (condition, max) => { }; }; +const runCondition = (handler, { path, sample, createError, prototypes }) => { + const errors = []; + for (let i = 0; i < prototypes.length; ++i) { + const result = prototypes[i].test(sample, path); + const [toDo, cause] = handler(result, i); + if (cause) { + if (result.length) errors.push(...result); + else errors.push(createError(cause)); + } + if (toDo === 'skip' || toDo === 'break') break; + if (toDo === 'continue') continue; + } + return errors; +}; + const isShorthand = schema => typeof schema === 'string'; const isRequired = s => (isShorthand(s) ? !s.startsWith('?') : s.required ?? true); const typeOf = s => { @@ -34,4 +49,18 @@ const typeOf = s => { return [s.type]; }; -module.exports = { conditions, isRequired, typeOf, isShorthand }; +const exportMeta = schema => { + const [entries, meta] = [Object.entries(schema), new Map()]; + for (const [k, v] of entries) { + if (typeof v === 'function' || k === 'warnings') continue; + if (typeof v === 'object') { + if (Array.isArray(v)) meta.set(k, v.map(exportMeta)); + else meta.set(k, exportMeta(v)); + continue; + } + meta.set(k, v); + } + return meta; +}; + +module.exports = { getCondition, isRequired, typeOf, isShorthand, exportMeta, runCondition }; diff --git a/package.json b/package.json index d3d47d8..d569c0a 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "license": "MIT", - "version": "0.2.0", + "version": "0.3.0", "type": "commonjs", "name": "metaforge", "homepage": "https://astrohelm.ru", - "description": "Library for runtime data validation by metadata", + "description": "Library that allow you to describe data structures by subset of JavaScript syntax and validate them at runtime", "author": "Alexander Ivanov ", "keywords": [ "nodejs", @@ -33,7 +33,7 @@ "browser": {}, "files": ["/lib", "/types"], "scripts": { - "test": "node --test", + "test": "clear && node --test", "dev": "node index.js", "prettier:fix": "prettier --write \"**/*.{js,ts,json,html,cjs,md,yaml}\"", "eslint:fix": "eslint --fix \"**/*.{js,ts}\"" diff --git a/tests/basic.test.js b/tests/basic.test.js index 9dcbac1..88684a8 100644 --- a/tests/basic.test.js +++ b/tests/basic.test.js @@ -12,6 +12,9 @@ test('Schema with errors & warnings', () => { c: { type: 'object', properties: { z: 'string' } }, z: 'string', z2: '?string', + season: { type: 'enum', enum: ['winter', 'spring', 'autumn', 'summer'] }, + room: { type: 'enum', enum: [1, 2, 3, 4] }, + room2: 'enum', //? +1 [Warning] Shorthand enum (enum is scalar) }, patternProperties: { '^[a-z]+': 'string', @@ -22,21 +25,23 @@ test('Schema with errors & warnings', () => { b: new Set(['a', 'b', 'c']), //? +1 [Warning] Shorthand non-scalar c: { z: 'string', a: true }, //? +1 [Error] Exotic hello: 'world', + season: 'today', //? +1 [Error] Not at enum + room: 5, //? +1 [Error] Not at enum 123: 'test', //? +2 [Error] Exoitic, Missing field "z" }; const schema = new Schema(plan); - assert.strictEqual(schema.warnings.length, 1); + assert.strictEqual(schema.warnings.length, 2); const { cause, message, path } = schema.warnings[0]; assert.strictEqual(path, 'PREPROCESS'); assert.strictEqual(cause, 'Shorthand usage with non-scalar schema'); assert.strictEqual(message, '[PREPROCESS] => Shorthand usage with non-scalar schema'); const errors = schema.test(sample); - assert.strictEqual(errors.length, 4); + assert.strictEqual(errors.length, 6); }); test('Schema without errors & warnings', () => { const plan = { - type: 'object', + type: 'map', properties: { a: ['number', 'string'], //? anyof b: { type: 'set', items: ['?string', 'any', 'unknown'], condition: 'allof' }, @@ -51,20 +56,31 @@ test('Schema without errors & warnings', () => { z: 'string', z2: '?string', //? not required z3: { type: 'string', required: false }, + z4: { type: 'string', preprocess: () => 'Required' }, //? Default value + z5: { + type: 'array', + items: [{ type: 'number', postprocess: v => v * 2 }], //? This process wont work + preprocess: () => [1, 2, 3, 4], + }, + season: { type: 'enum', enum: ['winter', 'spring', 'autumn', 'summer'] }, }, patternProperties: { - '^[a-z]+': 'string', + '^[a-z]+': { type: 'string', postprocess: v => v + ' !' }, }, }; - const sample = { - a: 'test', - b: new Set(['a', 'b', 'c']), - c: { z: 'string', d: [1, 'test'] }, - hello: 'world', - z: 'test', - }; + const sample = new Map( + Object.entries({ + a: 'test', + b: new Set(['a', 'b', 'c']), + c: { z: 'string', d: [1, 'test'] }, + hello: 'world', + z: 'test', + season: 'winter', + }), + ); const schema = new Schema(plan); assert.strictEqual(schema.warnings.length, 0); const errors = schema.test(sample); + console.log(errors, sample); assert.strictEqual(errors.length, 0); }); diff --git a/tests/parser.test.js b/tests/parser.test.js new file mode 100644 index 0000000..42fe9ed --- /dev/null +++ b/tests/parser.test.js @@ -0,0 +1,78 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const Schema = require('..'); + +test('Generate schema from sample', () => { + const sample = { + lines: ['Pushkin streen', 'Kalatushkin home', 8], + zip: '123103', + city: 'Moscow', + country: 'Russia', + }; + const expected = { + type: 'object', + properties: { + lines: { type: 'array', items: ['string', 'number'] }, + zip: 'string', + city: 'string', + country: 'string', + }, + }; + const plan = Schema.parse(sample); + assert.deepStrictEqual(plan, expected); +}); + +test('Generate schema from array of objects', () => { + const sample = [ + { + country: 'Russia', + lines: ['Pushkin street', 'Kalatushkin home', 8], + zip: '123103', + city: 'Moscow', + }, + { + lines: ['Ivanov street', 25], + city: 'Moscow', + zip: '123103', + country: 'Russia', + }, + { + lines: ['Brodway street'], + zip: '123103', + city: 'New York', + phone: '+1 111...', + country: 'USA', + }, + ]; + const expected = { + type: 'array', + items: [ + { + type: 'object', + properties: { + lines: { type: 'array', items: ['string', 'number'] }, + zip: 'string', + city: 'string', + country: 'string', + }, + }, + { + type: 'object', + properties: { + lines: { type: 'array', items: ['string'] }, + zip: 'string', + city: 'string', + phone: 'string', + country: 'string', + }, + }, + ], + }; + const plan = Schema.parse(sample); + const schema = new Schema(plan); + const errors = schema.test(sample); + assert.deepStrictEqual(plan, expected); + assert.strictEqual(errors.length + schema.warnings.length, 0); +}); diff --git a/tests/rules.test.js b/tests/rules.test.js index 9fa6892..deac01c 100644 --- a/tests/rules.test.js +++ b/tests/rules.test.js @@ -11,6 +11,5 @@ test('Rules', () => { assert.strictEqual(schema.warnings.length, 0); assert.strictEqual(schema.test().length, 3); //? Required + two rules assert.strictEqual(schema.test('Test').length, 1); //? One rule - console.log(schema.test('Hello world')); assert.strictEqual(schema.test('Hello world').length, 0); }); diff --git a/types/index.d.ts b/types/index.d.ts index 65c1ffa..d66b405 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,4 +1,4 @@ -//TODO: #1 Parser for samples +//TODO: #1 enum, tuple, date prototypes / Code cleanup //TODO: #2 Typescript code generation //TODO: #3 FS UTilities //TODO: #4 Types