Commit 1cf6fd11 authored by Carsten  Rose's avatar Carsten Rose

Merge branch 'develop' into 'master'

Develop

See merge request !233
parents 55163328 48dda007
Pipeline #3105 passed with stages
in 1 minute and 54 seconds
<!-- -*- markdown -*- -->
# Record locking
## Concept: Late locking
* A lock is required on first modification.
* Multiple forms might open the same record, all seems to have write access. The first one who modifies the record
get the lock, all following will switch to form=readonly on their first try to modify the record.
## Lock mode: Exclusive
* A lock can't be overwritten.
## Lock mode: Advisory
* A lock can be ignored.
* Last save win's.
## Lock mode: None
* No locking at all.
# Workarounds
* At least one Browser (FF 71, maybe other in the future too), do not allow to wrap the 'leave page' dialog anymore.
This might result in stale lock files (modified record, click on browser tab close or any link), cause the lock
logic does not know that the user leaves the page.
* Workaround: before 'do you want to leave the page' appears, the lock is released, independent if the user answers 'no'.
As soon as the users modifies the record again, a new lock is acquired. This is better than a stale record lock.
* Reload a page (F5) on a modified record, opens the form in readonly mode (record lock found).
* Reason: the lock release is fired by the browser AFTER form load - than the lock-logic reports 'record is already locked'.
* Workaround: with the above workaround, this does not happen anymore. Nevertheless, a 'tabUniqId' has been implemented.
That one is saved as record lock and a page reload origin can be identified as the same tab as where the lock has been
acquired.
= State Diagram =
See `Documentation-develop/diagram` for a state diagram.
......@@ -2255,7 +2255,7 @@ If a timeout expires, the lock becomes invalid. During the next change in a form
A lock is assigned to a record of a table. Multiple forms, with the same primary table, uses the same lock for a given record.
If a `Form` acts on further records (e.g. via FE action), those records are not protected by this basic record locking.
If a `Form` acts on further records (e.g. via FE action), those further records are not protected by this basic record locking.
If a user tries to delete a record and another user already owns a lock on that record, the delete action is denied.
......
......@@ -1372,6 +1372,7 @@ const QUERY_TYPE_SELECT = 'type: select,show,describe,explain';
const QUERY_TYPE_INSERT = 'type: insert';
const QUERY_TYPE_UPDATE = 'type: update,replace,delete';
const QUERY_TYPE_CONTROL = 'type: set';
const QUERY_TYPE_FAILED = 'type: query failed';
//Regexp
//const REGEXP_DATE_INT = '^\d{4}-\d{2}-\d{2}$';
......@@ -1816,7 +1817,7 @@ const DIRTY_API_ACTION_EXTEND = 'extend';
const LOCK_NOT_FOUND = 0;
const LOCK_FOUND_OWNER = 1;
const LOCK_FOUND_CONFLICT = 2;
const TAB_UNIQ_ID = 'tabUniqId'; // Currently only only a uniq identifier: no values stored behind the identifier - might change.
const TAB_UNIQ_ID = 'tabUniqId'; // Uniq identifier per tab: no values stored behind the identifier - might change.
// AutoCron
const AUTOCRON_MAX_AGE_MINUTES = 10;
......
......@@ -181,6 +181,7 @@ class Database {
* @param string $specificMessage
* @param array $keys
* @param array $stat DB_NUM_ROWS | DB_INSERT_ID | DB_AFFECTED_ROWS
* @param array $skipErrno
*
* @return array|int
* SELECT | SHOW | DESCRIBE | EXPLAIN: see $mode
......@@ -190,7 +191,7 @@ class Database {
* @throws \DbException
* @throws \UserFormException
*/
public function sql($sql, $mode = ROW_REGULAR, array $parameterArray = array(), $specificMessage = '', array &$keys = array(), array &$stat = array()) {
public function sql($sql, $mode = ROW_REGULAR, array $parameterArray = array(), $specificMessage = '', array &$keys = array(), array &$stat = array(), array $skipErrno = array()) {
$queryType = '';
$result = array();
$this->closeMysqliStmt();
......@@ -205,7 +206,7 @@ class Database {
$specificMessage .= " ";
}
$count = $this->prepareExecute($sql, $parameterArray, $queryType, $stat, $specificMessage);
$count = $this->prepareExecute($sql, $parameterArray, $queryType, $stat, $specificMessage, $skipErrno);
if ($count === false) {
throw new \DbException($specificMessage . "No idea why this error happens - please take some time and check the problem.", ERROR_DB_GENERIC_CHECK);
......@@ -339,23 +340,29 @@ class Database {
* Returns the number of selected rows (SELECT, SHOW, ..) or the affected rows (UPDATE, INSERT). $stat contains
* appropriate num_rows, insert_id or rows_affected.
*
* In case of an error, throw an exception.
* mysqli error code listed in $skipErrno[] do not throw an error.
*
* @param string $sql SQL statement with prepared statement variable.
* @param array $parameterArray parameter array for prepared statement execution.
* @param string $queryType returns QUERY_TYPE_SELECT | QUERY_TYPE_UPDATE | QUERY_TYPE_INSERT, depending on
* the query.
* @param array $stat DB_NUM_ROWS | DB_INSERT_ID | DB_AFFECTED_ROWS
*
* @param string $specificMessage
* @param array $skipErrno
*
* @return int|mixed
* @throws \CodeException
* @throws \DbException
* @throws \UserFormException
*/
private function prepareExecute($sql, array $parameterArray, &$queryType, array &$stat, $specificMessage = '') {
private function prepareExecute($sql, array $parameterArray, &$queryType, array &$stat, $specificMessage = '', array $skipErrno = array()) {
// Only log a modify type statement here if sqlLogMode is (at least) modifyAll
// If sqlLogMode is modify, log the statement after it has been executed and we know if there are affected rows.
$sqlLogMode = $this->isSqlModify($sql) ? SQL_LOG_MODE_MODIFY_ALL : SQL_LOG_MODE_ALL;
$errno = 0;
$result = 0;
$stat = array();
$errorMsg[ERROR_MESSAGE_TO_USER] = empty($specificMessage) ? 'SQL error' : $specificMessage;
......@@ -372,34 +379,52 @@ class Database {
$this->dbLog($sqlLogMode, $sql, $parameterArray);
if (false === ($this->mysqli_stmt = $this->mysqli->prepare($sql))) {
$this->dbLog(SQL_LOG_MODE_ERROR, $sql, $parameterArray);
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] = $this->getSqlHint($sql, $this->mysqli->error);
$errorMsg[ERROR_MESSAGE_OS] = '[ mysqli: ' . $this->mysqli->errno . ' ] ' . $this->mysqli->error;
throw new \DbException(json_encode($errorMsg), ERROR_DB_PREPARE);
if ($skipErrno === array() && false === array_search($this->mysqli->errno, $skipErrno)) {
$this->dbLog(SQL_LOG_MODE_ERROR, $sql, $parameterArray);
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] = $this->getSqlHint($sql, $this->mysqli->error);
$errorMsg[ERROR_MESSAGE_OS] = '[ mysqli: ' . $this->mysqli->errno . ' ] ' . $this->mysqli->error;
throw new \DbException(json_encode($errorMsg), ERROR_DB_PREPARE);
} else {
$errno = $this->mysqli->errno;
}
}
if (count($parameterArray) > 0) {
if (false === $this->prepareBindParam($parameterArray)) {
$this->dbLog(SQL_LOG_MODE_ERROR, $sql, $parameterArray);
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] = $this->getSqlHint($sql, $this->mysqli->error);
$errorMsg[ERROR_MESSAGE_OS] = '[ mysqli: ' . $this->mysqli_stmt->errno . ' ] ' . $this->mysqli_stmt->error;
if ($skipErrno !== array() && false === array_search($this->mysqli->errno, $skipErrno)) {
$this->dbLog(SQL_LOG_MODE_ERROR, $sql, $parameterArray);
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] = $this->getSqlHint($sql, $this->mysqli->error);
$errorMsg[ERROR_MESSAGE_OS] = '[ mysqli: ' . $this->mysqli_stmt->errno . ' ] ' . $this->mysqli_stmt->error;
throw new \DbException(json_encode($errorMsg), ERROR_DB_BIND);
throw new \DbException(json_encode($errorMsg), ERROR_DB_BIND);
} else {
$errno = $this->mysqli->errno;
}
}
}
if (false === $this->mysqli_stmt->execute()) {
$this->dbLog(SQL_LOG_MODE_ERROR, $sql, $parameterArray);
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] = $this->getSqlHint($sql, $this->mysqli->error);
$errorMsg[ERROR_MESSAGE_OS] = '[ mysqli: ' . $this->mysqli_stmt->errno . ' ] ' . $this->mysqli_stmt->error;
if ($skipErrno !== array() && false === array_search($this->mysqli->errno, $skipErrno)) {
$this->dbLog(SQL_LOG_MODE_ERROR, $sql, $parameterArray);
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] = $this->getSqlHint($sql, $this->mysqli->error);
$errorMsg[ERROR_MESSAGE_OS] = '[ mysqli: ' . $this->mysqli_stmt->errno . ' ] ' . $this->mysqli_stmt->error;
throw new \DbException(json_encode($errorMsg), ERROR_DB_EXECUTE);
throw new \DbException(json_encode($errorMsg), ERROR_DB_EXECUTE);
} else {
$errno = $this->mysqli->errno;
}
}
$msg = '';
$count = 0;
$command = strtoupper(explode(' ', $sql, 2)[0]);
if ($errno === 0) {
$command = strtoupper(explode(' ', $sql, 2)[0]);
} else {
$command = 'FAILED';
}
switch ($command) {
case 'SELECT':
case 'SHOW':
......@@ -444,6 +469,12 @@ class Database {
$count = $stat[DB_AFFECTED_ROWS];
$msg = '';
break;
case 'FAILED':
$queryType = QUERY_TYPE_FAILED;
$stat[DB_AFFECTED_ROWS] = 0;
$count = -1;
$msg = '[ mysqli: ' . $this->mysqli_stmt->errno . ' ] ' . $this->mysqli_stmt->error;
break;
default:
// Unknown command: treat it as a control command
......
......@@ -373,6 +373,9 @@ class DatabaseUpdate {
* @throws \UserFormException
*/
private function dbUpdateStatements($old, $new) {
$dummy = array();
if ($new == '' || $old === false || $old === null) {
return;
}
......@@ -391,7 +394,9 @@ class DatabaseUpdate {
if ($apply) {
// Play Statements
foreach ($sqlStatements as $sql) {
$this->db->sql($sql, ROW_REGULAR, array(), "Apply updates to QFQ database. Installed version: $old. New QFQ version: $new");
$this->db->sql($sql, ROW_REGULAR, array(),
"Apply updates to QFQ database. Installed version: $old. New QFQ version: $new",
$dummy, $dummy, [1060] /* duplicate column name */);
}
// Remember already applied updates - in case something breaks and the update has to be repeated.
$this->setDatabaseVersion($new);
......
......@@ -146,7 +146,7 @@ class Dirty {
}
/**
* Tries to get a 'DirtyRecord'. Returns an array (becomes JSON) about success or failure.
* Tries to get a lock ('dirty record'). Returns an array (becomes JSON) about success or failure.
*
* @param int $recordId
* @param array $tableVars
......
......@@ -63,6 +63,7 @@ abstract class AbstractDatabaseTest extends TestCase {
* @throws \DbException
* @throws \UserFormException
* @throws \UserReportException
* @throws \Exception
*/
protected function setUp() {
......
......@@ -115,6 +115,7 @@ class DatabaseFunction extends AbstractDatabaseTest {
* @throws \DbException
* @throws \UserFormException
* @throws \UserReportException
* @throws \Exception
*/
protected function setUp() {
parent::setUp();
......
......@@ -158,11 +158,6 @@ var QfqNS = QfqNS || {};
this.form.on('form.validation.failed', this.validationError);
this.form.on('form.validation.success', this.validationSuccess);
// Solves problem of leaving the page without releasing locks.
window.onbeforeunload = function (e) {
that.releaseLock();
};
$(".radio-inline").append($("<span>", { class: "checkmark", aria: "hidden"}));
$(".checkbox-inline").append($("<span>", { class: "checkmark", aria: "hidden"}));
$(".radio").append($("<span>", { class: "checkmark", aria: "hidden"}));
......@@ -581,7 +576,6 @@ var QfqNS = QfqNS || {};
};
n.QfqForm.prototype.callSave = function(uri) {
console.log("target: " + uri);
if(this.isFormChanged()) {
this.handleSaveClick();
this.goToAfterSave = uri;
......@@ -1389,9 +1383,6 @@ var QfqNS = QfqNS || {};
*/
n.QfqForm.prototype.goBack = function () {
var alert;
if(this.lockAcquired) {
this.releaseLock();
}
if (window.history.length < 2) {
alert = new n.Alert(
......
......@@ -81,7 +81,7 @@ var QfqNS = QfqNS || {};
// We have to use 'pagehide'. 'unload' is too late and the ajax request is lost.
window.addEventListener("pagehide", (function (that) {
return function () {
that.qfqForm.releaseLock(false);
that.qfqForm.releaseLock(true);
};
})(this));
} catch (e) {
......@@ -115,18 +115,19 @@ var QfqNS = QfqNS || {};
/**
* @private
*
* Releaselock has to be handled at the pagehide event, not here
*/
n.QfqPage.prototype.beforeUnloadHandler = function (event) {
var message = "\0/";
n.Log.debug("beforeUnloadHandler()");
if (this.qfqForm.isFormChanged() && !this.intentionalClose) {
n.Log.debug("Changes detected - not regular close");
this.qfqForm.releaseLock(true);
event.returnValue = message;
return message;
}
this.qfqForm.releaseLock();
};
/**
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment