@justlep/camo
Version:
A class-based Object-Document Mapper (ODM) for NeDB
623 lines (548 loc) • 23.4 kB
JavaScript
import {
isArray,
ValidationError, isNumber
} from './validate.js';
import {isNativeId} from './client.js';
import {
COLLECTION_NAME,
IS_BASE_DOCUMENT,
IS_EMBEDDED,
SCHEMA_1PROP_KEYS,
SCHEMA_ALL_KEYS,
SCHEMA_ARRAY_KEYS,
SCHEMA_REF_1_DOC_KEYS,
SCHEMA_REF_1_OR_N_EMBD_KEYS,
SCHEMA_REF_N_DOCS_KEYS,
ST_IS_TYPED_ARRAY,
IS_DOCUMENT,
ST_IS_CUSTOM_TYPE,
SCHEMA_BASIC_FROM_DATA, SCHEMA_ALL_ENTRIES_NO_ID, ST_IS_EMBED_ARRAY, SCHEMA_ALL_ENTRIES_FOR_JSON
} from './symbols.js';
import {generateSchemaForDocument} from './schema.js';
import {deprecate} from './util.js';
const EMPTY_HOOK = async () => null;
/**
* @typedef {Object} CamoPopulateTarget
* @property {BaseDocument[]|string[]} ref1DocsAndProps - like [doc1, 'foo', doc2, 'bar', ...]
* @property {(BaseDocument|string)[]} refNDocsAndProps - as above, but foo and bar are array properties of doc1/doc2
*/
/**
* @type {Map<typeof BaseDocument, Object.<string, CamoPopulateTarget>>}
*/
let docClassToIdPopTargetMap = new Map();
/** @type {Map<typeof BaseDocument, Object>} */
const schemaByDocClass = new Map();
/**
* @property {string} _id
* @property {CamoSchema} _schema
* @abstract
*/
export class BaseDocument {
/**
* Get custom collection name.
* The name is autogenerated by the lowercase class' name with an 's' appended.
* Can be overridden individually by derived classes.
*
* @returns {String}
*/
static collectionName() {
return this.hasOwnProperty(COLLECTION_NAME) ? this[COLLECTION_NAME] : (this[COLLECTION_NAME] = this.name.toLowerCase() + 's'); //eslint-disable-line
}
/**
* @return {string}
* @internal
*/
collectionName() {
return this.constructor.collectionName();
}
/**
* User-defined schema.
* Expected to be overridden by derived Document/EmbeddedDocument classes.
* @return {?Object|function():Object}
*/
static SCHEMA = null;
/**
* Set schema
* @param {Object} extension
* @deprecated - override static SCHEMA in derived classes instead
*/
schema(extension) {
if (schemaByDocClass.has(this.constructor)) {
return;
}
deprecate(`Defining schemas by calling this.schema() is deprecated. Override static field ${this.constructor.name}.SCHEMA instead.`);
if (!extension || typeof extension !== 'object') {
return;
}
for (const key of Object.keys(extension)) {
if (key[0] === '_' || key === 'toString') {
throw new Error(`Forbidden schema key name "${key}"`);
}
this[key] = extension[key];
}
}
/*
* Pre/post Hooks
*
* To add a hook, the extending class just needs
* to override the appropriate hook method below.
*/
/**
* Get default value
*
* @param {String} schemaKey Key of current schema
* @returns {*}
*/
_getDefaultValueForKey(schemaKey) {
if (schemaKey === '_id') {
return null;
}
let defaultForKey = this._schema[schemaKey]?.default;
return (typeof defaultForKey === 'function') ? defaultForKey() : defaultForKey;
}
/**
* Validate current document
* The method throw errors if document has invalid value
*/
validate() {
for (const key of this._schema[SCHEMA_ALL_KEYS]) {
/** @type {CamoSchemaEntry} */
let schemaEntry = this._schema[key];
let {type: schemaType, match, min, max, choices, validate, required} = schemaEntry,
isArrayTypeEntry = schemaEntry[ST_IS_TYPED_ARRAY],
value = this[key];
if (required) {
if (value === undefined || value === null ||
(!(typeof value === 'number' || value instanceof Date || typeof value === 'boolean') && !Object.keys(value).length)) {
throw new ValidationError(this, key, 'is required but empty');
}
}
if (validate && !validate(value)) {
throw new ValidationError(this, key, 'was rejected by custom validate()');
}
if (value === null || value === undefined | schemaEntry[ST_IS_CUSTOM_TYPE]) {
continue;
}
// simple types (String, Number, Boolean, Buffer, Date)
if (isArrayTypeEntry) {
let {elementType} = schemaType,
values = value; // just a plural-named alias for clarity in this block
if (!isArray(values)) {
throw new ValidationError(this, key, `should be array of ${elementType.name}, but is ${typeof values}`);
}
if (!elementType[IS_BASE_DOCUMENT]) { // Boolean[], Number[], String[], Date[], Buffer[]
let expectsDate = elementType === Date;
for (const elem of values) {
if (elem?.constructor !== elementType && !(expectsDate && isNumber(elem))) {
throw new ValidationError(this, key, `contains a non-${elementType.name} element`);
}
}
// from here, it's a packed, uni-type Array with all elements having the correct type
if (match && ~values.findIndex(s => !match.test(s))) {
throw new ValidationError(this, key, 'contains a non-matching string element');
}
if (min !== undefined && ~values.findIndex(n => n < min)) {
throw new ValidationError(this, key, `contains an element less than min ${min}`);
}
if (max !== undefined && ~values.findIndex(n => n > max)) {
throw new ValidationError(this, key, `contains an element greater than max ${max}`);
}
} else if (elementType[IS_EMBEDDED]) { // EmbeddedDocument[]
for (const embedded of values) {
if (!(embedded instanceof elementType)) {
throw new ValidationError(this, key, `contains a non-${elementType.name} element`);
}
embedded.validate();
}
} else if (elementType[IS_DOCUMENT]) { // Document[]
for (const elem of values) {
if (!isNativeId(elem instanceof elementType ? elem._id : elem)) {
throw new ValidationError(this, key, `contains a non-${elementType.name} element or an un-saved one`);
}
}
} else {
throw new Error(`Unexpected elemType ${elementType.name} - This should never happen!`);
}
} else if (!schemaType[IS_BASE_DOCUMENT]) { // Boolean, Number, String, Date, Buffer
if (value.constructor !== schemaType) { // no longer supporting Numbers in Date properties
throw new ValidationError(this, key, `should be ${schemaType.name}, but is ${typeof value}`);
}
if (match && !match.test(value)) {
throw new ValidationError(this, key, 'does not match the Regex');
}
if (choices && !choices.includes(value)) {
throw new ValidationError(this, key, `must be one of choices [${choices.join(',')}], but got ${value}`);
}
if (min !== undefined && value < min) {
throw new ValidationError(this, key, `has a value less than min ${min}`);
}
if (max !== undefined && value > max) {
throw new ValidationError(this, key, `has a value greater than max ${max}`);
}
} else if (schemaType[IS_EMBEDDED]) {
if (!(value instanceof schemaType)) {
throw new ValidationError(this, key, `should be ${schemaType.name}, but is ${typeof value}`);
}
value.validate();
} else if (schemaType[IS_BASE_DOCUMENT]) {
let isDocInstance = value instanceof schemaType;
if (!isDocInstance && !isNativeId(value)) {
throw new ValidationError(this, key, `should be ${schemaType.name}, but is ${typeof value}`);
}
if (isDocInstance && !value._id) {
throw new ValidationError(this, key, '');
}
} else {
throw new Error(`Unexpected schemaType ${schemaType.name} - This should never happen!`);
}
}
}
/** @internal */
_createIndexesOnce() {
}
/**
* Create new document from data
*
* @param {Object} [data]
* @returns {this}
*/
static create(data) {
if (data) {
return this._fromData(data);
}
return this._instantiate();
}
/**
* Create new document from self.
* If no schema was created for this (sub)class yet,
* it will be created ONCE and cached in the prototype,
* so we never generate a schema twice.
*
* @returns {BaseDocument}
*/
static _instantiate() {
let instance = new this(),
schema = schemaByDocClass.get(this);
if (!schema) {
let parentClass = Object.getPrototypeOf(this);
if (parentClass !== BaseDocument && !schemaByDocClass.has(parentClass)) {
// ensure that all classes up the inheritance tree have an initialized _schema (issue #4)
parentClass._instantiate();
}
schema = generateSchemaForDocument(instance);
schemaByDocClass.set(this, schema);
this.prototype._schema = schema;
instance._createIndexesOnce();
// console.log('Generated schema for %s, total schemas: %s; schema: %s', this.name, schemaByDocClass.size, schema);
}
// apply schema default values
for (const key of schema[SCHEMA_ARRAY_KEYS]) {
instance[key] = instance._getDefaultValueForKey(key) || [];
}
for (const key of schema[SCHEMA_1PROP_KEYS]) {
instance[key] = instance._getDefaultValueForKey(key);
}
return instance;
}
/**
* @param {Object|Object[]} data - raw POJO(s) from database
* @return {BaseDocument|BaseDocument[]} one or many document instances
*/
static _fromData(data) {
let docs = [];
for (const obj of isArray(data) ? data : [data]) {
let instance = this._instantiate(),
/** @type {CamoSchema} */
schema = instance._schema;
for (const key of Object.keys(obj)) {
if (key === '_id') {
instance._id = obj._id;
continue;
}
let objVal = obj[key],
schemaEntry = schema[key];
if (!schemaEntry) {
if (key === '__proto__') {
throw new Error('Data key __proto__ is not allowed');
}
if (instance.onUnknownData !== NOP) {
instance.onUnknownData(key, objVal);
}
continue;
}
let newVal = (objVal === null || objVal === undefined) ? instance._getDefaultValueForKey(key) : objVal;
if (newVal === null || newVal === undefined) {
instance[key] = newVal;
continue;
}
let schemaType = schemaEntry.type,
basicMapperFn = schemaEntry[SCHEMA_BASIC_FROM_DATA];
if (basicMapperFn) {
instance[key] = basicMapperFn(newVal);
} else if (schemaEntry[ST_IS_TYPED_ARRAY]) {
// Initialize array of EmbeddedDocuments
instance[key] = isArray(newVal) ? newVal.map(schemaType.mapFromData) : [];
} else if (schemaEntry[ST_IS_CUSTOM_TYPE]) {
instance[key] = schemaType.fromData(newVal);
} else {
throw new Error(`Unexpected flow for data key "${key}" - This should never happen`);
}
}
docs.push(instance);
}
return (docs.length === 1) ? docs[0] : docs;
}
/**
* Can be overridden in derived classes for data migration purposes and the likes.
* Invoked when creating an instance manually or by create() with a data object containing
* keys with no corresponding entry in the Document's schema.
*
* @param {string} dataKey
* @param {*} dataVal
*/
onUnknownData(dataKey, dataVal) {
}
populate() {
return BaseDocument.populate(this);
}
/**
* Populates document references
*
* @param {BaseDocument[]|BaseDocument} docOrDocs
* @param {?Array} [fields] if an array, only the contained field names will be populated
* @returns {Promise}
*/
static populate(docOrDocs, fields) {
if (!docOrDocs) {
return Promise.resolve([]);
}
/** @type {BaseDocument[]} */
let documents = isArray(docOrDocs) ? docOrDocs : [docOrDocs];
if (!documents.length) {
return Promise.resolve([]);
}
// Load all 1-level-deep references, Find all unique keys needed to be loaded...
// Assumption here: all documents in the database will have the same schema
let firstSchema = documents[0]._schema,
useFields = fields && Array.isArray(fields) && fields.length;
docClassToIdPopTargetMap.clear();
// Handle multi-reference keys (example schema: { myDocs: [MyDocumentClass] })
for (const key of firstSchema[SCHEMA_REF_N_DOCS_KEYS]) {
if (useFields && fields.indexOf(key) < 0) {
continue;
}
for (const doc of documents) {
let referencedIds = doc[key];
if (isArray(referencedIds) && referencedIds.length) {
let referencedDocClass = firstSchema[key].type.elementType,
id2popTarget = docClassToIdPopTargetMap.get(referencedDocClass);
if (!id2popTarget) {
docClassToIdPopTargetMap.set(referencedDocClass, id2popTarget = Object.create(null));
}
for (const id of referencedIds) {
if (id2popTarget[id]) {
id2popTarget[id].refNDocsAndProps.push(doc, key);
} else {
id2popTarget[id] = {
refNDocsAndProps: [doc, key],
ref1DocsAndProps: []
};
}
}
doc[key] = []; // flush the ids -> referenced values are pushed in later instead
}
}
}
// Handle single reference keys (example schema: { myDoc: MyDocumentClass })
for (const key of firstSchema[SCHEMA_REF_1_DOC_KEYS]) {
if (useFields && fields.indexOf(key) < 0) {
continue;
}
for (const doc of documents) {
let referencedId = doc[key];
if (referencedId && isNativeId(referencedId)) {
let referencedDocClass = firstSchema[key].type,
id2popTarget = docClassToIdPopTargetMap.get(referencedDocClass);
if (!id2popTarget) {
docClassToIdPopTargetMap.set(referencedDocClass, id2popTarget = Object.create(null));
}
if (id2popTarget[referencedId]) {
id2popTarget[referencedId].ref1DocsAndProps.push(doc, key);
} else {
id2popTarget[referencedId] = {
refNDocsAndProps: [],
ref1DocsAndProps: [doc, key]
};
}
}
}
}
let loadPromises = [];
for (const [docClass, id2popTarget] of docClassToIdPopTargetMap.entries()) {
loadPromises.push(docClass.find({_id: {$in: Object.keys(id2popTarget)}}, {populate: false}).then(foundDocs => {
for (const foundDoc of foundDocs) {
let {ref1DocsAndProps, refNDocsAndProps} = id2popTarget[foundDoc._id];
for (let i = 0, len = ref1DocsAndProps.length; i < len; i++) {
let targetDoc = ref1DocsAndProps[i],
targetProp = ref1DocsAndProps[++i];
targetDoc[targetProp] = foundDoc;
}
for (let i = 0, len = refNDocsAndProps.length; i < len; i++) {
let targetDoc = refNDocsAndProps[i],
targetProp = refNDocsAndProps[++i];
targetDoc[targetProp].push(foundDoc);
}
}
}));
}
// ...and finally execute all promises and return our fully loaded documents.
return Promise.all(loadPromises).then(() => docOrDocs);
}
/**
* For JSON.stringify
*
* @returns {*}
* @override
*/
toJSON() {
let obj = this._toData(true);
for (const key of Object.keys(obj)) {
let val = obj[key];
if (val && val.toJSON) {
obj[key] = val.toJSON();
} else if (isArray(val)) {
obj[key] = val.map(v => (v && v.toJSON) ? v.toJSON() : v);
}
}
return obj;
}
/**
* Returns a JSON object copy of a Document/EmbeddedDocument.
* Values of type EmbeddedDocument are converted to POJOs, while Document-type values are preserved.
* The returned document serves as the raw basis for both {@link toJSON} and {@link Document.save}.
*
* IMPORTANT: This methods adheres strictly to the schema,
* i.e. the output contains only such properties that actually have a schema entry!
*
* @param {boolean} [isForJson]
* @returns {Object}
*/
_toData(isForJson) {
let dataObj = Object.create(null);
for (const schemaEntry of this._schema[isForJson ? SCHEMA_ALL_ENTRIES_FOR_JSON : SCHEMA_ALL_ENTRIES_NO_ID]) {
let key = schemaEntry._key,
val = this[key];
if (schemaEntry.type[IS_EMBEDDED]) {
dataObj[key] = val ? val._toData(isForJson) : undefined;
} else if (schemaEntry[ST_IS_EMBED_ARRAY]) {
dataObj[key] = isArray(val) ? (val.length ? val.map(v => v._toData(isForJson)) : []) : undefined;
} else {
dataObj[key] = val;
}
}
return dataObj;
}
/**
* @return {BaseDocument[]} - will NOT contain falsy values
*/
_getSelfAndEmbeddeds() {
let arr = [this];
for (const key of this._schema[SCHEMA_REF_1_OR_N_EMBD_KEYS]) {
let emb = this[key];
if (isArray(emb)) {
arr.push(...emb.filter(o => !!o));
} else if (emb) {
arr.push(emb);
}
}
return arr;
}
/**
* Runs for a given hook name the respective hook functions of the Document and all of its EmbeddedDocuments
*
* @param {string} hookName
* @return {Promise}
* @internal
*/
_executeHook(hookName) {
let hookPromises = [],
totalPromises = 0;
for (const doc of this._getSelfAndEmbeddeds()){
if (doc[hookName] === EMPTY_HOOK) {
continue; // most likely case frequently
}
let hookFn = doc[hookName];
if (typeof hookFn !== 'function') {
return Promise.reject(`Hook ${doc.constructor.name}.${hookName} is not a function`);
}
let promise = hookFn.apply(doc);
if (promise && promise instanceof Promise) {
totalPromises = hookPromises.push(promise);
}
}
return totalPromises === 1 ? hookPromises[0] : totalPromises ? Promise.all(hookPromises) : Promise.resolve();
}
/** @deprecated */
get id() {
throw new Error('Document.id - use Document._id instead');
}
/** @deprecated */
set id(x) {
throw new Error('Document.id - use Document._id instead');
}
}
BaseDocument.prototype._schema = Object.create(null);
BaseDocument.prototype._schema[SCHEMA_ALL_KEYS] = [];
BaseDocument[IS_BASE_DOCUMENT] = true;
BaseDocument.prototype[IS_BASE_DOCUMENT] = true;
const NOP = () => {};
/**
* @type {Map<string, CamoUnknownDataKeyHandler>}
*/
const _unknownDataKeyHandlers = new Map([
['throw', function throwOnUnknownData(dataKey) {
throw new Error(`Unknown data key "${dataKey}" for new ${this.constructor.name}`);
}],
['ignore', NOP],
['accept', function acceptUnkownData(dataKey, dataVal) {
this[dataKey] = dataVal;
}],
['logAndAccept', function logAndAcceptUnkownData(dataKey, dataVal) {
this[dataKey] = dataVal;
console.log(`Accepted unknown data key "${dataKey}" for new ${this.constructor.name}`);
}],
['logAndIgnore', function logAndIgnoreUnkownData(dataKey, dataVal) {
console.log(`Ignored unknown data key "${dataKey}" for new ${this.constructor.name}`);
}]
]);
_unknownDataKeyHandlers.set('default', _unknownDataKeyHandlers.get('throw'));
/**
* @param {'throw'|'ignore'|'accept'|'logAndAccept'|'logAndIgnore'|'default'|CamoUnknownDataKeyHandler} modeOrFn
*/
export const setUnknownDataKeyBehavior = (modeOrFn) => {
let handler = typeof modeOrFn === 'function' ? modeOrFn : _unknownDataKeyHandlers.get(modeOrFn);
if (!handler) {
throw new Error('Invalid argument for setUnknownDataKeyBehavior()');
}
BaseDocument.prototype.onUnknownData = handler;
};
setUnknownDataKeyBehavior('default');
/**
* @return {CamoUnknownDataKeyHandler}
* @internal
*/
export const _getUnknownDataKeyBehavior = () => BaseDocument.prototype.onUnknownData;
Object.assign(BaseDocument.prototype, {
preValidate: EMPTY_HOOK,
postValidate: EMPTY_HOOK,
preSave: EMPTY_HOOK,
postSave: EMPTY_HOOK,
preDelete: EMPTY_HOOK,
postDelete: EMPTY_HOOK
});
/**
* @callback CamoUnknownDataKeyHandler
* @param {string} dataKey
* @param {*} dataValue
* @this {BaseDocument} - the document instance the data is intended for
*/