var Connection = require ('./connection'); var VnModel = require ('vn/model'); /** * 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 Klass = new Class (); module.exports = Klass; var Status = VnModel.Status; var SortWay = VnModel.SortWay; var Mode = { ON_CHANGE : 1 ,ON_DEMAND : 2 }; var Operation = { INSERT : 1 << 1 ,UPDATE : 1 << 2 ,DELETE : 1 << 3 }; Klass.extend ({ Status: Status ,SortWay: SortWay ,Mode: Mode ,Operation: Operation }); Klass.implement ({ Extends: VnModel ,Tag: 'db-model' ,Properties: { /** * The connection used to execute the statement. */ conn: { type: Connection ,set: function (x) { this._conn = x; this._autoLoad (); } ,get: function () { return this._conn; } }, /** * The result index. */ resultIndex: { type: Number ,set: function (x) { this._resultIndex = x; } ,get: function () { return this._resultIndex; } }, /** * The lot used to execute the statement. */ lot: { type: Vn.LotIface ,set: function (x) { this.link ({_lot: x}, {'change': this._onLotChange}); this._onLotChange (); } ,get: function () { return this._lot; } }, /** * The remote filter used for query. */ filter: { type: Sql.Filter ,set: function (x) { this._filter = x; this.refresh (); } ,get: function () { return this._filter; } }, /** * The model select statement. */ stmt: { type: Sql.Stmt ,set: function (x) { this._stmt = x; this._autoLoad (); } ,get: function () { return this._stmt; } }, /** * The model query. */ query: { type: String ,set: function (x) { this.stmt = new Sql.String ({query: x}); } ,get: function () { if (this._stmt) return this._stmt.render (null); else return null; } }, /** * The main table. */ mainTable: { type: String ,set: function (x) { this._mainTable = null; this._requestedMainTable = x; this._refreshMainTable (); } ,get: function () { return this._mainTable; } }, /** * Determines if the model is updatable. */ updatable: { type: Boolean ,set: function (x) { this._updatable = false; this._requestedUpdatable = x; this._refreshUpdatable (); } ,get: function () { return this._updatable; } }, /** * 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 ,result: null ,tables: null ,columns: null ,columnMap: null ,_updatable: false ,_requestedSortIndex: -1 ,_requestedUpdatable: false ,_operations: null ,_operationsMap: null ,_defaults: [] ,_requestedMainTable: null ,initialize: function (props) { this.parent (props); this._cleanData (); this._setStatus (Status.CLEAN); } ,appendChild: function (child) { if (child.nodeType === Node.TEXT_NODE) this.query = child.textContent; } ,_getLotParams: function () { if (!this._stmt) return null; var holders = this._stmt.findHolders (); if (!holders) return null; var lotParams = this._lot ? this._lot.params : {}; if (lotParams == null) lotParams = {}; var params = {}; for (var i = 0; i < holders.length; i++) params[holders[i]] = lotParams[holders[i]]; return params; } ,_onLotChange: function () { var lotParams = this._getLotParams (); if (!Vn.Value.equals (lotParams, this._lastLotParams)) this._autoLoad (); } ,_autoLoad: function () { if (this.autoLoad) this.refresh (); else this.clean (); } ,_isReady: function (params) { if (!this._stmt || !this._conn) return false; var holders = this._stmt.findHolders (); if (!holders) return true; for (var i = 0; i < holders.length; i++) if (params[holders[i]] === undefined) return false; return true; } /** * Refresh the model data reexecuting the query on the database. */ ,refresh: function (params) { var lotParams = this._getLotParams (); var myParams = {}; Object.assign (myParams, lotParams); Object.assign (myParams, params); if (this._filter && (!params || params.filter === undefined)) myParams.filter = this._filter; this._lastLotParams = lotParams; if (this._isReady (myParams)) { this._setStatus (Status.LOADING); this._conn.execStmt (this._stmt, this._selectDone.bind (this), myParams); } else this.clean (); } ,clean: function () { this._cleanData (); this._setStatus (Status.CLEAN); } ,_selectDone: function (resultSet) { var result; var dataResult; this._cleanData (); try { for (var i = 0; dataResult = resultSet.fetchResult (); i++) if (i == this._resultIndex) result = dataResult; if (!result || typeof result !== 'object') throw new Error ('The provided statement doesn\'t return a result set'); } catch (e) { this._setStatus (Status.ERROR); throw e; } this.result = result; this.tables = result.tables; this.columns = result.columns; this.columnMap = {}; for (var i = 0; i < this.columns.length; i++) this.columnMap[this.columns[i].name] = i; this._repairColumns (); this._refreshMainTable (); if (this._requestedSortIndex !== -1) { sortColumn = this.getColumnName (this._requestedSortIndex); if (sortColumn) this._requestedSortName = sortColumn; } this._setData (result.data); } ,_cleanData: function () { this.result = null; this._data = null; this.tables = null; this.columns = null; this.columnMap = null; this._sortColumn = -1; this._indexes = []; this._resetOperations (); } ,_refreshUpdatable: function () { var oldValue = this._updatable; this._updatable = this._mainTable !== null && this._requestedUpdatable; if (oldValue != this._updatable) this.emit ('updatable-changed'); } ,_refreshMainTable: function () { 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 (); } /** * Checks if the column index exists. * * @param {Number} column The column index * @return {Boolean} %true if column exists, %false otherwise */ ,checkColumnIndex: function (column) { return this.columns && column >= 0 && column < this.columns.length; } ,_checkTableUpdatable: function (tableIndex) { var tableUpdatable = tableIndex !== null && this.tables[tableIndex].pks.length > 0; if (!tableUpdatable) console.warn ("Db.Model: Table %s is not updatable", this.tables[tableIndex].name); 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: function (columnName) { var index; if (this.columnMap && (index = this.columnMap[columnName]) !== undefined) return index; return -1; } /** * Get the index of the column name from its index. * * @param {String} columnIndex The column index * @return {Number} The column name or undefined if column not exists */ ,getColumnName: function (columnIndex) { if (!(this.columns && this.checkColumnIndex (columnIndex))) return null; return this.columns[columnIndex].name; } /** * Updates a value on the model using the column name. * * @param {Number} rowIndex The row index * @param {Number} columnName The column name * @param {*} value The new value */ ,set: function (rowIndex, columnName, value) { if (!this.checkRowExists (rowIndex)) return; var columnIndex = this.getColumnIndex (columnName); if (columnIndex === -1) return; var tableIndex = this.columns[columnIndex].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 (var i = 0; i < pks.length; i++) { var pkName = this.columns[pks[i]].name; if (!row[pkName] && !op.oldValues[pkName]) { tableOp = Operation.INSERT; break; } } op.tables[tableIndex] = tableOp; } if (tableOp & Operation.UPDATE && op.oldValues[columnName] === undefined) op.oldValues[columnName] = row[columnName]; this.parent (rowIndex, columnName, value); if (this.mode == Mode.ON_CHANGE && !(op.type & Operation.INSERT)) this.performOperations (); } /** * Gets a value from the model. * * @param {Number} rowIndex The row index * @param {String} columnIndex The column index * @return {*} The value */ ,getByIndex: function (rowIndex, columnIndex) { var columName = this.getColumnName (columnIndex); if (columName == null) return undefined; return this.get (rowIndex, columName); } /** * Updates a value on the model. * * @param {Number} rowIndex The row index * @param {String} columnIndex The column index * @param {*} value The new value */ ,setByIndex: function (rowIndex, columnIndex, value) { var columName = this.getColumnName (columnIndex); if (columName == null) { console.warn ('Column %s doesn\'t exist', columnIndex); return; } this.set (rowIndex, columName, value); } /** * Returns an array with model column names. * * @return {Array} The column names */ ,keys: function () { return this.ready ? Object.keys (this._model.columnMap) : null; } /** * Sets the default value for inserted rows. * * @param {String} column The destination column name * @param {String} table The destination table name * @param {Sql.Expr} expr The default value expression */ ,setDefault: function (column, table, expr) { this._defaults.push ({ column: column ,table: table ,expr: expr }); } /** * Sets the default value for inserted rows. * * @param {String} column The destination column name * @param {String} table The destination table name * @param {*} value The default value */ ,setDefaultFromValue: function (column, table, value) { this._defaults.push ({ column: column ,table: table ,value: value }); } /** * Sets the default value for inserted rows from another column in the * model. * * @param {String} column The destination column name * @param {String} table The destination table name * @param {String} srcColumn The source column */ ,setDefaultFromColumn: function (column, table, srcColumn) { this._defaults.push ({ column: column ,table: table ,srcColumn: srcColumn }); } /** * Deletes a row from the model. * * @param {Number} rowIndex The row index */ ,deleteRow: function (rowIndex) { if (!this.checkRowExists (rowIndex) || !this._checkTableUpdatable (this._mainTable)) return; var op = this._createOperation (rowIndex); op.type |= Operation.DELETE; if (!this._requestedMainTable) { this.parent (rowIndex); } else { this.emit ('row-updated-before', rowIndex); if (!op.oldValues) op.oldValues = {}; var cols = this.columns; var updatedCols = []; for (var i = 0; i < cols.length; i++) if (cols[i].table === this._mainTable) { var columnName = cols[i].name; if (op.oldValues[columnName] === undefined) op.oldValues[columnName] = op.row[columnName]; op.row[columnName] = null; updatedCols.push (columnName); } this.emit ('row-updated', rowIndex, updatedCols); } if (this.mode === Mode.ON_CHANGE) this.performOperations (); } /** * Inserts a new row on the model. * * @return The index of the inserted row */ ,insertRow: function () { 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.parent (newRow); var op = this._createOperation (rowIndex); op.type |= Operation.INSERT; return rowIndex; } /** * Orders the model by the specified column. * * @param {Number} columnIndex The column index * @param {SortWay} way The sort way */ ,sortByIndex: function (columnIndex, way) { this._requestedSortIndex = columnIndex; var columnName = this.getColumnName (columnIndex); if (columnName == null) return; this._sort (columnName, way); } ,sort: function (columnName, way) { this._requestedSortIndex = -1; this._sort (columnName, way); } /** * 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} columnIndex The column index * @param {Object} value The value to search * @return {Number} The column index */ ,searchByIndex: function (columnIndex, value) { var columnName = this.getColumnName (columnIndex); return this.search (columnName, value); } /** * Performs all model changes on the database. */ ,performOperations: function () { var ops = this._operations; if (ops.length === 0) { this.emit ('operations-done'); return; } var stmts = new Sql.MultiStmt (); var query = new Sql.String ({query: 'START TRANSACTION'}); stmts.push (query); for (var i = 0; i < ops.length; i++) { query = null; var op = ops[i]; if (op.type & Operation.DELETE) { if (op.type & Operation.INSERT) continue; var where = this._createWhere (this._mainTable, op, true); if (where) { query = new Sql.Delete ({ where: where, limit: 1 }); query.addTarget (this._createTarget (this._mainTable)); } } else if (op.type & (Operation.INSERT | Operation.UPDATE)) { query = new Sql.MultiStmt (); for (var tableIndex in op.tables) { var stmt = this._createDmlQuery (op, parseInt (tableIndex)); query.push (stmt); } } if (query) { stmts.push (query); } else { console.warn ('Db.Model: %s', _('ErrorSavingChanges')); return; } } var query = new Sql.String ({query: 'COMMIT'}); stmts.push (query); this._conn.execStmt (stmts, this._onOperationsDone.bind (this, ops)); this._resetOperations (); } ,_createDmlQuery: function (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: where, limit: 1 }); 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 (var i = 0; i < this._defaults.length; i++) { var def = this._defaults[i]; if (def.table === table.name) { if (def.value) dmlQuery.addSet (def.column, def.value); else if (def.expr) dmlQuery.addExpr (def.column, def.expr); else if (def.srcColumn) dmlQuery.addSet (def.column, row[def.srcColumn]); } } for (var i = 0; i < cols.length; i++) if (cols[i].table === tableIndex) { if (row[i] !== null) dmlQuery.addSet (cols[i].orgname, row[cols[i].name]); select.addField (cols[i].orgname); } } else { var updateWhere = this._createWhere (tableIndex, op, true); if (!updateWhere) return null; var dmlQuery = new Sql.Update ({ where: updateWhere, limit: 1 }); for (var i = 0; i < cols.length; i++) if (cols[i].table === tableIndex && op.oldValues[cols[i].name] !== undefined) { var fieldName = cols[i].orgname; dmlQuery.addSet (fieldName, row[cols[i].name]); select.addField (fieldName); } } dmlQuery.addTarget (target); multiStmt.push (dmlQuery); multiStmt.push (select); return multiStmt; } ,_onOperationsDone: function (ops, resultSet) { var error = resultSet.getError (); if (error) { this._operations = this._operations.concat (ops); for (var i = 0; i < ops.length; i++) this._operationsMap[ops[i].row.index] = ops[i]; throw error; } resultSet.fetchResult (); for (var i = 0; i < ops.length; i++) { var op = ops[i]; var row = op.row; var type = op.type; if (type & Operation.DELETE) { resultSet.fetchResult (); } else if (type & (Operation.INSERT | Operation.UPDATE)) { this.emit ('row-updated-before', row.index); var updatedCols = []; var cols = this.columns; for (var tableIndex in op.tables) { var j = 0; tableIndex = parseInt (tableIndex); resultSet.fetchResult (); var newValues = resultSet.fetchRow (); if (op.tables[tableIndex] & Operation.INSERT) { for (var i = 0; i < cols.length; i++) if (cols[i].table === tableIndex) { row[cols[i].name] = newValues[j++]; updatedCols.push (cols[i].name); } } else { for (var i = 0; i < cols.length; i++) if (cols[i].table === tableIndex && op.oldValues[cols[i].name] !== undefined) { row[cols[i].name] = newValues[j++]; updatedCols.push (cols[i].name); } } } this.emit ('row-updated', row.index, updatedCols); } } resultSet.fetchResult (); this.emit ('operations-done'); } ,_createTarget: function (tableIndex) { var table = this.tables[tableIndex]; return new Sql.Table ({ name: table.orgname ,schema: table.schema }); } ,_createWhere: function (tableIndex, op, useOldValues) { var Type = Sql.Operation.Type; var where = new Sql.Operation ({type: Type.AND}); var pks = this.tables[tableIndex].pks; if (pks.length === 0) return null; for (var i = 0; i < pks.length; i++) { var column = this.columns[pks[i]]; var equalOp = new Sql.Operation ({type: Type.EQUAL}); equalOp.push (new Sql.Field ({name: column.orgname})); where.push (equalOp); var pkValue = null; if (useOldValues && op.oldValues && op.oldValues[column.name] !== undefined) pkValue = op.oldValues[column.name]; else pkValue = op.row[column.name]; 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: function (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; } /** * Undoes all unsaved changes made to the model. */ ,reverseOperations: function () { 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[cols[i].name] !== undefined) { var columnName = cols[i].name; row[columnName] = op.oldValues[columnName]; updatedCols.push (columnName); } this.emit ('row-updated', row.index, updatedCols); } } this._resetOperations (); this._refreshRowIndexes (0); } ,_resetOperations: function () { this._operations = []; this._operationsMap = {}; } ,_refreshRowIndexes: function (start) { this.parent (start); if (this._operationsMap) { this._operationsMap = {}; for (var i = 0; i < this._operations.length; i++) this._operationsMap[i] = this._operations[i]; } } /** * 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: function (table, orgname, schema, pks, ai) { if (!this.tableInfo) this.tableInfo = {}; this.tableInfo[table] = { orgname: orgname, schema: schema, pks: pks, ai: ai }; this._repairColumns (); } ,_repairColumns: function () { // 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 (var j = 0; j < tableInfo.pks.length; j++) { var colIndex = this.getColumnIndex (tableInfo.pks[j]); if (colIndex !== -1) table.pks.push (colIndex); else console.warn ('Db.Model: Can\'t repair primary key: `%s`.`%s`' ,tableInfo.orgname ,tableInfo.pks[j] ); } } if (tableInfo.ai) { var colIndex = this.getColumnIndex (tableInfo.ai); if (colIndex !== -1) this.columns[colIndex].flags |= Connection.Flag.AI; else console.warn ('Db.Model: Can\'t repair autoincrement column: `%s`.`%s`' ,tableInfo.orgname ,tableInfo.ai ); } } } });