var Connection = require('./connection'); /** * Class to handle the Database select results. Also allows * updates, insertions and deletions on tables where the primary key is * selected. * * Note that table and column names must be unique in the selection query, * otherwise updates are not allowed on that table/column. If two tables or * columns have the same name, an alias should be used to make it updatable. */ var Model = new Class(); module.exports = Model; var Status = { CLEAN : 1 ,LOADING : 2 ,READY : 3 ,ERROR : 4 }; var Mode = { ON_CHANGE : 1 ,ON_DEMAND : 2 }; var Operation = { INSERT : 1 << 1 ,UPDATE : 1 << 2 ,DELETE : 1 << 3 }; var SortWay = { ASC : 1 ,DESC : 2 }; Model.extend({ Status: Status ,Mode: Mode ,Operation: Operation ,SortWay: SortWay }); Model.implement({ Extends: Vn.Object ,Tag: 'db-model' ,Properties: { /** * The connection used to execute the statement. */ conn: { type: Connection ,set(x) { this._conn = x; this._autoLoad(); } ,get() { return this._conn; } }, /** * The result index. */ resultIndex: { type: Number ,set(x) { this._resultIndex = x; } ,get() { return this._resultIndex; } }, /** * The lot used to execute the statement. */ lot: { type: Vn.LotIface ,set(x) { this.link({_lot: x}, {'change': this._onLotChange}); this._onLotChange(); } ,get() { return this._lot; } }, /** * The model select statement. */ stmt: { type: Sql.Stmt ,set(x) { this._stmt = x; this._autoLoad(); } ,get() { return this._stmt; } }, /** * The model query. */ query: { type: String ,set(x) { this.stmt = new Sql.String({query: x}); } ,get() { if (this._stmt) return this._stmt.render(null); else return null; } }, /** * The main table. */ mainTable: { type: String ,set(x) { this._mainTable = null; this._requestedMainTable = x; this._refreshMainTable(); } ,get() { return this._mainTable; } }, /** * Determines if the model is updatable. */ updatable: { type: Boolean ,set(x) { this._updatable = false; this._requestedUpdatable = x; this._refreshUpdatable(); } ,get() { return this._updatable; } }, /** * The number of rows in the model. */ numRows: { type: Number ,get() { if (this.data) return this.data.length; return 0; } }, /** * The current status of the model. */ status: { type: Number ,get() { return this._status; } }, /** * Checks if the model data is ready. */ ready: { type: Boolean ,get() { return this._status == Status.READY; } }, /** * Update mode. */ mode: { enumType: Mode ,value: Mode.ON_CHANGE }, /** * Wether to execute the model query automatically. */ autoLoad: { type: Boolean ,value: true } } ,_conn: null ,_resultIndex: 0 ,_lot: null ,_stmt: null ,_status: Status.CLEAN ,data: null ,tables: null ,columns: null ,columnMap: null ,_updatable: false ,_paramsChanged: true ,_requestedSortIndex: -1 ,_requestedSortName: null ,_sortColumn: null ,_sortWay: null ,_requestedIndexes: {} ,_indexes: [] ,_requestedUpdatable: false ,_operations: null ,_operationsMap: null ,_defaults: [] ,_requestedMainTable: null ,initialize(props) { Vn.Object.prototype.initialize.call(this, props); this._cleanData(); this._setStatus(Status.CLEAN); } ,appendChild(child) { if (child.nodeType === Node.TEXT_NODE) this.query = child.textContent; } ,loadXml(builder, node) { Vn.Object.prototype.loadXml.call(this, builder, node); var query = node.firstChild.nodeValue; if (query) this.query = query; } ,_getHolders(stmt) { if (!stmt) return null; let holders = this._stmt.findHolders(); if (!holders) return null; if (this._lot) { const params = this._lot.params; for (const holder of holders) if (params[holder] instanceof Sql.Object) { const paramHolders = params[holder].findHolders(); if (paramHolders) holders = holders.concat(paramHolders); } } return holders; } ,_getHolderValues() { let holders = this._getHolders(this._stmt); if (!holders) return null; const lotParams = this._lot ? this._lot.params : {}; const params = {}; for (const holder of holders) if (!(lotParams[holder] instanceof Sql.Object)) params[holder] = lotParams[holder]; return params; } ,_getHolderParams() { let holders = this._getHolders(this._stmt); if (!holders) return null; const lotParams = this._lot ? this._lot.params : {}; const params = {}; for (const holder of holders) params[holder] = lotParams[holder]; return params; } ,_onLotChange() { const params = this._getHolderValues(); this._paramsChanged = !Vn.Value.equals(params, this._lastParams); if (this.autoLoad) this.lazyRefresh(); } ,_autoLoad() { if (this.autoLoad) this.refresh(); else this.clean(); } ,_isReady(params) { if (!this._stmt || !this._conn) return false; for (const param in params) if (params[param] === undefined) return false; return true; } ,async lazyRefresh() { if (this._paramsChanged) await this.refresh(); } ,clean() { this._cleanData(); this._setStatus(Status.CLEAN); } /** * Refresh the model data reexecuting the query on the database. */ ,async refresh() { const params = this._getHolderParams(); if (!this._isReady(params)) { this.clean(); return; } this._setStatus(Status.LOADING); this._lastParams = this._getHolderValues(); this._paramsChanged = false; let result; let dataResult; this._cleanData(); try { const resultSet = await this._conn.execStmt(this._stmt, params); for (let i = 0; result = resultSet.fetchResult(); i++) if (i == this._resultIndex) dataResult = result; if (!dataResult || typeof dataResult !== 'object') throw new Error('The provided statement doesn\'t return a result set'); } catch (e) { this._setStatus(Status.ERROR); throw e; } this.data = dataResult.data; this.tables = dataResult.tables; this.columns = dataResult.columns; this.columnMap = dataResult.columnMap; this._repairColumns(); this._refreshRowIndexes(0); this._refreshMainTable(); for (column in this._requestedIndexes) this._buildIndex(column); let sortColumn = null; if (this._requestedSortName) sortColumn = this._requestedSortName; else if (this._requestedSortIndex !== -1 && this.checkColExists(this._requestedSortIndex)) sortColumn = this.getColumnName(this._requestedSortIndex); if (sortColumn !== null) this._realSort(sortColumn, this._sortWay); this._setStatus(Status.READY); } ,_refreshRowIndexes(start) { for (var i = start; i < this.data.length; i++) this.data[i].index = i; if (this._operationsMap) { this._operationsMap = {}; for (var i = 0; i < this._operations.length; i++) this._operationsMap[i] = this._operations[i]; } } ,_cleanData() { this.data = null; this.tables = null; this.columns = null; this.columnMap = null; this._sortColumn = null; this._indexes = []; this._resetOperations(); } ,_refreshUpdatable() { var oldValue = this._updatable; this._updatable = this._mainTable !== null && this._requestedUpdatable; if (oldValue != this._updatable) this.emit('updatable-changed'); } ,_refreshMainTable() { var newMainTable = null; var tables = this.tables; if (tables) for (var i = 0; i < tables.length; i++) if (tables[i].pks.length > 0) if (!this._requestedMainTable || tables[i].name === this._requestedMainTable) { newMainTable = i; break; } this._mainTable = newMainTable; this._refreshUpdatable(); } /** * Sets the default value for inserted fields. * * @param {String} field The destination field name * @param {String} table The destination table name * @param {Sql.Expr} srcColumn The default value expression */ ,setDefault(field, table, expr) { this._defaults.push({field, table, expr}); } /** * Sets the default value for inserted fields. * * @param {String} field The destination field name * @param {String} table The destination table name * @param {Object} value The default value */ ,setDefaultFromValue(field, table, value) { this._defaults.push({field, table, value}); } /** * Sets the default value for inserted fields from another column in the * model. * * @param {String} field The destination field name * @param {String} table The destination table name * @param {String} srcColumn The source column */ ,setDefaultFromColumn(field, table, srcColumn) { this._defaults.push({field, table, srcColumn}); } /** * Checks if column index exists. * * @param {integer} column The column index * @return {Boolean} %true if column exists, %false otherwise */ ,checkColExists(column) { return this.columns && column >= 0 && column < this.columns.length; } /** * Checks if column name exists. * * @param {string} columnName The column name * @return {Boolean} %true if column exists, %false otherwise */ ,checkColName(columnName) { return this.columnMap && this.columnMap[columnName] != null; } /** * Checks if the row exists. * * @param {integer} rowIndex The row index * @return {Boolean} %true if row exists, %false otherwise */ ,checkRowExists(rowIndex) { return this.data && rowIndex >= 0 && rowIndex < this.data.length; } ,_checkTableUpdatable(tableIndex) { var tableUpdatable = tableIndex !== null && this.tables[tableIndex].pks.length > 0; if (!tableUpdatable && !tableIndex) { if (tableIndex) console.warn("Db.Model: Table %s is not updatable", this.tables[tableIndex].name); else console.warn("Db.Model: Model not updatable"); } return tableUpdatable; } /** * Get the index of the column from its name. * * @param {string} columnName The column name * @return {number} The column index or -1 if column not exists */ ,getColumnIndex(columnName) { if (this.checkColName(columnName)) return this.columnMap[columnName].index; return -1; } /** * Get the index of the column from its name. * * @param {number} columnIndex The column name * @return {string} The column index or -1 if column not exists */ ,getColumnName(columnIndex) { if (this.checkColExists(columnIndex)) return this.columns[columnIndex].name; return null; } /** * Gets the row as object. * * @param {number} rowIndex The row index * @return {Object} The row */ ,getObject(rowIndex) { if (!this.checkRowExists(rowIndex)) return undefined; return this.data[rowIndex]; } /** * Gets a value from the model. * * @param {number} rowIndex The row index * @param {string} columnName The column name * @return {mixed} The value */ ,get(rowIndex, columnName) { if (this.checkRowExists(rowIndex)) return this.data[rowIndex][columnName]; } /** * Updates a value on the model. * * @param {number} rowIndex The row index * @param {string} columnName The column name * @param {mixed} value The new value */ ,async set(rowIndex, columnName, value) { if (!this.checkRowExists(rowIndex) && !this.checkColName(columnName)) return; var tableIndex = this.columnMap[columnName].table; if (!this._checkTableUpdatable(tableIndex)) return; var row = this.data[rowIndex]; var op = this._createOperation(rowIndex); op.type |= Operation.UPDATE; if (!op.oldValues) op.oldValues = []; if (!op.tables) op.tables = {}; var tableOp = op.tables[tableIndex]; if (!tableOp) { tableOp = Operation.UPDATE; var pks = this.tables[tableIndex].pks; for (const pk of pks) if (!row[pk] && !op.oldValues[pk]) { tableOp = Operation.INSERT; break; } op.tables[tableIndex] = tableOp; } if (tableOp & Operation.UPDATE && op.oldValues[columnName] === undefined) op.oldValues[columnName] = row[columnName]; this.emit('row-updated-before', rowIndex); row[columnName] = value; this.emit('row-updated', rowIndex, [columnName]); if (this.mode == Mode.ON_CHANGE && !(op.type & Operation.INSERT)) await this.performOperations(); } /** * Gets a value from the model using the column index. * * @param {number} rowIndex The row index * @param {number} columnIndex The column index * @return {mixed} The value */ ,getByIndex(rowIndex, columnIndex) { var columnName = this.getColumnName(columnIndex); if (columnName) return this.get(rowIndex, columnName); return undefined; } /** * Updates a value on the model using the column index. * * @param {number} rowIndex The row index * @param {number} columnIndex The column index * @param {mixed} value The new value */ ,async setByIndex(rowIndex, columnIndex, value) { var columnName = this.getColumnName(columnIndex); if (columnName) await this.set(rowIndex, columnName, value); else console.warn('Db.Model: Column %d doesn\'t exist', columnIndex); } /** * Deletes a row from the model. * * @param {number} rowIndex The row index */ ,async deleteRow(rowIndex) { if (!this.checkRowExists(rowIndex) || !this._checkTableUpdatable(this._mainTable)) return; var op = this._createOperation(rowIndex); op.type |= Operation.DELETE; if (!this._requestedMainTable) { this.emit('row-deleted-before', rowIndex); this.data.splice(rowIndex, 1); this.emit('row-deleted', rowIndex); this._refreshRowIndexes(rowIndex); } else { this.emit('row-updated-before', rowIndex); if (!op.oldValues) op.oldValues = []; var updatedCols = []; for (var i = 0; i < this.columns.length; i++) if (this.columns[i].table == this._mainTable) { const colName = this.columns[i].name; if (op.oldValues[colName] === undefined) op.oldValues[colName] = op.row[colName]; op.row[colName] = null; updatedCols.push(i); } this.emit('row-updated', rowIndex, updatedCols); } if (this.mode === Mode.ON_CHANGE) await this.performOperations(); } /** * Inserts a new row on the model. * * @return The index of the inserted row */ ,insertRow() { if (!this._checkTableUpdatable(this._mainTable)) return -1; var cols = this.columns; var newRow = {}; for (var i = 0; i < cols.length; i++) if (cols[i].table === this._mainTable) newRow[cols[i].name] = cols[i].def; else newRow[cols[i].name] = null; var rowIndex = this.data.push(newRow) - 1; newRow.index = rowIndex; var op = this._createOperation(rowIndex); op.type |= Operation.INSERT; this.emit('row-inserted', rowIndex); return rowIndex; } /** * Performs all model changes on the database. */ ,async performOperations() { const ops = this._operations; if (ops.length === 0) { this.emit('operations-done'); return; } const stmts = new Sql.MultiStmt(); let query = new Sql.String({query: 'START TRANSACTION'}); stmts.push(query); for (let i = 0; i < ops.length; i++) { query = null; let op = ops[i]; if (op.type & Operation.DELETE) { if (op.type & Operation.INSERT) continue; const where = this._createWhere(this._mainTable, op, true); if (where) { query = new Sql.Delete({where}); query.addTarget(this._createTarget(this._mainTable)); } } else if (op.type & (Operation.INSERT | Operation.UPDATE)) { query = new Sql.MultiStmt(); for (const tableIndex in op.tables) { const stmt = this._createDmlQuery(op, parseInt(tableIndex)); query.push(stmt); } } if (query) { stmts.push(query); } else { console.warn('Db.Model: %s', _('ErrorSavingChanges')); return; } } query = new Sql.String({query: 'COMMIT'}); stmts.push(query); const resultSet = await this._conn.execStmt(stmts); const error = resultSet.getError(); if (error) { this._operations = this._operations.concat(ops); for (let i = 0; i < ops.length; i++) this._operationsMap[ops[i].row.index] = ops[i]; throw error; } resultSet.fetchResult(); let isOperation = false; for (let i = 0; i < ops.length; i++) { const op = ops[i]; const row = op.row; if (!(op.type & Operation.DELETE && op.type & Operation.INSERT)) // eslint-disable-next-line no-unused-vars isOperation = true; if (op.type & Operation.DELETE) { resultSet.fetchResult(); } else if (op.type & (Operation.INSERT | Operation.UPDATE)) { this.emit('row-updated-before', row.index); const updatedCols = []; const cols = this.columns; for (let tableIndex in op.tables) { let j = 0; tableIndex = parseInt(tableIndex); resultSet.fetchResult(); const newValues = resultSet.fetchRow(); if (op.tables[tableIndex] & Operation.INSERT) { for (let i = 0; i < cols.length; i++) if (cols[i].table === tableIndex) { row[cols[i].name] = newValues[j++]; updatedCols.push(i); } } else { for (let i = 0; i < cols.length; i++) if (cols[i].table === tableIndex && op.oldValues[i] !== undefined) { row[cols[i].name] = newValues[j++]; updatedCols.push(i); } } } this.emit('row-updated', row.index, updatedCols); } } resultSet.fetchResult(); // if (isOperation) this.emit('operations-done'); this._resetOperations(); } ,_createDmlQuery(op, tableIndex) { var where = this._createWhere(tableIndex, op, false); if (!where) return null; var multiStmt = new Sql.MultiStmt(); var target = this._createTarget(tableIndex); var select = new Sql.Select({where}); select.addTarget(target); var row = op.row; var cols = this.columns; if (op.tables[tableIndex] & Operation.INSERT) { var dmlQuery = new Sql.Insert(); var table = this.tables[tableIndex]; for (const def of this._defaults) if (def.table === table.name) { if (def.value) dmlQuery.addSet(def.field, def.value); else if (def.expr) dmlQuery.addExpr(def.field, def.expr); else if (def.srcColumn) dmlQuery.addSet(def.field, row[def.srcColumn]); } for (const col of cols) if (col.table === tableIndex) { if (row[col.name] !== null) dmlQuery.addSet(col.orgname, row[col.name]); select.addField(col.orgname); } } else { var updateWhere = this._createWhere(tableIndex, op, true); if (!updateWhere) return null; var dmlQuery = new Sql.Update({where: updateWhere}); for (const col of cols) if (col.table === tableIndex && op.oldValues[col.name] !== undefined) { var fieldName = col.orgname; dmlQuery.addSet(fieldName, row[col.name]); select.addField(fieldName); } } dmlQuery.addTarget(target); multiStmt.push(dmlQuery); multiStmt.push(select); return multiStmt; } /** * Undoes all unsaved changes made to the model. */ ,reverseOperations() { for (var i = 0; i < this._operations.length; i++) { var op = this._operations[i]; var row = op.row; if (op.type & Operation.DELETE && !(op.type & Operation.INSERT)) { this.data.splice(row.index, 0, row); this.emit('row-inserted', row.index); } else if (op.type & Operation.UPDATE) { this.emit('row-updated-before', row.index); var updatedCols = []; var cols = this.columns; for (var i = 0; i < cols.length; i++) if (op.oldValues[i] !== undefined) { const colName = cols[i].name; row[colName] = op.oldValues[colName]; updatedCols.push(i); } this.emit('row-updated', row.index, updatedCols); } } this._resetOperations(); this._refreshRowIndexes(0); } ,_resetOperations() { this._operations = []; this._operationsMap = {}; } /* * Function used to sort the model ascending. */ ,sortFunctionAsc(column, a, b) { if (a[column] < b[column]) return -1; else if (a[column] > b[column]) return 1; return 0; } /* * Function used to sort the model descending. */ ,sortFunctionDesc(column, a, b) { if (a[column] > b[column]) return -1; else if (a[column] < b[column]) return 1; return 0; } /** * Orders the model by the specified column name. * * @param {integer} columnName The column name * @param {SortWay} way The sort way */ ,sortByName(columnName, way) { this._requestedSortIndex = -1; this._requestedSortName = columnName; if (this.checkColName(columnName)) this._sort(columnName, way); } /** * Orders the model by the specified column. * * @param {integer} column The column index * @param {SortWay} way The sort way */ ,sort(column, way) { this._requestedSortIndex = column; this._requestedSortName = null; const columnName = this.getColumnName(column); if (columnName) return; this._sort(columnName, way); } ,_sort(column, way) { this._setStatus(Status.LOADING); this._realSort(column, way); this._setStatus(Status.READY); } ,_realSort(column, way) { if (column !== this._sortColumn) { if (way === SortWay.DESC) var sortFunction = this.sortFunctionDesc; else var sortFunction = this.sortFunctionAsc; this.data.sort(sortFunction.bind(this, column)); } else if (way !== this._sortWay) this.data.reverse(); this._sortColumn = column; this._sortWay = way; this._refreshRowIndexes(0); } /** * Builds an internal hash index for the specified column, this speeds * significantly searches on that column, specially when model has a lot of * rows. * * FIXME: Not fully implemented. * * @param {String} column The column name */ ,indexColumn(column) { this._requestedIndexes[column] = true; if (this._status === Status.READY) this._buildIndex(column); } ,_buildIndex(columnName) { if (this.checkColName(columnName)) { var index = {}; var data = this.data; switch (this.columns[columnName].type) { case Connection.Type.TIMESTAMP: case Connection.Type.DATE_TIME: case Connection.Type.DATE: for (var i = 0; i < data.length; i++) index[data[i][columnName].toString()] = i; break; default: for (var i = 0; i < data.length; i++) index[data[i][columnName]] = i; } this._indexes[columnName] = index; } } /** * Searchs a value on the model and returns the row index of the first * ocurrence. * If an index have been built on that column, it will be used, for more * information see the indexColumn() method. * * @param {String} columnName The column name * @param {Object} value The value to search * @return {integer} The column index */ ,search(columnName, value) { if (!this.checkColName(columnName)) return -1; if (value) switch (this.columnMap[columnName].type) { case Connection.Type.BOOLEAN: value = !!value; break; case Connection.Type.INTEGER: value = parseInt(value); break; case Connection.Type.DOUBLE: value = parseFloat(value); break; default: value = value.toString(); } let rowIndex = -1; const index = this._indexes[columnName]; if (index) { // Searchs the value using an internal index if (index[value] !== undefined) rowIndex = index[value]; } else { // Searchs the value using a loop var data = this.data; switch (this.columnMap[columnName].type) { case Connection.Type.TIMESTAMP: case Connection.Type.DATE_TIME: case Connection.Type.DATE: for (var i = 0; i < data.length; i++) if (value === data[i][columnName].toString()) { rowIndex = i; break; } break; default: for (var i = 0; i < data.length; i++) if (value === data[i][columnName]) { rowIndex = i; break; } } } return rowIndex; } /** * Searchs a value on the model and returns the row index of the first * ocurrence. * * @param {integer} columnIndex The column index * @param {Object} value The value to search * @return {integer} The column index */ ,searchByIndex(columnIndex, value) { var columnName = this.getColumnName(columnIndex); return this.search(columnName, value); } ,_setStatus(status) { this._status = status; this.emit('status-changed', status); this.emit('status-changed-after', status); } ,_createTarget(tableIndex) { var table = this.tables[tableIndex]; return new Sql.Table({ name: table.orgname ,schema: table.schema }); } ,_createWhere(tableIndex, op, useOldValues) { const where = new Sql.Operation({type: Sql.Operation.Type.AND}); const pks = this.tables[tableIndex].pks; if (pks.length === 0) return null; for (const pk of pks) { const column = this.columnMap[pk]; const equalOp = new Sql.Operation({type: Sql.Operation.Type.EQUAL}); equalOp.push(new Sql.Field({name: column.orgname})); where.push(equalOp); let pkValue = null; if (useOldValues && op.oldValues && op.oldValues[pk] !== undefined) pkValue = op.oldValues[pk]; else pkValue = op.row[pk]; if (pkValue) equalOp.push(new Sql.Value({value: pkValue})); else if (column.flags & Connection.Flag.AI && !useOldValues) equalOp.push(new Sql.Function({name: 'LAST_INSERT_ID'})); else return null; } return where; } ,_createOperation(rowIndex) { var op = this._operationsMap[rowIndex]; if (!op) { op = { type: 0, row: this.data[rowIndex] }; this._operations.push(op); this._operationsMap[rowIndex] = op; } return op; } /** * Overrides information about a table and its columns. If a parameter is * not provided, the original will be preserved. This method should be used * primarily to avoid the mysql bug that causes this information will not * be set correctly. * For more information see the following links: * - https://bugs.mysql.com/bug.php?id=44660 * - https://bugs.mysql.com/bug.php?id=26894 * * @param {String} table The table alias * @param {String} orgtable The original table name * @param {String} schema The original table schema * @param {Array} pks Array with the names of primary keys * @param {String} ai The autoincrement column name */ ,setInfo(table, orgname, schema, pks, ai) { if (!this.tableInfo) this.tableInfo = {}; this.tableInfo[table] = { orgname, schema, pks, ai }; this._repairColumns(); } ,_repairColumns() { // Repairs wrong table info if (this.tableInfo && this.tables) for (var i = 0; i < this.tables.length; i++) { var table = this.tables[i]; var tableInfo = this.tableInfo[table.name]; if (!tableInfo) continue; table.orgname = tableInfo.orgname; table.schema = tableInfo.schema; if (tableInfo.pks) { table.pks = []; for (const pk of tableInfo.pks) { if (this.checkColName(pk)) table.pks.push(pk); else console.warn('Db.Model: Can\'t repair primary key: `%s`.`%s`' ,tableInfo.orgname ,pk ); } } if (tableInfo.ai) { if (this.checkColName(tableInfo.ai)) this.columnMap[tableInfo.ai].flags |= Connection.Flag.AI; else console.warn('Db.Model: Can\'t repair autoincrement column: `%s`.`%s`' ,tableInfo.orgname ,tableInfo.ai ); } } } });