UNPKG

qldb-serialiser

Version:
490 lines (440 loc) 18.8 kB
const cloneDeep = require('lodash/cloneDeep'); const {DataTypes} = require('./qldb.datatypes'); class Ledger { constructor(qldbConnection, tableName, model, options) { this.qldbConnection = qldbConnection; this.tableName = tableName; this.primaryKey = null; this.model = model; this.options = options; if (this.options.timestamps == true) { this.model['createdAt'] = { type: DataTypes.TIMESTAMP, }; this.model['updatedAt'] = { type: DataTypes.TIMESTAMP, }; } // QLDB values this.metadata = null; this.blockAddress = null; this.hash = null; // Bindings this.mapDataToModel = this.mapDataToModel.bind(this); this.getByPk = this.getByPk.bind(this); this.getByDocumentId = this.getByDocumentId.bind(this); // instance methods this.instanceMethods = {}; if (this.options.instanceMethods) { this.instanceMethods = this.options.instanceMethods; } this.getPrimaryKeyName(); } /** * bind instance method to returning object which defined in model class * * @param {object} modelObject * @return {object} */ bindInstanceMethod(modelObject) { Object.entries(this.instanceMethods).forEach(([k, v]) => { Object.defineProperty(modelObject, k, { value: v.bind(modelObject), }); }); return modelObject; } /** * build model class instance based on queried object * * @param {*} raw - queried object * @return {object} */ buildModelObject(raw) { if (typeof raw !== 'object') { return raw; } let result = raw; return this.bindInstanceMethod(result); } /** * Synchronize model in ledger: Creates table and indexes if not existed. Call it only once after model is defined. * Creates index on the field if choosen as index and is at top level */ async sync() { await this.qldbConnection.checkTableExistence(this.tableName); for (const fieldName in this.model) { if (this.model[fieldName].index === true) { await this.qldbConnection.checkIfIndexExists(this.tableName, fieldName) } } } /** * Find all records by the supplied arguments * * @param args * @returns {Promise<*>} */ async getBy(args) { const results = await this.qldbConnection.findBy(this.tableName, this.model, args); let boundResults = results.map((res) => this.buildModelObject(res)); // Add the pagination info to to the bound results if (results.rows) { boundResults.rows = results.rows; } return boundResults; }; /** * Find one records by the supplied arguments, if an array is found only the first object is returned * * @param args * @returns {Promise<*>} */ async getOneBy(args) { const result = await this.qldbConnection.findOneBy(this.tableName, this.model, args); return this.buildModelObject(result); }; /** * Delete one or more records. The option {recursive: true} in the arguments will delete linked entries that are * defined in the model as LEDGER. Note that the record(s) only are removed from the active table. They will remain * in the _ql_committed tables. * * @param args * @returns {Promise<void>} */ async delete(args) { return await this.qldbConnection.delete(this.tableName, this.model, args); } /** * Find a record by its defined Primary Key. The model needs to have one field as 'primaryKey: true,' * * @param id * @returns {Promise<*>} */ async getByPk(id) { let args = {}; const primaryKeyName = await this.getPrimaryKeyName(); if (primaryKeyName) { args[primaryKeyName] = id; } else { return false; } const result = await this.qldbConnection.findOneBy(this.tableName, this.model,{where:args}); return this.buildModelObject(result); }; /** * Find historic changes of a record based on the primary key. The model needs to have one field as 'primaryKey: true,' * If the start and/or end date lies in the future there is an error returned. * * @param PkId * @param startDate * @param endDate * @returns {Promise<boolean|*>} */ async getHistoryByPk(PkId, startDate = null, endDate = null) { const primaryKeyName = await this.getPrimaryKeyName(); if (!primaryKeyName) { return false; } const now = new Date(); if ((Date.parse(startDate) > now) || (Date.parse(endDate) > now)) { return 'invalid_dates'; } let whereArgs = {} whereArgs[primaryKeyName] = PkId; const args = { where: whereArgs, useMetaData: false, startDate: startDate, endDate: endDate } const results = await this.qldbConnection.getHistory(this.tableName, args); return results.map((res) => { const { data } = res; if (data) { Object.assign(res, { data: this.buildModelObject(data) }); } return res; }); }; /** * Find historic changes of a record based on the document id. If the start and/or end date lies in the future there * is an error returned. * * @param documentId * @param startDate * @param endDate * @returns {Promise<boolean|*>} */ async getHistoryByDocumentId(documentId, startDate = null, endDate = null) { const now = new Date(); if ((Date.parse(startDate) > now) || (Date.parse(endDate) > now)) { return 'invalid_dates'; } const args = { where: {id: documentId}, useMetaData: true, startDate: startDate, endDate: endDate } const results = await this.qldbConnection.getHistory(this.tableName, args); return results.map((res) => { const { data } = res; if (data) { Object.assign(res, { data: this.buildModelObject(data) }); } return res; }); }; /** *Find historic changes of a record based on the supplied arguments. if empty object is passed as argument, it returns history of whole table * @param args * @returns {Promise<void>} */ async getHistoryBy(args) { const results = await this.qldbConnection.getHistory(this.tableName, args); return results.map((res) => { const { data } = res; if (data) { Object.assign(res, { data: this.buildModelObject(data) }); } return res; }); }; /** * Gets committed document with its metadata and hashes. * * @param args * @returns {Promise<*>} */ async getCommittedDocumentBy(args) { const results = await this.qldbConnection.findCommittedDocument(this.tableName, args); return results.map((res) => { const { data } = res; if (data) { Object.assign(res, { data: this.buildModelObject(data) }); } return res; }); }; /** * Gets a document by the document.id in the metadata. * * @param value * @returns {Promise<*>} */ async getByDocumentId(value) { let args = {} args['metadata.id'] = value; const results = await this.qldbConnection.findCommittedData(this.tableName,{where:args}); return results.map((res) => { const { data } = res; if (data) { Object.assign(res, { data: this.buildModelObject(data) }); } return res; }); }; async getDocumentIdByPK(pkValue) { let args = {} args['data.' + this.primaryKey] = pkValue; const results = await this.qldbConnection.findCommittedData(this.tableName,{where:args}); return results.map((res) => { const { data } = res; if (data) { Object.assign(res, { data: this.buildModelObject(data) }); } return res; }); }; /** * Add the data to the QLDB but first map it to the model and return potential errors * * @param data * @returns {Promise<unknown>} */ async add(data, maxDepth=3) { const modelCopy = cloneDeep(this.model); //check if table exists or not. if not existed, creates table and insert data into it //await this.qldbConnection.checkTableExistence(this.tableName); --> removing because of performance issues(fixme later workaround). const mappedModel = await this.mapDataToModel(data, modelCopy, 0, maxDepth); if (mappedModel.errors) { return {errors: mappedModel.errors}; } // return mappedModel; return this.qldbConnection.create(this.tableName,mappedModel).then((results) =>{ return results; }); }; /** * Update a record by supplying arguments. the structure of the args object is as follows * args = { * fields: {fieldName: FieldValue}, * where: {fieldName: FieldValue} * } * * @param args object * @returns {Promise<any|*[]>} */ async update(args, maxDepth=3) { const modelCopy = cloneDeep(this.model); const mappedModel = await this.mapDataToModel(args.fields, modelCopy, 0, maxDepth, true); if (mappedModel.errors) { return {errors: mappedModel.errors}; } if (this.options.timestamps == true){ args.fields.updatedAt = mappedModel.updatedAt.value; } return this.qldbConnection.update(this.tableName, args, mappedModel).then((results) =>{ return results; }); } /** * get the name of the primary key of this model * * @returns {string|boolean} */ async getPrimaryKeyName() { if (this.primaryKey !== null) { return this.primaryKey; } for (const [fieldName, FieldOptions] of Object.entries(this.model)) { if (FieldOptions.primaryKey == true) { this.primaryKey = fieldName; return fieldName; } } return false; } /** * Maps the given data to the model and validates it against the model type. Use the maxDepth to limit the amount of * data in the model. * * @param data The data received at the endpoint * @param model The model associated with this level * @param currentDepth Active depth, used to calculate the maxdepth * @param maxDepth Number of levels down that are used to prevent deadlocks and massive data usage. Defaults to 3 * @param isUpdate Marks the model as an update for the creation of an update model. It ignores the pk and missing checks * * @returns {Promise<{errors: []}|boolean|*>} */ async mapDataToModel(data, model, currentDepth = 0, maxDepth = 3, isUpdate = false) { if (currentDepth >= maxDepth) { return true; } let errors = []; // updating document createdAt/updatedAt timestamps const date = new Date().toISOString(); if (!isUpdate && this.options.timestamps === true) { model.createdAt.value = date; model.updatedAt.value = date; } if (isUpdate && this.options.timestamps === true) { model.updatedAt.value = date; } for (const [fieldName, fieldOptions] of Object.entries(model)) { // Check if the field is present in the data if (data[fieldName] != null) { let fieldType = fieldOptions.type.name.toLowerCase() if (fieldType == 'ledger') { // Check the values in the linked model /** * If the data is not an object, check if there is a primary key with the value of the data. */ if (typeof data[fieldName] != 'object') { const result = await fieldOptions.model.getByPk(data[fieldName]); if (Object.keys(result).length == 0) { errors.push({ field: fieldName, message: 'document_reference_not_found', value: data[fieldName] }); } // Change Ledger model to String and set value to prevent creation of a new record model[fieldName].value = data[fieldName]; //result[0].metadata.id; model[fieldName].type = DataTypes.STRING; delete model[fieldName]['model']; } else { let result = await fieldOptions.model.mapDataToModel(data[fieldName], fieldOptions.model.model, currentDepth + 1, maxDepth, isUpdate); if (result.errors) { result.errors.forEach(error => { errors.push({ field: fieldName + '.' + error.field, message: error.message }); }); } } model[fieldName].value = data[fieldName]; } else if (fieldType == 'json') { // JSON data is ignored in checking and used 'as is' model[fieldName].value = data[fieldName]; } else if (typeof data[fieldName] == fieldType) { // Check if the value is of the correct type if (fieldType == 'object') { let result = await this.mapDataToModel(data[fieldName], fieldOptions.model, currentDepth + 1, maxDepth, isUpdate); if (result.errors) { result.errors.forEach(error => { errors.push({ field: fieldName + '.' + error.field, message: error.message }); }); } } // Check if the entered field is a primary key and if so check id that already exists if ((fieldOptions.primaryKey) && (!isUpdate)) { const result = await this.getByPk(data[fieldName]); if (Object.keys(result).length != 0) { errors.push({ field: fieldName, message: 'pk_reference_duplicate', value: data[fieldName] }); } else { model[fieldName].value = data[fieldName]; } } else { model[fieldName].value = data[fieldName]; } /** * Arrays are mistaken for objects by the node typeof function. An array is an array of objects or any * other type. When checking an array we need to check for one or more value sets. The code will fill * the model section with the values of the last value-set. When creating the SQL for insertion these * values will be ignored and the values are taken from the value member on the model level (one up). */ } else if (Array.isArray(data[fieldName])) { for (const element of data[fieldName]) { let result = null; // if the model is not defined in array datatype, it will map the data as is. useful for array of strings, numbers, JSON e.t.c if (typeof fieldOptions.model === 'undefined') { result = await this.mapDataToModel(element, fieldOptions, currentDepth, maxDepth, isUpdate); } // Nested arrays of linked Ledger models else if ((fieldOptions.model.constructor) && (fieldOptions.model.constructor.name.toLowerCase() == 'ledger')) { result = await fieldOptions.model.mapDataToModel(element, fieldOptions.model.model, currentDepth + 1, maxDepth, isUpdate); } else { result = await this.mapDataToModel(element, fieldOptions.model, currentDepth , maxDepth, isUpdate); } if (result.errors) { result.errors.forEach(error => { errors.push({field: fieldName + '.' + error.field , message: error.message}); }); } } model[fieldName].value = data[fieldName]; } else { errors.push({ field: fieldName, message: 'invalid_value', expected: fieldType, received: typeof data[fieldName]}); } // If the value is not present but has a default value in the model then use that } else if (model[fieldName].default !== undefined) { model[fieldName].value = model[fieldName].default; // Set the value to NULL if allowed } else if (model[fieldName].allowNull == true) { model[fieldName].value = null; } else if ((model[fieldName].allowNull == false) && (!isUpdate)) { // If the value is not allowed to be null add an error errors.push({ field: fieldName, message: 'missing'}); } }; // If errors occurred, return them otherwise return the model. if (errors.length > 0) { return { errors: errors }; } return model; } } module.exports = { Ledger }