diff --git a/src/aphront/storage/connection/AphrontDatabaseConnection.php b/src/aphront/storage/connection/AphrontDatabaseConnection.php index 70c4062..b3bd2c8 100644 --- a/src/aphront/storage/connection/AphrontDatabaseConnection.php +++ b/src/aphront/storage/connection/AphrontDatabaseConnection.php @@ -1,305 +1,305 @@ close(); } final public function setLastActiveEpoch($epoch) { $this->lastActiveEpoch = $epoch; return $this; } final public function getLastActiveEpoch() { return $this->lastActiveEpoch; } final public function setPersistent($persistent) { $this->persistent = $persistent; return $this; } final public function getPersistent() { return $this->persistent; } public function queryData($pattern/* , $arg, $arg, ... */) { $args = func_get_args(); array_unshift($args, $this); return call_user_func_array('queryfx_all', $args); } public function query($pattern/* , $arg, $arg, ... */) { $args = func_get_args(); array_unshift($args, $this); return call_user_func_array('queryfx', $args); } public function supportsAsyncQueries() { return false; } public function supportsParallelQueries() { return false; } public function setReadOnly($read_only) { $this->readOnly = $read_only; return $this; } public function getReadOnly() { return $this->readOnly; } public function setQueryTimeout($query_timeout) { $this->queryTimeout = $query_timeout; return $this; } public function getQueryTimeout() { return $this->queryTimeout; } public function asyncQuery($raw_query) { throw new Exception(pht('Async queries are not supported.')); } public static function resolveAsyncQueries(array $conns, array $asyncs) { throw new Exception(pht('Async queries are not supported.')); } /** * Is this connection idle and safe to close? * * A connection is "idle" if it can be safely closed without loss of state. * Connections inside a transaction or holding locks are not idle, even * though they may not actively be executing queries. * * @return bool True if the connection is idle and can be safely closed. */ public function isIdle() { if ($this->isInsideTransaction()) { return false; } if ($this->isHoldingAnyLock()) { return false; } return true; } /* -( Global Locks )------------------------------------------------------- */ public function rememberLock($lock) { if (isset($this->locks[$lock])) { throw new Exception( pht( 'Trying to remember lock "%s", but this lock has already been '. 'remembered.', $lock)); } $this->locks[$lock] = true; return $this; } public function forgetLock($lock) { if (empty($this->locks[$lock])) { throw new Exception( pht( 'Trying to forget lock "%s", but this connection does not remember '. 'that lock.', $lock)); } unset($this->locks[$lock]); return $this; } public function forgetAllLocks() { $this->locks = array(); return $this; } public function isHoldingAnyLock() { return (bool)$this->locks; } /* -( Transaction Management )--------------------------------------------- */ /** * Begin a transaction, or set a savepoint if the connection is already * transactional. * * @return this * @task xaction */ public function openTransaction() { $state = $this->getTransactionState(); $point = $state->getSavepointName(); $depth = $state->getDepth(); $new_transaction = ($depth == 0); if ($new_transaction) { $this->query('START TRANSACTION'); } else { $this->query('SAVEPOINT '.$point); } $state->increaseDepth(); return $this; } /** * Commit a transaction, or stage a savepoint for commit once the entire * transaction completes if inside a transaction stack. * * @return this * @task xaction */ public function saveTransaction() { $state = $this->getTransactionState(); $depth = $state->decreaseDepth(); if ($depth == 0) { $this->query('COMMIT'); } return $this; } /** * Rollback a transaction, or unstage the last savepoint if inside a * transaction stack. * * @return this */ public function killTransaction() { $state = $this->getTransactionState(); $depth = $state->decreaseDepth(); if ($depth == 0) { $this->query('ROLLBACK'); } else { $this->query('ROLLBACK TO SAVEPOINT '.$state->getSavepointName()); } return $this; } /** * Returns true if the connection is transactional. * * @return bool True if the connection is currently transactional. * @task xaction */ public function isInsideTransaction() { $state = $this->getTransactionState(); return ($state->getDepth() > 0); } /** * Get the current @{class:AphrontDatabaseTransactionState} object, or create * one if none exists. * * @return AphrontDatabaseTransactionState Current transaction state. * @task xaction */ protected function getTransactionState() { if (!$this->transactionState) { $this->transactionState = new AphrontDatabaseTransactionState(); } return $this->transactionState; } /** * @task xaction */ public function beginReadLocking() { $this->getTransactionState()->beginReadLocking(); return $this; } /** * @task xaction */ public function endReadLocking() { $this->getTransactionState()->endReadLocking(); return $this; } /** * @task xaction */ public function isReadLocking() { return $this->getTransactionState()->isReadLocking(); } /** * @task xaction */ public function beginWriteLocking() { $this->getTransactionState()->beginWriteLocking(); return $this; } /** * @task xaction */ public function endWriteLocking() { $this->getTransactionState()->endWriteLocking(); return $this; } /** * @task xaction */ public function isWriteLocking() { return $this->getTransactionState()->isWriteLocking(); } } diff --git a/src/aphront/storage/connection/AphrontIsolatedDatabaseConnection.php b/src/aphront/storage/connection/AphrontIsolatedDatabaseConnection.php index d44ca14..9638b6f 100644 --- a/src/aphront/storage/connection/AphrontIsolatedDatabaseConnection.php +++ b/src/aphront/storage/connection/AphrontIsolatedDatabaseConnection.php @@ -1,129 +1,132 @@ configuration = $configuration; if (self::$nextInsertID === null) { // Generate test IDs into a distant ID space to reduce the risk of // collisions and make them distinctive. self::$nextInsertID = 55555000000 + mt_rand(0, 1000); } } public function openConnection() { return; } public function close() { return; } public function escapeUTF8String($string) { return ''; } public function escapeBinaryString($string) { return ''; } public function escapeColumnName($name) { return ''; } public function escapeMultilineComment($comment) { return ''; } public function escapeStringForLikeClause($value) { return ''; } private function getConfiguration($key, $default = null) { return idx($this->configuration, $key, $default); } public function getInsertID() { return $this->insertID; } public function getAffectedRows() { return $this->affectedRows; } public function selectAllResults() { return $this->allResults; } - public function executeRawQuery($raw_query) { + public function executeQuery(PhutilQueryString $query) { // NOTE: "[\s<>K]*" allows any number of (properly escaped) comments to // appear prior to the allowed keyword, since this connection escapes // them as "" (above). + $display_query = $query->getMaskedString(); + $raw_query = $query->getUnmaskedString(); + $keywords = array( 'INSERT', 'UPDATE', 'DELETE', 'START', 'SAVEPOINT', 'COMMIT', 'ROLLBACK', ); $preg_keywords = array(); foreach ($keywords as $key => $word) { $preg_keywords[] = preg_quote($word, '/'); } $preg_keywords = implode('|', $preg_keywords); if (!preg_match('/^[\s<>K]*('.$preg_keywords.')\s*/i', $raw_query)) { throw new AphrontNotSupportedQueryException( pht( "Database isolation currently only supports some queries. You are ". "trying to issue a query which does not begin with an allowed ". "keyword (%s): '%s'.", implode(', ', $keywords), - $raw_query)); + $display_query)); } - $this->transcript[] = $raw_query; + $this->transcript[] = $display_query; // NOTE: This method is intentionally simplified for now, since we're only // using it to stub out inserts/updates. In the future it will probably need // to grow more powerful. $this->allResults = array(); // NOTE: We jitter the insert IDs to keep tests honest; a test should cover // the relationship between objects, not their exact insertion order. This // guarantees that IDs are unique but makes it impossible to hard-code tests // against this specific implementation detail. self::$nextInsertID += mt_rand(1, 10); $this->insertID = self::$nextInsertID; $this->affectedRows = 1; } public function executeRawQueries(array $raw_queries) { $results = array(); foreach ($raw_queries as $id => $raw_query) { $results[$id] = array(); } return $results; } public function getQueryTranscript() { return $this->transcript; } } diff --git a/src/aphront/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php b/src/aphront/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php index 19b0b8d..c1d0640 100644 --- a/src/aphront/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php +++ b/src/aphront/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php @@ -1,384 +1,387 @@ configuration = $configuration; } public function __clone() { $this->establishConnection(); } public function openConnection() { $this->requireConnection(); } public function close() { if ($this->lastResult) { $this->lastResult = null; } if ($this->connection) { $this->closeConnection(); $this->connection = null; } } public function escapeColumnName($name) { return '`'.str_replace('`', '``', $name).'`'; } public function escapeMultilineComment($comment) { // These can either terminate a comment, confuse the hell out of the parser, // make MySQL execute the comment as a query, or, in the case of semicolon, // are quasi-dangerous because the semicolon could turn a broken query into // a working query plus an ignored query. static $map = array( '--' => '(DOUBLEDASH)', '*/' => '(STARSLASH)', '//' => '(SLASHSLASH)', '#' => '(HASH)', '!' => '(BANG)', ';' => '(SEMICOLON)', ); $comment = str_replace( array_keys($map), array_values($map), $comment); // For good measure, kill anything else that isn't a nice printable // character. $comment = preg_replace('/[^\x20-\x7F]+/', ' ', $comment); return '/* '.$comment.' */'; } public function escapeStringForLikeClause($value) { $value = addcslashes($value, '\%_'); $value = $this->escapeUTF8String($value); return $value; } protected function getConfiguration($key, $default = null) { return idx($this->configuration, $key, $default); } private function establishConnection() { $host = $this->getConfiguration('host'); $database = $this->getConfiguration('database'); $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall( array( 'type' => 'connect', 'host' => $host, 'database' => $database, )); $retries = max(1, $this->getConfiguration('retries', 3)); while ($retries--) { try { $conn = $this->connect(); $profiler->endServiceCall($call_id, array()); break; } catch (AphrontQueryException $ex) { if ($retries && $ex->getCode() == 2003) { $class = get_class($ex); $message = $ex->getMessage(); phlog(pht('Retrying (%d) after %s: %s', $retries, $class, $message)); } else { $profiler->endServiceCall($call_id, array()); throw $ex; } } } $this->connection = $conn; } protected function requireConnection() { if (!$this->connection) { if ($this->connectionPool) { $this->connection = array_pop($this->connectionPool); } else { $this->establishConnection(); } } return $this->connection; } protected function beginAsyncConnection() { $connection = $this->requireConnection(); $this->connection = null; return $connection; } protected function endAsyncConnection($connection) { if ($this->connection) { $this->connectionPool[] = $this->connection; } $this->connection = $connection; } public function selectAllResults() { $result = array(); $res = $this->lastResult; if ($res == null) { throw new Exception(pht('No query result to fetch from!')); } while (($row = $this->fetchAssoc($res))) { $result[] = $row; } return $result; } - public function executeRawQuery($raw_query) { + public function executeQuery(PhutilQueryString $query) { + $display_query = $query->getMaskedString(); + $raw_query = $query->getUnmaskedString(); + $this->lastResult = null; $retries = max(1, $this->getConfiguration('retries', 3)); while ($retries--) { try { $this->requireConnection(); $is_write = $this->checkWrite($raw_query); $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall( array( 'type' => 'query', 'config' => $this->configuration, - 'query' => $raw_query, + 'query' => $display_query, 'write' => $is_write, )); $result = $this->rawQuery($raw_query); $profiler->endServiceCall($call_id, array()); if ($this->nextError) { $result = null; } if ($result) { $this->lastResult = $result; break; } $this->throwQueryException($this->connection); } catch (AphrontConnectionLostQueryException $ex) { $can_retry = ($retries > 0); if ($this->isInsideTransaction()) { // Zero out the transaction state to prevent a second exception // ("program exited with open transaction") from being thrown, since // we're about to throw a more relevant/useful one instead. $state = $this->getTransactionState(); while ($state->getDepth()) { $state->decreaseDepth(); } $can_retry = false; } if ($this->isHoldingAnyLock()) { $this->forgetAllLocks(); $can_retry = false; } $this->close(); if (!$can_retry) { throw $ex; } } } } public function executeRawQueries(array $raw_queries) { if (!$raw_queries) { return array(); } $is_write = false; foreach ($raw_queries as $key => $raw_query) { $is_write = $is_write || $this->checkWrite($raw_query); $raw_queries[$key] = rtrim($raw_query, "\r\n\t ;"); } $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall( array( 'type' => 'multi-query', 'config' => $this->configuration, 'queries' => $raw_queries, 'write' => $is_write, )); $results = $this->rawQueries($raw_queries); $profiler->endServiceCall($call_id, array()); return $results; } protected function processResult($result) { if (!$result) { try { $this->throwQueryException($this->requireConnection()); } catch (Exception $ex) { return $ex; } } else if (is_bool($result)) { return $this->getAffectedRows(); } $rows = array(); while (($row = $this->fetchAssoc($result))) { $rows[] = $row; } $this->freeResult($result); return $rows; } protected function checkWrite($raw_query) { // NOTE: The opening "(" allows queries in the form of: // // (SELECT ...) UNION (SELECT ...) $is_write = !preg_match('/^[(]*(SELECT|SHOW|EXPLAIN)\s/', $raw_query); if ($is_write) { if ($this->getReadOnly()) { throw new Exception( pht( 'Attempting to issue a write query on a read-only '. 'connection (to database "%s")!', $this->getConfiguration('database'))); } AphrontWriteGuard::willWrite(); return true; } return false; } protected function throwQueryException($connection) { if ($this->nextError) { $errno = $this->nextError; $error = pht('Simulated error.'); $this->nextError = null; } else { $errno = $this->getErrorCode($connection); $error = $this->getErrorDescription($connection); } $this->throwQueryCodeException($errno, $error); } private function throwCommonException($errno, $error) { $message = pht('#%d: %s', $errno, $error); switch ($errno) { case 2013: // Connection Dropped throw new AphrontConnectionLostQueryException($message); case 2006: // Gone Away $more = pht( 'This error may occur if your configured MySQL "wait_timeout" or '. '"max_allowed_packet" values are too small. This may also indicate '. 'that something used the MySQL "KILL " command to kill '. 'the connection running the query.'); throw new AphrontConnectionLostQueryException("{$message}\n\n{$more}"); case 1213: // Deadlock throw new AphrontDeadlockQueryException($message); case 1205: // Lock wait timeout exceeded throw new AphrontLockTimeoutQueryException($message); case 1062: // Duplicate Key // NOTE: In some versions of MySQL we get a key name back here, but // older versions just give us a key index ("key 2") so it's not // portable to parse the key out of the error and attach it to the // exception. throw new AphrontDuplicateKeyQueryException($message); case 1044: // Access denied to database case 1142: // Access denied to table case 1143: // Access denied to column case 1227: // Access denied (e.g., no SUPER for SHOW SLAVE STATUS). throw new AphrontAccessDeniedQueryException($message); case 1045: // Access denied (auth) throw new AphrontInvalidCredentialsQueryException($message); case 1146: // No such table case 1049: // No such database case 1054: // Unknown column "..." in field list throw new AphrontSchemaQueryException($message); } // TODO: 1064 is syntax error, and quite terrible in production. return null; } protected function throwConnectionException($errno, $error, $user, $host) { $this->throwCommonException($errno, $error); $message = pht( 'Attempt to connect to %s@%s failed with error #%d: %s.', $user, $host, $errno, $error); throw new AphrontConnectionQueryException($message, $errno); } protected function throwQueryCodeException($errno, $error) { $this->throwCommonException($errno, $error); $message = pht( '#%d: %s', $errno, $error); throw new AphrontQueryException($message, $errno); } /** * Force the next query to fail with a simulated error. This should be used * ONLY for unit tests. */ public function simulateErrorOnNextQuery($error) { $this->nextError = $error; return $this; } /** * Check inserts for characters outside of the BMP. Even with the strictest * settings, MySQL will silently truncate data when it encounters these, which * can lead to data loss and security problems. */ protected function validateUTF8String($string) { if (phutil_is_utf8($string)) { return; } throw new AphrontCharacterSetQueryException( pht( 'Attempting to construct a query using a non-utf8 string when '. 'utf8 is expected. Use the `%%B` conversion to escape binary '. 'strings data.')); } } diff --git a/src/xsprintf/queryfx.php b/src/xsprintf/queryfx.php index 5a950b1..d1eef61 100644 --- a/src/xsprintf/queryfx.php +++ b/src/xsprintf/queryfx.php @@ -1,29 +1,27 @@ getUnmaskedString(); - $conn->setLastActiveEpoch(time()); - $conn->executeRawQuery($query); + $conn->executeQuery($query); } function queryfx_all(AphrontDatabaseConnection $conn, $sql /* , ... */) { $argv = func_get_args(); call_user_func_array('queryfx', $argv); return $conn->selectAllResults(); } function queryfx_one(AphrontDatabaseConnection $conn, $sql /* , ... */) { $argv = func_get_args(); $ret = call_user_func_array('queryfx_all', $argv); if (count($ret) > 1) { throw new AphrontCountQueryException( pht('Query returned more than one row.')); } else if (count($ret)) { return reset($ret); } return null; }