@justlep/camo
Version:
A class-based Object-Document Mapper (ODM) for NeDB
325 lines (285 loc) • 14 kB
JavaScript
import {
IS_BASE_DOCUMENT, ST_IS_CUSTOM_TYPE,
IS_DOCUMENT, IS_EMBEDDED, ST_IS_TYPED_ARRAY,
SCHEMA_1PROP_KEYS,
SCHEMA_ALL_KEYS, SCHEMA_ARRAY_KEYS,
ST_IS_EMBED_ARRAY,
SCHEMA_REF_1_DOC_KEYS, SCHEMA_REF_1_OR_N_EMBD_KEYS,
SCHEMA_REF_N_DOCS_KEYS, SCHEMA_ALL_ENTRIES, SCHEMA_BASIC_FROM_DATA, SCHEMA_ALL_ENTRIES_NO_ID, SCHEMA_ALL_ENTRIES_FOR_JSON
} from './symbols.js';
import {isArray, isDate, isNumber} from './validate.js';
import {isNativeId} from './client.js';
import {deprecate, hasOwnProp} from './util.js';
import {Document} from './document.js';
/**
* Generate a schema for this instance.
*
* @param {BaseDocument} doc
* @return {CamoSchema}
*/
export const generateSchemaForDocument = (doc) => {
/** @type {CamoSchema} */
let schema = Object.create(null);
// console.log(`Generating schema for class ${doc.constructor.name}`);
// Assumption: schemas of Document subclasses are immutable at runtime,
// so it should be safe to use a shallow copy of the next best _schema off the prototype chain
for (const key of doc._schema[SCHEMA_ALL_KEYS]) { // NOT Object.keys() since we need _id from the prototype
schema[key] = doc._schema[key];
}
const schemaKeySet = new Set(Object.keys(schema));
/** @type {?Object|function} */
const STATIC_SCHEMA = hasOwnProp(doc.constructor, 'SCHEMA') ? doc.constructor.SCHEMA : null;
/** @type {Object} the schema source object, preferably the overridden static SCHEMA field, or as fallback the instance itself */
const src = (typeof STATIC_SCHEMA === 'function' ? STATIC_SCHEMA() : STATIC_SCHEMA) || doc;
if (src === doc) {
deprecate(`Defining schemas in constructors is deprecated. Override static field ${doc.constructor.name}.SCHEMA instead.`);
}
for (const docProp of Object.keys(src)) {
if (docProp[0] === '_' && doc.constructor !== Document) {
continue; // ignore private variables
}
let propVal = src[docProp];
if (propVal?.type) {
schema[docProp] = assertValidSchemaEntry(docProp, propVal, false);
} else {
schema[docProp] = assertValidSchemaEntry(docProp, {type: propVal}, true);
}
schemaKeySet.add(docProp);
}
/** @type {string[]} */
const schemaKeys = [...schemaKeySet];
/** @type {string[]} - caching all keys of the schema since Object.keys(schema) is ~90% slower than schema[SCHEMA_ALL_KEYS] */
schema[SCHEMA_ALL_KEYS] = schemaKeys;
/** @type {string[]} - keys which reference 1 Document-type entity */
schema[SCHEMA_REF_1_DOC_KEYS] = schemaKeys.filter(k => schema[k].type[IS_DOCUMENT]);
/** @type {string[]} - keys which reference arrays of Document-type entities */
schema[SCHEMA_REF_N_DOCS_KEYS] = schemaKeys.filter(k => schema[k].type[ST_IS_TYPED_ARRAY] && schema[k].type.elementType[IS_DOCUMENT]);
/** @type {string[]} - keys which reference single OR arrays of Embedded-type entities */
schema[SCHEMA_REF_1_OR_N_EMBD_KEYS] = schemaKeys.filter(k => schema[k].type[IS_EMBEDDED] || schema[k].type.elementType?.[IS_EMBEDDED]);
/** @type {string[]} - keys of array properties */
schema[SCHEMA_ARRAY_KEYS] = schemaKeys.filter(k => schema[k].type[ST_IS_TYPED_ARRAY]);
/** @type {string[]} - keys of non-array properties */
schema[SCHEMA_1PROP_KEYS] = schemaKeys.filter(k => !schema[k].type[ST_IS_TYPED_ARRAY]);
/** @type {CamoSchemaEntry[]} */
schema[SCHEMA_ALL_ENTRIES] = Object.values(schema);
/** @type {CamoSchemaEntry[]} */
schema[SCHEMA_ALL_ENTRIES_NO_ID] = schema[SCHEMA_ALL_ENTRIES].filter(en => en._key !== '_id');
/** @type {CamoSchemaEntry[]} */
schema[SCHEMA_ALL_ENTRIES_FOR_JSON] = schema[SCHEMA_ALL_ENTRIES].filter(en => !en.private);
if (doc[IS_EMBEDDED] && (schema[SCHEMA_REF_N_DOCS_KEYS].length + schema[SCHEMA_REF_1_DOC_KEYS].length)) {
/** forbid for now, as {@link Document.save} won't handle this case currently */
throw new Error(`EmbeddedDocument classes must not have Document-type references (found in: ${doc.constructor.name})`);
}
return schema;
};
/**
* @typedef {Object.<string, CamoSchemaEntry>} CamoSchema - a mapping a string keys to descriptors about the key's value
*/
/**
* @typedef {Object} CamoSchemaEntry - an Object specifying the value type & limitations of a Document property
* @property {*|TypedArrayType|CustomType} type - some constructor of a supported type
* @property {function(*):boolean} [validate] custom validator function; returning true means value is fine,
* (!) required for custom types (Object/[]/Array)
* @property {CamoDefaultValueCreator|*} [default] - a function generating a default value, or a default value itself
* @property {boolean} [unique] - if true, a database unique index will be ensured for the respective field
* @property {boolean} [indexed] - if true, a database non-unique, non-sparse index will be ensured for the respective field
* @property {boolean} [private]
* @property {boolean} [required]
* @property {number} [min] - max value for numbers
* @property {number} [max] - max value for numbers // TODO check: for Dates as well?
* @property {*[]} [choices] - array of allowed values
* @property {RegExp} [match]
* @property {function(*):*} [toData] - required and only allowed for custom types (Object/[]/Array),
* transforms the current value into an Object to be stored in the database,
* @property {function(*):*} [fromData] - required and only allowed for custom types (Object/[]/Array),
* transforms saved value from the database back to a document instance' property value
* @property {string} _key - filled during schema creation
*/
const ALLOWED_SCHEMA_PROPS = new Set(['type', 'validate', 'default', 'unique', 'indexed', 'private', 'required', 'min', 'max', 'choices', 'match']);
const isSupportedBasicType = t => t === String || t === Number || t === Boolean || t === Buffer || t === Date || t?.[IS_BASE_DOCUMENT];
/**
* @param {string} docProp - the property name in the checked Document
* @param {CamoSchemaEntry} entry
* @param {boolean} onlyType - if true, check only the `type` property but not the optional props
* @return {CamoSchemaEntry} the given entry (o) if valid
* @throws {Error} if invalid
*/
function assertValidSchemaEntry(docProp, entry, onlyType) {
let t = entry.type,
needsDefaultArray = false;
if (isSupportedBasicType(t)) {
entry[SCHEMA_BASIC_FROM_DATA] = getFromDataMapperFnForClass(t);
} else if (t === Array || t === Object || isArray(t) && !t.length) {
// wildcard-types, see https://github.com/justlep/camo/issues/1
// auto-deletes 'fromData' + 'toData' from o
entry.type = new CustomType(entry, docProp);
needsDefaultArray = t === Array || isArray(t);
} else if (isArray(t)) {
entry.type = new TypedArrayType(entry, docProp);
needsDefaultArray = true;
} else {
throw new Error(`Unsupported type or bad variable for property "${docProp}". `+
`Remember, non-persisted objects must start with an underscore (_). Got: ${t}`);
}
if (needsDefaultArray) {
let defaultVal = entry.default;
entry.default = (!defaultVal || isArray(defaultVal) && !defaultVal.length) ? (() => []) :
isArray(defaultVal) ? (() => defaultVal.slice()) : defaultVal;
}
if (onlyType) {
entry._key = docProp;
return entry;
}
for (const key of Object.keys(entry)) {
if (!ALLOWED_SCHEMA_PROPS.has(key)) {
throw new Error(`Unknown schema property "${key}". Allowed properties are: ${[...ALLOWED_SCHEMA_PROPS].join(', ')}`);
}
}
if (entry.validate && typeof entry.validate !== 'function') {
throwInvalidOptSchemaKeyValue(docProp, 'validate', 'function', entry.validate);
}
if (entry.type === Date && typeof entry.default !== 'undefined') {
let defaultValueOrFn = entry.default,
needsCheck = true;
if (typeof defaultValueOrFn === 'function') {
entry.default = () => ensureDate(defaultValueOrFn());
needsCheck = false;
} else if (typeof defaultValueOrFn === 'string' || isNumber(defaultValueOrFn)) {
entry.default = ensureDate(defaultValueOrFn);
}
if (needsCheck && !(entry.default instanceof Date)) {
throwInvalidOptSchemaKeyValue(docProp, 'default', 'function|Date|number|string', defaultValueOrFn);
}
}
for (const prop of ['unique', 'indexed', 'private', 'required']) {
let val = entry[prop];
if (val !== undefined && val !== true && val !== false) {
throwInvalidOptSchemaKeyValue(docProp, prop, 'boolean', val);
}
}
if (typeof entry.choices !== 'undefined') {
if (!isArray(entry.choices) || !entry.choices.length) {
throwInvalidOptSchemaKeyValue(docProp, 'choices', 'Array', entry.choices);
}
if (t !== String && t !== Number && t !== Date) {
throw new Error(`Schema property "choices" is only available for types Number|String|Date, but was used for ${t.name}`);
}
}
if (entry.min !== undefined || entry.max !== undefined) {
for (const prop of ['min', 'max']) {
let val = entry[prop];
if (typeof val !== 'undefined') {
if (!isNumber(val) && !isDate(val)) {
throwInvalidOptSchemaKeyValue(docProp, prop, 'number', val);
}
if (t !== Number && t !== Date) {
throw new Error(`Schema property "${prop}" "is only available for types Number|Date, but was used for ${t.name}`);
}
}
}
}
if (typeof entry.match !== 'undefined') {
if (!(entry.match instanceof RegExp)) {
throwInvalidOptSchemaKeyValue(docProp, 'match', 'RegExp', entry.match);
}
if (t !== String && !(t[ST_IS_TYPED_ARRAY] && t.elementType === String)) {
throw new Error(`Schema property "match" is only available for types String|String[], but was used for ${t.name}`);
}
}
entry._key = docProp;
return entry;
}
/**
* @param {string} docProp
* @param {string} schemaProp
* @param {string} expected
* @param {*} actual
* @return {*}
*/
const throwInvalidOptSchemaKeyValue = (docProp, schemaProp, expected, actual) => {
throw new Error(`Invalid value for schema.${schemaProp} of Document.${docProp} property. Expected ${expected}, but got ${typeof actual}`);
};
/**
* @callback CamoDefaultValueCreator
* @return *
*/
/**
* Represents a non-basic and non-Document-like custom (wildcard) type for a schema entry.
* Validation and JSON-transforms are responsibility of the user.
* That's why this entry type requires explicit, additional function properties 'validate', 'toData', 'fromData',
* in the raw schema entry.
*
* @param {CamoSchemaEntry} entry
* @param {string} docProp
* @constructor
*/
function CustomType(entry, docProp) {
let typesString = ['toData', 'fromData', 'validate'].map(fnName => typeof entry[fnName]).join(',');
if (typesString !== 'function,function,function') {
throw new Error(`Document property '${docProp}' is custom-type, requiring [toData,fromData,validate] function schema properties, but got [${typesString}]`);
}
this.toData = entry.toData;
delete entry.toData;
this.fromData = entry.fromData;
delete entry.fromData;
this.validate = entry.validate;
entry[ST_IS_CUSTOM_TYPE] = true;
this[ST_IS_CUSTOM_TYPE] = true;
}
/**
*
* @param {CamoSchemaEntry} entry
* @param {string} docProp
* @constructor
*/
function TypedArrayType(entry, docProp) {
let t = entry.type;
if (t.length !== 1) {
throw new Array(`Schema definition for array property ${docProp} must contain 1 element, but has ${t.length}`);
}
let firstElem = t[0];
if (!isSupportedBasicType(firstElem)) {
throw new Error(`Document property '${docProp}' is array-type with custom-type elements. `+
`Custom-type Object/Array entries must be defined as: {${docProp}: {type: Object, validate: {function}, fromData: {function}, toData: {function}}}`);
}
this.elementType = firstElem;
this.mapFromData = getFromDataMapperFnForClass(firstElem);
entry[ST_IS_TYPED_ARRAY] = true;
this[ST_IS_TYPED_ARRAY] = true;
if (firstElem[IS_EMBEDDED]) {
entry[ST_IS_EMBED_ARRAY] = true;
this[ST_IS_EMBED_ARRAY] = true;
}
}
const idem = v => v;
const undef = () => void 0;
/**
* @param {typeof Function|typeof BaseDocument} clazz
* @return {function(*):*}
*/
const getFromDataMapperFnForClass = clazz => {
switch (clazz) {
case String:
case Number:
case Boolean:
return idem;
case Date:
return n => (isNumber(n) || typeof n === 'string') ? new Date(n) : n instanceof Date ? n : undefined;
case Buffer:
// TODO check if we really need instanceof-check
return s => typeof s === 'string' ? Buffer.from(s) : s instanceof Buffer ? s : undefined;
}
if (clazz[IS_EMBEDDED]) {
// TODO check if we really need instanceof-check
return (o) => o ? (o instanceof clazz ? o : clazz._fromData(o)) : undefined;
}
if (clazz[IS_DOCUMENT]) {
// TODO check if we really need instanceof-check
return (o) => o ? (isNativeId(o) ? o : o instanceof clazz ? o : clazz._fromData(o)) : undefined;
}
return undef;
};
/**
* @return {function(*): Date}
*/
const ensureDate = getFromDataMapperFnForClass(Date);