mirror of
https://github.com/standardebooks/web.git
synced 2025-07-05 06:10:36 -04:00
Remove DbConnection class in favor of fully-static and typed Db class
This commit is contained in:
parent
0a684facee
commit
e2e14a3551
6 changed files with 564 additions and 528 deletions
|
@ -40,6 +40,7 @@ parameters:
|
||||||
-'#^Safe\\#'
|
-'#^Safe\\#'
|
||||||
uncheckedExceptionClasses:
|
uncheckedExceptionClasses:
|
||||||
- 'Exceptions\DatabaseQueryException'
|
- 'Exceptions\DatabaseQueryException'
|
||||||
|
- 'Exceptions\DuplicateDatabaseKeyException'
|
||||||
- 'Exceptions\MultiSelectMethodNotFoundException'
|
- 'Exceptions\MultiSelectMethodNotFoundException'
|
||||||
- 'PDOException'
|
- 'PDOException'
|
||||||
- 'TypeError'
|
- 'TypeError'
|
||||||
|
|
|
@ -66,7 +66,7 @@ if(SITE_STATUS == SITE_STATUS_LIVE){
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$GLOBALS['DbConnection'] = new DbConnection(DATABASE_DEFAULT_DATABASE, DATABASE_DEFAULT_HOST);
|
Db::Connect(DATABASE_DEFAULT_DATABASE, DATABASE_DEFAULT_HOST);
|
||||||
|
|
||||||
Session::InitializeFromCookie();
|
Session::InitializeFromCookie();
|
||||||
|
|
||||||
|
|
601
lib/Db.php
601
lib/Db.php
|
@ -1,42 +1,12 @@
|
||||||
<?
|
<?
|
||||||
|
use Safe\DateTimeImmutable;
|
||||||
|
use function Safe\preg_match;
|
||||||
|
use function Safe\posix_getpwuid;
|
||||||
|
|
||||||
class Db{
|
class Db{
|
||||||
public static function GetLastInsertedId(): int{
|
protected static \PDO $Link; // This is `protected` because we may want to subclass this class to connect to another instance of a database, like Sphinx.
|
||||||
return $GLOBALS['DbConnection']->GetLastInsertedId();
|
public static int $QueryCount = 0;
|
||||||
}
|
public static int $LastQueryAffectedRowCount = 0;
|
||||||
|
|
||||||
public static function GetAffectedRowCount(): int{
|
|
||||||
return $GLOBALS['DbConnection']->LastQueryAffectedRowCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @template T
|
|
||||||
* @param string $query
|
|
||||||
* @param array<mixed> $args
|
|
||||||
* @param class-string<T> $class
|
|
||||||
* @return array<T>
|
|
||||||
*/
|
|
||||||
public static function Query(string $query, array $args = [], string $class = 'stdClass'): array{
|
|
||||||
return $GLOBALS['DbConnection']->Query($query, $args, $class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a select query that returns a join against multiple tables.
|
|
||||||
*
|
|
||||||
* @template T
|
|
||||||
*
|
|
||||||
* @param string $sql The SQL query to execute.
|
|
||||||
* @param array<mixed> $params An array of parameters to bind to the SQL statement.
|
|
||||||
* @param class-string<T> $class The class to instantiate for each row, or `null` to return an array of rows.
|
|
||||||
*
|
|
||||||
* @return array<T> An array of `$class`.
|
|
||||||
*
|
|
||||||
* @throws Exceptions\MultiSelectMethodNotFoundException If a class was specified but the class doesn't have a `FromMultiTableRow()` method.
|
|
||||||
* @throws Exceptions\DatabaseQueryException When an error occurs during execution of the query.
|
|
||||||
*/
|
|
||||||
public static function MultiTableSelect(string $sql, array $params, string $class): array{
|
|
||||||
/** @throws Exceptions\DatabaseQueryException|Exceptions\MultiSelectMethodNotFoundException */
|
|
||||||
return $GLOBALS['DbConnection']->MultiTableSelect($sql, $params, $class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a single integer value for the first column database query result.
|
* Returns a single integer value for the first column database query result.
|
||||||
|
@ -47,7 +17,7 @@ class Db{
|
||||||
* @param array<mixed> $args
|
* @param array<mixed> $args
|
||||||
*/
|
*/
|
||||||
public static function QueryInt(string $query, array $args = []): int{
|
public static function QueryInt(string $query, array $args = []): int{
|
||||||
$result = $GLOBALS['DbConnection']->Query($query, $args);
|
$result = static::Query($query, $args);
|
||||||
|
|
||||||
if(sizeof($result) > 0){
|
if(sizeof($result) > 0){
|
||||||
return current((Array)$result[0]);
|
return current((Array)$result[0]);
|
||||||
|
@ -65,7 +35,7 @@ class Db{
|
||||||
* @param array<mixed> $args
|
* @param array<mixed> $args
|
||||||
*/
|
*/
|
||||||
public static function QueryFloat(string $query, array $args = []): float{
|
public static function QueryFloat(string $query, array $args = []): float{
|
||||||
$result = $GLOBALS['DbConnection']->Query($query, $args);
|
$result = static::Query($query, $args);
|
||||||
|
|
||||||
if(sizeof($result) > 0){
|
if(sizeof($result) > 0){
|
||||||
return current((Array)$result[0]);
|
return current((Array)$result[0]);
|
||||||
|
@ -83,7 +53,7 @@ class Db{
|
||||||
* @param array<mixed> $args
|
* @param array<mixed> $args
|
||||||
*/
|
*/
|
||||||
public static function QueryBool(string $query, array $args = []): bool{
|
public static function QueryBool(string $query, array $args = []): bool{
|
||||||
$result = $GLOBALS['DbConnection']->Query($query, $args);
|
$result = static::Query($query, $args);
|
||||||
|
|
||||||
if(sizeof($result) > 0){
|
if(sizeof($result) > 0){
|
||||||
return (bool)current((array)$result[0]);
|
return (bool)current((array)$result[0]);
|
||||||
|
@ -95,7 +65,7 @@ class Db{
|
||||||
/**
|
/**
|
||||||
* Returns an SQL query string appropriate for set membership.
|
* Returns an SQL query string appropriate for set membership.
|
||||||
*
|
*
|
||||||
* This is useful for queries of the form WHERE var IN (?,?,?) and the length of the set is dynamic.
|
* This is useful for queries of the form `WHERE var IN (?,?,?)` and the length of the set is dynamic.
|
||||||
*
|
*
|
||||||
* @param array<mixed> $arr
|
* @param array<mixed> $arr
|
||||||
*/
|
*/
|
||||||
|
@ -108,4 +78,553 @@ class Db{
|
||||||
|
|
||||||
return rtrim($sql, ',') . ')';
|
return rtrim($sql, ',') . ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a database connection.
|
||||||
|
*
|
||||||
|
* @param ?string $defaultDatabase The default database to connect to, or `null` not to define one.
|
||||||
|
* @param string $host The database hostname.
|
||||||
|
* @param ?string $user The user to connect to, or `null` to log in as the current Unix user via a local socket.
|
||||||
|
* @param string $password The password to use, or an empty string if no password is required.
|
||||||
|
* @param bool $forceUtf8 If **TRUE**, issue `set names utf8mb4 collate utf8mb4_unicode_ci` when starting the connection.
|
||||||
|
*/
|
||||||
|
public static function Connect(?string $defaultDatabase = null, string $host = 'localhost', ?string $user = null, string $password = '', bool $forceUtf8 = true): void{
|
||||||
|
if(isset(static::$Link)){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($user === null){
|
||||||
|
// Get the user running the script for local socket login.
|
||||||
|
$user = posix_getpwuid(posix_geteuid())['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionString = 'mysql:';
|
||||||
|
|
||||||
|
if(mb_stripos($host, ':') !== false){
|
||||||
|
$port = null;
|
||||||
|
preg_match('/([^:]*):([0-9]+)/ius', $host, $matches);
|
||||||
|
if(sizeof($matches) > 1){
|
||||||
|
$host = $matches[1];
|
||||||
|
}
|
||||||
|
if(sizeof($matches) > 2){
|
||||||
|
$port = $matches[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionString .= 'host=' . $host;
|
||||||
|
|
||||||
|
if($port !== null){
|
||||||
|
$connectionString .= ';port=' . $port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$connectionString .= 'host=' . $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($defaultDatabase !== null){
|
||||||
|
$connectionString .= ';dbname=' . $defaultDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't use persistent connections (connection pooling) because we would have race conditions with `last_insert_id()`.
|
||||||
|
$params = [\PDO::ATTR_EMULATE_PREPARES => false, \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_PERSISTENT => false];
|
||||||
|
|
||||||
|
if($forceUtf8){
|
||||||
|
$params[\PDO::MYSQL_ATTR_INIT_COMMAND] = 'set names utf8mb4 collate utf8mb4_unicode_ci;';
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$Link = new \PDO($connectionString, $user, $password, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a generic query in the database.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @param string $sql The SQL query to execute.
|
||||||
|
* @param array<mixed> $params An array of parameters to bind to the SQL statement.
|
||||||
|
* @param class-string<T> $class The type of object to return in the return array.
|
||||||
|
*
|
||||||
|
* @return array<T> An array of objects of type `$class`, or `stdClass` if `$class` is `null`.
|
||||||
|
*
|
||||||
|
* @throws Exceptions\DuplicateDatabaseKeyException When a unique key constraint has been violated.
|
||||||
|
* @throws Exceptions\DatabaseQueryException When an error occurs during execution of the query.
|
||||||
|
*/
|
||||||
|
public static function Query(string $sql, array $params = [], string $class = 'stdClass'): array{
|
||||||
|
$handle = static::PreparePdoHandle($sql, $params);
|
||||||
|
$result = [];
|
||||||
|
$deadlockRetries = 0;
|
||||||
|
$done = false;
|
||||||
|
|
||||||
|
while(!$done){
|
||||||
|
try{
|
||||||
|
$result = static::ExecuteQuery($handle, $class);
|
||||||
|
$done = true;
|
||||||
|
}
|
||||||
|
catch(\PDOException $ex){
|
||||||
|
if(isset($ex->errorInfo[1]) && $ex->errorInfo[1] == 1213 && $deadlockRetries < 3){
|
||||||
|
// InnoDB deadlock, this is normal and happens occasionally. All we have to do is retry the query.
|
||||||
|
$deadlockRetries++;
|
||||||
|
usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds
|
||||||
|
}
|
||||||
|
elseif(isset($ex->errorInfo[1]) && $ex->errorInfo[1] == 1062){
|
||||||
|
// Duplicate key, bubble this up without logging it so the business logic can handle it
|
||||||
|
throw new Exceptions\DuplicateDatabaseKeyException(str_replace('SQLSTATE[23000]: Integrity constraint violation: 1062 ', '', $ex->getMessage() . '. Query: ' . $sql . '. Parameters: ' . vds($params)));
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
throw static::CreateDetailedException($ex, $sql, $params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$QueryCount++;
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a select query that returns a join against multiple tables.
|
||||||
|
*
|
||||||
|
* For example, `select * from Users inner join Posts on Users.UserId = Posts.UserId`.
|
||||||
|
*
|
||||||
|
* The result is an array of objects generated from the object's `FromMultiTableRow()` method. The `FromMultiTableRow()` method must have this signature:
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* public static function FromMultiTableRow(array $row): T
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Important note:** If the two tables are joined via `using (Id)` instead of `on TableA.Id = TableB.Id`, the SQL query would return only one column for the join key (`Id` in this case). Therefore, if both objects require that column, the `FromMultiTableRow()` must explicitly assign the column to the object that's missing it, typically the second table in the join.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @param string $sql The SQL query to execute.
|
||||||
|
* @param array<mixed> $params An array of parameters to bind to the SQL statement.
|
||||||
|
* @param class-string<T> $class The class to instantiate for each row.
|
||||||
|
*
|
||||||
|
* @return array<T> An array of `$class`.
|
||||||
|
*
|
||||||
|
* @throws Exceptions\MultiSelectMethodNotFoundException If the class doesn't have a `FromMultiTableRow()` method.
|
||||||
|
* @throws Exceptions\DatabaseQueryException When an error occurs during execution of the query.
|
||||||
|
*/
|
||||||
|
public static function MultiTableSelect(string $sql, array $params, string $class): array{
|
||||||
|
if(!method_exists($class, 'FromMultiTableRow')){
|
||||||
|
throw new Exceptions\MultiSelectMethodNotFoundException($class);
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = static::PreparePdoHandle($sql, $params);
|
||||||
|
$result = [];
|
||||||
|
$deadlockRetries = 0;
|
||||||
|
$done = false;
|
||||||
|
|
||||||
|
while(!$done){
|
||||||
|
try{
|
||||||
|
/** @var array<T> $result */
|
||||||
|
$result = static::ExecuteMultiTableSelect($handle, $class);
|
||||||
|
$done = true;
|
||||||
|
}
|
||||||
|
catch(\PDOException $ex){
|
||||||
|
if(isset($ex->errorInfo[1]) && $ex->errorInfo[1] == 1213 && $deadlockRetries < 3){
|
||||||
|
// InnoDB deadlock, this is normal and happens occasionally. All we have to do is retry the query.
|
||||||
|
$deadlockRetries++;
|
||||||
|
usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds.
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
throw static::CreateDetailedException($ex, $sql, $params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$QueryCount++;
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a select query that returns a join against multiple tables.
|
||||||
|
*
|
||||||
|
* For example, `select * from Users inner join Posts on Users.UserId = Posts.UserId`.
|
||||||
|
*
|
||||||
|
* The result is an array of rows. Each row is an array of objects, with each object containing its columns and values. For example,
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* [
|
||||||
|
* [
|
||||||
|
* 'Users' => {
|
||||||
|
* 'UserId' => 111,
|
||||||
|
* 'Name' => 'Alice'
|
||||||
|
* },
|
||||||
|
* 'Posts' => {
|
||||||
|
* 'PostId' => 222,
|
||||||
|
* 'UserId' => 111,
|
||||||
|
* 'Title' => 'Lorem Ipsum'
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* [
|
||||||
|
* 'Users' => {
|
||||||
|
* 'UserId' => 333,
|
||||||
|
* 'Name' => 'Bob'
|
||||||
|
* },
|
||||||
|
* 'Posts' => {
|
||||||
|
* 'PostId' => 444,
|
||||||
|
* 'UserId' => 333,
|
||||||
|
* 'Title' => 'Dolor sit'
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Important note:** If the two tables are joined via `using (Id)` instead of `on TableA.Id = TableB.Id`, the SQL query would return only one column for the join key (`Id` in this case).
|
||||||
|
*
|
||||||
|
* @param string $sql The SQL query to execute.
|
||||||
|
* @param array<mixed> $params An array of parameters to bind to the SQL statement.
|
||||||
|
*
|
||||||
|
* @return array<array<string, stdClass>> An array of `$class` if `$class` is not `null`, otherwise an array of rows of the form `["LeftTableName" => $stdClass, "RightTableName" => $stdClass]`.
|
||||||
|
*
|
||||||
|
* @throws Exceptions\DatabaseQueryException When an error occurs during execution of the query.
|
||||||
|
*/
|
||||||
|
public static function MultiTableSelectGeneric(string $sql, array $params): array{
|
||||||
|
$handle = static::PreparePdoHandle($sql, $params);
|
||||||
|
$result = [];
|
||||||
|
$deadlockRetries = 0;
|
||||||
|
$done = false;
|
||||||
|
|
||||||
|
while(!$done){
|
||||||
|
try{
|
||||||
|
/** @var array<array<string, stdClass>> $result */
|
||||||
|
$result = static::ExecuteMultiTableSelect($handle, stdClass::class);
|
||||||
|
$done = true;
|
||||||
|
}
|
||||||
|
catch(\PDOException $ex){
|
||||||
|
if(isset($ex->errorInfo[1]) && $ex->errorInfo[1] == 1213 && $deadlockRetries < 3){
|
||||||
|
// InnoDB deadlock, this is normal and happens occasionally. All we have to do is retry the query.
|
||||||
|
$deadlockRetries++;
|
||||||
|
usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds.
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
throw static::CreateDetailedException($ex, $sql, $params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$QueryCount++;
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a string of SQL, prepare a PDO handle by binding the parameters to the query.
|
||||||
|
*
|
||||||
|
* @param string $sql The SQL query to execute.
|
||||||
|
* @param array<mixed> $params An array of parameters to bind to the SQL statement.
|
||||||
|
*
|
||||||
|
* @return \PdoStatement The `\PDOStatement` to be used to execute the query.
|
||||||
|
*
|
||||||
|
* @throws Exceptions\DatabaseQueryException When an error occurs during execution of the query.
|
||||||
|
*/
|
||||||
|
protected static function PreparePdoHandle(string $sql, array $params): \PDOStatement{
|
||||||
|
try{
|
||||||
|
$handle = static::$Link->prepare($sql);
|
||||||
|
}
|
||||||
|
catch(\PDOException $ex){
|
||||||
|
throw static::CreateDetailedException($ex, $sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = 0;
|
||||||
|
foreach($params as $parameter){
|
||||||
|
$name++;
|
||||||
|
|
||||||
|
if($parameter instanceof DateTimeInterface){
|
||||||
|
$handle->bindValue($name, $parameter->format('Y-m-d H:i:s'));
|
||||||
|
}
|
||||||
|
elseif(is_bool($parameter)){
|
||||||
|
// MySQL strict mode requires `0` or `1` instead of `true` or `false`.
|
||||||
|
// We can't use `PDO::PARAM_BOOL`, it just doesn't work.
|
||||||
|
|
||||||
|
$handle->bindValue($name, $parameter ? 1 : 0, PDO::PARAM_INT);
|
||||||
|
}
|
||||||
|
elseif($parameter instanceof BackedEnum){
|
||||||
|
$handle->bindValue($name, $parameter->value);
|
||||||
|
}
|
||||||
|
elseif(is_int($parameter)){
|
||||||
|
$handle->bindValue($name, $parameter, PDO::PARAM_INT);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$handle->bindValue($name, $parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a regular query and return the result as an array of objects.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @param \PdoStatement $handle The PDO handle to execute.
|
||||||
|
* @param class-string<T> $class The type of object to return in the return array.
|
||||||
|
*
|
||||||
|
* @return array<T> An array of objects of type `$class`, or `stdClass` if `$class` is `null`.
|
||||||
|
*
|
||||||
|
* @throws \PDOException When an error occurs during execution of the query.
|
||||||
|
*/
|
||||||
|
protected static function ExecuteQuery(\PDOStatement $handle, string $class = 'stdClass'): array{
|
||||||
|
$handle->execute();
|
||||||
|
|
||||||
|
static::$LastQueryAffectedRowCount = $handle->rowCount();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
do{
|
||||||
|
try{
|
||||||
|
$columnCount = $handle->columnCount();
|
||||||
|
|
||||||
|
if($columnCount == 0){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = [];
|
||||||
|
|
||||||
|
for($i = 0; $i < $columnCount; $i++){
|
||||||
|
$metadata[$i] = $handle->getColumnMeta($i);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $handle->fetchAll(\PDO::FETCH_NUM);
|
||||||
|
|
||||||
|
$useObjectFillMethod = method_exists($class, 'FromRow');
|
||||||
|
|
||||||
|
foreach($rows as $row){
|
||||||
|
if($useObjectFillMethod){
|
||||||
|
$object = new stdClass();
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$object = new $class();
|
||||||
|
}
|
||||||
|
|
||||||
|
for($i = 0; $i < $handle->columnCount(); $i++){
|
||||||
|
if($metadata[$i] === false){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($useObjectFillMethod){
|
||||||
|
// Don't specify a class so that we don't perform an enum check at this point.
|
||||||
|
// We'll check for enum types in the class's `FromRow()` method.
|
||||||
|
$object->{$metadata[$i]['name']} = static::GetColumnValue($row[$i], $metadata[$i]);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$object->{$metadata[$i]['name']} = static::GetColumnValue($row[$i], $metadata[$i], $class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($useObjectFillMethod){
|
||||||
|
$result[] = $class::FromRow($object);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$result[] = $object;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(\PDOException $ex){
|
||||||
|
// `HY000` is thrown when there is no result set, e.g. for an update operation.
|
||||||
|
// If anything besides that is thrown, then send it up the stack.
|
||||||
|
if(!isset($ex->errorInfo[0]) || $ex->errorInfo[0] != "HY000"){
|
||||||
|
throw $ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while($handle->nextRowset());
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a multi-table select query.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @param \PdoStatement $handle The PDO handle to execute.
|
||||||
|
* @param class-string<T> $class The class to instantiate for each row, or `stdClass` to return an array of rows.
|
||||||
|
*
|
||||||
|
* @return array<T>|array<array<string, stdClass>> An array of `$class` if `$class` is not `stdClass`, otherwise an array of rows of the form `["LeftTableName" => $stdClass, "RightTableName" => $stdClass]`.
|
||||||
|
*
|
||||||
|
* @throws \PDOException When an error occurs during execution of the query.
|
||||||
|
*/
|
||||||
|
protected static function ExecuteMultiTableSelect(\PDOStatement $handle, string $class): array{
|
||||||
|
$handle->execute();
|
||||||
|
|
||||||
|
static::$LastQueryAffectedRowCount = $handle->rowCount();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
do{
|
||||||
|
$columnCount = $handle->columnCount();
|
||||||
|
|
||||||
|
if($columnCount == 0){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = [];
|
||||||
|
|
||||||
|
for($i = 0; $i < $columnCount; $i++){
|
||||||
|
$metadata[$i] = $handle->getColumnMeta($i);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $handle->fetchAll(\PDO::FETCH_NUM);
|
||||||
|
|
||||||
|
foreach($rows as $row){
|
||||||
|
$resultRow = [];
|
||||||
|
for($i = 0; $i < $handle->columnCount(); $i++){
|
||||||
|
if($metadata[$i] === false || !isset($metadata[$i]['table'])){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$object = $resultRow[$metadata[$i]['table']] ?? new stdClass();
|
||||||
|
|
||||||
|
$object->{$metadata[$i]['name']} = static::GetColumnValue($row[$i], $metadata[$i]);
|
||||||
|
|
||||||
|
$resultRow[$metadata[$i]['table']] = $object;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($class == stdClass::class){
|
||||||
|
$result[] = $resultRow;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$result[] = $class::FromMultiTableRow($resultRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while($handle->nextRowset());
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a column value and its database driver metadata, return a strongly-typed value.
|
||||||
|
*
|
||||||
|
* @param mixed $column The value of the column, most likely either a string or integer.
|
||||||
|
* @param array<mixed> $metadata An array of metadata returned from the database driver.
|
||||||
|
* @param string $class The type of object that this return value will be part of.
|
||||||
|
*
|
||||||
|
* @return mixed The strongly-typed column value.
|
||||||
|
*/
|
||||||
|
protected static function GetColumnValue(mixed $column, array $metadata, string $class = 'stdClass'): mixed{
|
||||||
|
if($column === null){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
switch($metadata['native_type'] ?? null){
|
||||||
|
case 'DATE':
|
||||||
|
case 'DATETIME':
|
||||||
|
case 'TIMESTAMP':
|
||||||
|
/** @throws void */
|
||||||
|
/** @var string $column */
|
||||||
|
return new DateTimeImmutable($column);
|
||||||
|
|
||||||
|
case 'LONG':
|
||||||
|
case 'TINY':
|
||||||
|
case 'SHORT':
|
||||||
|
case 'INT24':
|
||||||
|
case 'LONGLONG':
|
||||||
|
/** @var int $column */
|
||||||
|
return intval($column);
|
||||||
|
|
||||||
|
case 'FLOAT':
|
||||||
|
case 'DOUBLE':
|
||||||
|
case 'NEWDECIMAL':
|
||||||
|
/** @var string $column */
|
||||||
|
return floatval($column);
|
||||||
|
|
||||||
|
case 'STRING':
|
||||||
|
// We don't check the type `VAR_STRING` here because in MariaDB, enums are always of type `STRING`.
|
||||||
|
// Since this check is slow, we don't want to run it unnecessarily.
|
||||||
|
if($class == 'stdClass'){
|
||||||
|
return $column;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
// If the column is a string and we're filling a typed object, check if the object property is a backed enum. If so, generate it using `type::from()`. Otherwise, fill it with a string.
|
||||||
|
// Note: Using `ReflectionProperty` in this way is pretty slow. Maybe we'll think of a better way to automatically fill enum types later.
|
||||||
|
try{
|
||||||
|
$rp = new ReflectionProperty($class, $metadata['name']);
|
||||||
|
/** @var ?ReflectionNamedType $property */
|
||||||
|
$property = $rp->getType();
|
||||||
|
if($property !== null){
|
||||||
|
$type = $property->getName();
|
||||||
|
if(is_a($type, 'BackedEnum', true)){
|
||||||
|
/** @var string $column */
|
||||||
|
return $type::from($column);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
return $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
return $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(\Exception){
|
||||||
|
return $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ID of the last row that was inserted during this database connection.
|
||||||
|
*
|
||||||
|
* @return int The ID of the last row that was inserted during this database connection.
|
||||||
|
*
|
||||||
|
* @throws Exceptions\DatabaseQueryException When the last inserted ID can't be determined.
|
||||||
|
*/
|
||||||
|
public static function GetLastInsertedId(): int{
|
||||||
|
try{
|
||||||
|
$id = static::$Link->lastInsertId();
|
||||||
|
}
|
||||||
|
catch(\PDOException){
|
||||||
|
$id = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($id === false || $id == '0'){
|
||||||
|
throw new Exceptions\DatabaseQueryException('Couldn\'t get last insert ID.');
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
return intval($id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a detailed `Exceptions\DatabaseQueryException` from a `\PDOException`.
|
||||||
|
*
|
||||||
|
* @param \PDOException $ex The exception to create details from.
|
||||||
|
* @param string $sql The prepared SQL that caused the exception.
|
||||||
|
* @param array<mixed> $params The parameters passed to the prepared SQL.
|
||||||
|
*
|
||||||
|
* @return Exceptions\DatabaseQueryException A more detailed exception to be thrown further up the stack.
|
||||||
|
*/
|
||||||
|
protected static function CreateDetailedException(\PDOException $ex, string $sql, array $params): Exceptions\DatabaseQueryException{
|
||||||
|
// Throw a custom exception that includes more information on the query and paramaters.
|
||||||
|
return new Exceptions\DatabaseQueryException('Error when executing query: ' . $ex->getMessage() . '. Query: ' . $sql . '. Parameters: ' . vds($params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of arguments to pass to an SQL query whose last two arguments are integers representing the values in a `limit` clause, recalculate the last item to a value close to infinity if that value is zero.
|
||||||
|
*
|
||||||
|
* For example, passing array `['bob', 0, 0]` would return `['bob', 0, 999999999]`. This would be useful to pass to a query with a `limit` clause, like:
|
||||||
|
*
|
||||||
|
* ```sql
|
||||||
|
* select * from Users where Username = ? limit ?, ?;
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param array<mixed> $args A list of SQL query arguments, with two integers representing limits as the last two items.
|
||||||
|
*
|
||||||
|
* @return array<mixed>
|
||||||
|
*/
|
||||||
|
public static function ParseLimits(array $args): array{
|
||||||
|
if(sizeof($args) >= 2){
|
||||||
|
if($args[sizeof($args) - 2] == 0 && ($args[sizeof($args) - 1] === 0 || $args[sizeof($args) - 1] === null)){
|
||||||
|
$args[sizeof($args) - 1] = 999999999; // Close enough to infinity.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,484 +0,0 @@
|
||||||
<?
|
|
||||||
use Safe\DateTimeImmutable;
|
|
||||||
use function Safe\preg_match;
|
|
||||||
use function Safe\posix_getpwuid;
|
|
||||||
|
|
||||||
class DbConnection{
|
|
||||||
private \PDO $_link;
|
|
||||||
public int $QueryCount = 0;
|
|
||||||
public int $LastQueryAffectedRowCount = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a database connection.
|
|
||||||
*
|
|
||||||
* @param ?string $defaultDatabase The default database to connect to, or `null` not to define one.
|
|
||||||
* @param string $host The database hostname.
|
|
||||||
* @param ?string $user The user to connect to, or `null` to log in as the current Unix user via a local socket.
|
|
||||||
* @param string $password The password to use, or an empty string if no password is required.
|
|
||||||
* @param bool $forceUtf8 If **TRUE**, issue `set names utf8mb4 collate utf8mb4_unicode_ci` when starting the connection.
|
|
||||||
*/
|
|
||||||
public function __construct(?string $defaultDatabase = null, string $host = 'localhost', ?string $user = null, string $password = '', bool $forceUtf8 = true){
|
|
||||||
if($user === null){
|
|
||||||
// Get the user running the script for local socket login
|
|
||||||
$user = posix_getpwuid(posix_geteuid());
|
|
||||||
if($user){
|
|
||||||
$user = $user['name'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$connectionString = 'mysql:';
|
|
||||||
|
|
||||||
if(mb_stripos($host, ':') !== false){
|
|
||||||
$port = null;
|
|
||||||
preg_match('/([^:]*):([0-9]+)/ius', $host, $matches);
|
|
||||||
$host = $matches[1];
|
|
||||||
if(sizeof($matches) > 2){
|
|
||||||
$port = $matches[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
$connectionString .= 'host=' . $host;
|
|
||||||
|
|
||||||
if($port !== null){
|
|
||||||
$connectionString .= ';port=' . $port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
$connectionString .= 'host=' . $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
if($defaultDatabase !== null){
|
|
||||||
$connectionString .= ';dbname=' . $defaultDatabase;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can't use persistent connections (connection pooling) because we would have race condition problems with last_insert_id()
|
|
||||||
$params = [\PDO::ATTR_EMULATE_PREPARES => false, \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_PERSISTENT => false];
|
|
||||||
|
|
||||||
if($forceUtf8){
|
|
||||||
$params[\PDO::MYSQL_ATTR_INIT_COMMAND] = 'set names utf8mb4 collate utf8mb4_unicode_ci;';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->_link = new \PDO($connectionString, $user, $password, $params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a generic query in the database.
|
|
||||||
*
|
|
||||||
* @template T
|
|
||||||
*
|
|
||||||
* @param string $sql The SQL query to execute.
|
|
||||||
* @param array<mixed> $params An array of parameters to bind to the SQL statement.
|
|
||||||
* @param class-string<T> $class The type of object to return in the return array.
|
|
||||||
*
|
|
||||||
* @return array<T> An array of objects of type `$class`, or `stdClass` if `$class` is `null`.
|
|
||||||
*
|
|
||||||
* @throws Exceptions\DuplicateDatabaseKeyException When a unique key constraint has been violated.
|
|
||||||
* @throws Exceptions\DatabaseQueryException When an error occurs during execution of the query.
|
|
||||||
*/
|
|
||||||
public function Query(string $sql, array $params = [], string $class = 'stdClass'): array{
|
|
||||||
$handle = $this->PreparePdoHandle($sql, $params);
|
|
||||||
$result = [];
|
|
||||||
$deadlockRetries = 0;
|
|
||||||
$done = false;
|
|
||||||
|
|
||||||
while(!$done){
|
|
||||||
try{
|
|
||||||
$result = $this->ExecuteQuery($handle, $class);
|
|
||||||
$done = true;
|
|
||||||
}
|
|
||||||
catch(\PDOException $ex){
|
|
||||||
if(isset($ex->errorInfo[1]) && $ex->errorInfo[1] == 1213 && $deadlockRetries < 3){
|
|
||||||
// InnoDB deadlock, this is normal and happens occasionally. All we have to do is retry the query.
|
|
||||||
$deadlockRetries++;
|
|
||||||
usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds
|
|
||||||
}
|
|
||||||
elseif(isset($ex->errorInfo[1]) && $ex->errorInfo[1] == 1062){
|
|
||||||
// Duplicate key, bubble this up without logging it so the business logic can handle it
|
|
||||||
throw new Exceptions\DuplicateDatabaseKeyException(str_replace('SQLSTATE[23000]: Integrity constraint violation: 1062 ', '', $ex->getMessage() . '. Query: ' . $sql . '. Parameters: ' . vds($params)));
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
throw $this->CreateDetailedException($ex, $sql, $params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->QueryCount++;
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a select query that returns a join against multiple tables.
|
|
||||||
*
|
|
||||||
* For example, `select * from Users inner join Posts on Users.UserId = Posts.UserId`.
|
|
||||||
*
|
|
||||||
* The result is an array of rows. Each row is an array of objects, with each object containing its columns and values. For example,
|
|
||||||
*
|
|
||||||
* ```php
|
|
||||||
* [
|
|
||||||
* [
|
|
||||||
* 'Users' => {
|
|
||||||
* 'UserId' => 111,
|
|
||||||
* 'Name' => 'Alice'
|
|
||||||
* },
|
|
||||||
* 'Posts' => {
|
|
||||||
* 'PostId' => 222,
|
|
||||||
* 'UserId' => 111,
|
|
||||||
* 'Title' => 'Lorem Ipsum'
|
|
||||||
* }
|
|
||||||
* ],
|
|
||||||
* [
|
|
||||||
* 'Users' => {
|
|
||||||
* 'UserId' => 333,
|
|
||||||
* 'Name' => 'Bob'
|
|
||||||
* },
|
|
||||||
* 'Posts' => {
|
|
||||||
* 'PostId' => 444,
|
|
||||||
* 'UserId' => 333,
|
|
||||||
* 'Title' => 'Dolor sit'
|
|
||||||
* }
|
|
||||||
* ]
|
|
||||||
* ]
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* **Important note:** If the two tables above were joined via `using (UserId)` instead of `on Users.UserId = Posts.UserId`, the SQL query would return only one column for the join key (`UserId` in this case). Therefore, if both objects require an ID, the filler method must explicitly assign the ID to one of the two objects that's missing it. In the above example, the `Users` result would have the `UserId` column, and `Posts` would not.
|
|
||||||
*
|
|
||||||
* @template T
|
|
||||||
*
|
|
||||||
* @param string $sql The SQL query to execute.
|
|
||||||
* @param array<mixed> $params An array of parameters to bind to the SQL statement.
|
|
||||||
* @param class-string<T> $class The class to instantiate for each row, or `null` to return an array of rows.
|
|
||||||
*
|
|
||||||
* @return array<T>|array<array<string, stdClass>> An array of `$class` if `$class` is not `null`, otherwise an array of rows of the form `["LeftTableName" => $stdClass, "RightTableName" => $stdClass]`.
|
|
||||||
*
|
|
||||||
* @throws Exceptions\MultiSelectMethodNotFoundException If a class was specified but the class doesn't have a `FromMultiTableRow()` method.
|
|
||||||
* @throws Exceptions\DatabaseQueryException When an error occurs during execution of the query.
|
|
||||||
*/
|
|
||||||
public function MultiTableSelect(string $sql, array $params = [], ?string $class = null): array{
|
|
||||||
if($class !== null && !method_exists($class, 'FromMultiTableRow')){
|
|
||||||
throw new Exceptions\MultiSelectMethodNotFoundException($class);
|
|
||||||
}
|
|
||||||
|
|
||||||
$handle = $this->PreparePdoHandle($sql, $params);
|
|
||||||
$result = [];
|
|
||||||
$deadlockRetries = 0;
|
|
||||||
$done = false;
|
|
||||||
|
|
||||||
while(!$done){
|
|
||||||
try{
|
|
||||||
$result = $this->ExecuteMultiTableSelect($handle, $class);
|
|
||||||
$done = true;
|
|
||||||
}
|
|
||||||
catch(\PDOException $ex){
|
|
||||||
if(isset($ex->errorInfo[1]) && $ex->errorInfo[1] == 1213 && $deadlockRetries < 3){
|
|
||||||
// InnoDB deadlock, this is normal and happens occasionally. All we have to do is retry the query.
|
|
||||||
$deadlockRetries++;
|
|
||||||
usleep(500000 * $deadlockRetries); // Give the deadlock some time to clear up. Start at .5 seconds
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
throw $this->CreateDetailedException($ex, $sql, $params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->QueryCount++;
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a string of SQL, prepare a PDO handle by binding the parameters to the query.
|
|
||||||
*
|
|
||||||
* @param string $sql The SQL query to execute.
|
|
||||||
* @param array<mixed> $params An array of parameters to bind to the SQL statement.
|
|
||||||
*
|
|
||||||
* @return \PdoStatement The `\PDOStatement` to be used to execute the query.
|
|
||||||
*
|
|
||||||
* @throws Exceptions\DatabaseQueryException When an error occurs during execution of the query.
|
|
||||||
*/
|
|
||||||
private function PreparePdoHandle(string $sql, array $params): \PDOStatement{
|
|
||||||
try{
|
|
||||||
$handle = $this->_link->prepare($sql);
|
|
||||||
}
|
|
||||||
catch(\PDOException $ex){
|
|
||||||
throw $this->CreateDetailedException($ex, $sql, $params);
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = 0;
|
|
||||||
foreach($params as $parameter){
|
|
||||||
$name++;
|
|
||||||
|
|
||||||
if($parameter instanceof DateTimeInterface){
|
|
||||||
$handle->bindValue($name, $parameter->format('Y-m-d H:i:s'));
|
|
||||||
}
|
|
||||||
elseif(is_bool($parameter)){
|
|
||||||
// MySQL strict mode requires 0 or 1 instead of true or false
|
|
||||||
// Can't use PDO::PARAM_BOOL, it just doesn't work
|
|
||||||
|
|
||||||
$handle->bindValue($name, $parameter ? 1 : 0, PDO::PARAM_INT);
|
|
||||||
}
|
|
||||||
elseif($parameter instanceof BackedEnum){
|
|
||||||
$handle->bindValue($name, $parameter->value);
|
|
||||||
}
|
|
||||||
elseif(is_int($parameter)){
|
|
||||||
$handle->bindValue($name, $parameter, PDO::PARAM_INT);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
$handle->bindValue($name, $parameter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a regular query and return the result as an array of objects.
|
|
||||||
*
|
|
||||||
* @template T
|
|
||||||
*
|
|
||||||
* @param \PdoStatement $handle The PDO handle to execute.
|
|
||||||
* @param class-string<T> $class The type of object to return in the return array.
|
|
||||||
*
|
|
||||||
* @return array<T> An array of objects of type `$class`, or `stdClass` if `$class` is `null`.
|
|
||||||
*
|
|
||||||
* @throws \PDOException When an error occurs during execution of the query.
|
|
||||||
*/
|
|
||||||
private function ExecuteQuery(\PDOStatement $handle, string $class = 'stdClass'): array{
|
|
||||||
$handle->execute();
|
|
||||||
|
|
||||||
$this->LastQueryAffectedRowCount = $handle->rowCount();
|
|
||||||
|
|
||||||
$result = [];
|
|
||||||
do{
|
|
||||||
try{
|
|
||||||
$columnCount = $handle->columnCount();
|
|
||||||
|
|
||||||
if($columnCount == 0){
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = [];
|
|
||||||
|
|
||||||
for($i = 0; $i < $columnCount; $i++){
|
|
||||||
$metadata[$i] = $handle->getColumnMeta($i);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $handle->fetchAll(\PDO::FETCH_NUM);
|
|
||||||
|
|
||||||
$useObjectFillMethod = method_exists($class, 'FromRow');
|
|
||||||
|
|
||||||
foreach($rows as $row){
|
|
||||||
if($useObjectFillMethod){
|
|
||||||
$object = new stdClass();
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
$object = new $class();
|
|
||||||
}
|
|
||||||
|
|
||||||
for($i = 0; $i < $handle->columnCount(); $i++){
|
|
||||||
if($metadata[$i] === false){
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if($useObjectFillMethod){
|
|
||||||
// Don't specify a class so that we don't perform an enum check at this point.
|
|
||||||
// We'll check for enum types in the class's FromRow() method.
|
|
||||||
$object->{$metadata[$i]['name']} = $this->GetColumnValue($row[$i], $metadata[$i]);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
$object->{$metadata[$i]['name']} = $this->GetColumnValue($row[$i], $metadata[$i], $class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if($useObjectFillMethod){
|
|
||||||
$result[] = $class::FromRow($object);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
$result[] = $object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(\PDOException $ex){
|
|
||||||
// HY000 is thrown when there is no result set, e.g. for an update operation.
|
|
||||||
// If anything besides that is thrown, then send it up the stack
|
|
||||||
if(!isset($ex->errorInfo[0]) || $ex->errorInfo[0] != "HY000"){
|
|
||||||
throw $ex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while($handle->nextRowset());
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a multi-table select query.
|
|
||||||
*
|
|
||||||
* @template T
|
|
||||||
*
|
|
||||||
* @param \PdoStatement $handle The PDO handle to execute.
|
|
||||||
* @param class-string<T> $class The class to instantiate for each row, or `null` to return an array of rows.
|
|
||||||
*
|
|
||||||
* @return array<T>|array<array<string, stdClass>> An array of `$class` if `$class` is not `null`, otherwise an array of rows of the form `["LeftTableName" => $stdClass, "RightTableName" => $stdClass]`.
|
|
||||||
*
|
|
||||||
* @throws \PDOException When an error occurs during execution of the query.
|
|
||||||
*/
|
|
||||||
private function ExecuteMultiTableSelect(\PDOStatement $handle, ?string $class): array{
|
|
||||||
$handle->execute();
|
|
||||||
|
|
||||||
$this->LastQueryAffectedRowCount = $handle->rowCount();
|
|
||||||
|
|
||||||
$result = [];
|
|
||||||
do{
|
|
||||||
$columnCount = $handle->columnCount();
|
|
||||||
|
|
||||||
if($columnCount == 0){
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = [];
|
|
||||||
|
|
||||||
for($i = 0; $i < $columnCount; $i++){
|
|
||||||
$metadata[$i] = $handle->getColumnMeta($i);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = $handle->fetchAll(\PDO::FETCH_NUM);
|
|
||||||
|
|
||||||
foreach($rows as $row){
|
|
||||||
$resultRow = [];
|
|
||||||
for($i = 0; $i < $handle->columnCount(); $i++){
|
|
||||||
if($metadata[$i] === false || !isset($metadata[$i]['table'])){
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$object = $resultRow[$metadata[$i]['table']] ?? new stdClass();
|
|
||||||
|
|
||||||
$object->{$metadata[$i]['name']} = $this->GetColumnValue($row[$i], $metadata[$i]);
|
|
||||||
|
|
||||||
$resultRow[$metadata[$i]['table']] = $object;
|
|
||||||
}
|
|
||||||
|
|
||||||
if($class === null){
|
|
||||||
$result[] = $resultRow;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
$result[] = $class::FromMultiTableRow($resultRow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while($handle->nextRowset());
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given a column value and its database driver metadata, return a strongly-typed value.
|
|
||||||
*
|
|
||||||
* @param mixed $column The value of the column, most likely either a string or integer.
|
|
||||||
* @param array<mixed> $metadata An array of metadata returned from the database driver.
|
|
||||||
* @param string $class The type of object that this return value will be part of.
|
|
||||||
*
|
|
||||||
* @return mixed The strongly-typed column value.
|
|
||||||
*/
|
|
||||||
private function GetColumnValue(mixed $column, array $metadata, string $class = 'stdClass'): mixed{
|
|
||||||
if($column === null){
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
switch($metadata['native_type'] ?? null){
|
|
||||||
case 'DATE':
|
|
||||||
case 'DATETIME':
|
|
||||||
case 'TIMESTAMP':
|
|
||||||
/** @throws void */
|
|
||||||
/** @var string $column */
|
|
||||||
return new DateTimeImmutable($column);
|
|
||||||
|
|
||||||
case 'LONG':
|
|
||||||
case 'TINY':
|
|
||||||
case 'SHORT':
|
|
||||||
case 'INT24':
|
|
||||||
case 'LONGLONG':
|
|
||||||
/** @var int $column */
|
|
||||||
return intval($column);
|
|
||||||
|
|
||||||
case 'FLOAT':
|
|
||||||
case 'DOUBLE':
|
|
||||||
case 'NEWDECIMAL':
|
|
||||||
/** @var string $column */
|
|
||||||
return floatval($column);
|
|
||||||
|
|
||||||
case 'STRING':
|
|
||||||
// We don't check the type VAR_STRING here because in MariaDB, enums are always of type STRING.
|
|
||||||
// Since this check is slow, we don't want to run it unnecessarily.
|
|
||||||
if($class == 'stdClass'){
|
|
||||||
return $column;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
// If the column is a string and we're filling a typed object, check if the object property is a backed enum. If so, generate it using from(). Otherwise, fill it with a string.
|
|
||||||
// Note: Using ReflectionProperty in this way is pretty slow. Maybe we'll think of a
|
|
||||||
// better way to automatically fill enum types later.
|
|
||||||
try{
|
|
||||||
$rp = new ReflectionProperty($class, $metadata['name']);
|
|
||||||
/** @var ?ReflectionNamedType $property */
|
|
||||||
$property = $rp->getType();
|
|
||||||
if($property !== null){
|
|
||||||
$type = $property->getName();
|
|
||||||
if(is_a($type, 'BackedEnum', true)){
|
|
||||||
/** @var string $column */
|
|
||||||
return $type::from($column);
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
return $column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
return $column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(\Exception){
|
|
||||||
return $column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return $column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the ID of the last row that was inserted during this database connection.
|
|
||||||
*
|
|
||||||
* @return int The ID of the last row that was inserted during this database connection.
|
|
||||||
*
|
|
||||||
* @throws Exceptions\DatabaseQueryException When the last inserted ID can't be determined.
|
|
||||||
*/
|
|
||||||
public function GetLastInsertedId(): int{
|
|
||||||
try{
|
|
||||||
$id = $this->_link->lastInsertId();
|
|
||||||
}
|
|
||||||
catch(\PDOException){
|
|
||||||
$id = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if($id === false || $id == '0'){
|
|
||||||
throw new Exceptions\DatabaseQueryException('Couldn\'t get last insert ID.');
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
return intval($id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a detailed `Exceptions\DatabaseQueryException` from a `\PDOException`.
|
|
||||||
*
|
|
||||||
* @param \PDOException $ex The exception to create details from.
|
|
||||||
* @param string $sql The prepared SQL that caused the exception.
|
|
||||||
* @param array<mixed> $params The parameters passed to the prepared SQL.
|
|
||||||
*
|
|
||||||
* @return Exceptions\DatabaseQueryException A more detailed exception to be thrown further up the stack.
|
|
||||||
*/
|
|
||||||
private function CreateDetailedException(\PDOException $ex, string $sql, array $params): Exceptions\DatabaseQueryException{
|
|
||||||
// Throw a custom exception that includes more information on the query and paramaters
|
|
||||||
return new Exceptions\DatabaseQueryException('Error when executing query: ' . $ex->getMessage() . '. Query: ' . $sql . '. Parameters: ' . vds($params));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
<?
|
<?
|
||||||
namespace Exceptions;
|
namespace Exceptions;
|
||||||
|
|
||||||
class DuplicateDatabaseKeyException extends AppException{
|
class DuplicateDatabaseKeyException extends DatabaseQueryException{
|
||||||
/** @var string $message */
|
/** @var string $message */
|
||||||
protected $message = 'An attempted row insertion has violated the database unique index.';
|
protected $message = 'An attempted row insertion has violated the database unique index.';
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
namespace Traits;
|
namespace Traits;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normally, the `DbConnection` class fills in an object itself, using reflection to decide on enums. Sometimes, we want to define an explicit `FromRow()` method on a class. This trait provides a default `FromRow()` method that assigns columns to object properties, and attemps to figure out enum types. The object can override this method if necessary.
|
* Normally, the `Db` class fills in an object itself, using reflection to decide on enums. Sometimes, we want to define an explicit `FromRow()` method on a class. This trait provides a default `FromRow()` method that assigns columns to object properties, and attemps to figure out enum types. The object can override this method if necessary.
|
||||||
*/
|
*/
|
||||||
trait FromRow{
|
trait FromRow{
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue