mc-cms-namelessmc/core/classes/Database/DB.php

589 lines
17 KiB
PHP
Raw Permalink Normal View History

<?php
/**
* Creates a singleton connection to the database with credentials from the config file.
*
* @package NamelessMC\Database
* @author Samerton
* @version 2.0.0-pr13
* @license MIT
*/
class DB
{
2022-05-11 12:14:37 +02:00
private static ?DB $_instance = null;
2020-12-13 19:38:04 +01:00
private string $_prefix;
private ?string $_force_charset;
private ?string $_force_collation;
2022-05-11 12:14:37 +02:00
protected PDO $_pdo;
private PDOStatement $_statement;
private bool $_error = false;
private array $_results;
private int $_count = 0;
protected QueryRecorder $_query_recorder;
private function __construct(
string $host,
string $database,
string $username,
string $password,
int $port,
?string $force_charset,
?string $force_collation,
string $prefix
) {
$this->_force_charset = $force_charset;
$this->_force_collation = $force_collation;
$this->_prefix = $prefix;
$connection_string = 'mysql:host=' . $host . ';port=' . $port . ';dbname=' . $database;
if ($force_charset) {
$connection_string .= ';charset=' . $force_charset;
2020-12-13 19:38:04 +01:00
}
$this->_pdo = new PDO(
$connection_string,
$username,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]
);
2021-10-25 19:49:12 -07:00
$this->_query_recorder = QueryRecorder::getInstance();
2020-12-13 19:38:04 +01:00
}
2022-05-11 12:14:37 +02:00
2022-06-13 22:02:59 -06:00
public static function getCustomInstance(
string $host,
string $database,
string $username,
string $password,
int $port = 3306,
?string $force_charset = null,
?string $force_collation = null,
2022-06-13 22:02:59 -06:00
string $prefix = 'nl2_'
): DB {
return new DB(
$host,
$database,
$username,
$password,
$port,
$force_charset,
$force_collation,
$prefix
);
2022-06-13 22:02:59 -06:00
}
public static function getInstance(): DB
{
2022-06-13 22:02:59 -06:00
if (self::$_instance) {
return self::$_instance;
}
if (Config::get('mysql.initialise_charset')) {
$force_charset = Config::get('mysql.charset') ?: 'utf8mb4';
} else {
$force_charset = null;
}
if (Config::get('mysql.initialise_collation')) {
$force_collation = Config::get('mysql.collation') ?: 'utf8mb4_unicode_ci';
} else {
$force_collation = null;
}
2022-06-13 22:02:59 -06:00
return self::$_instance = self::getCustomInstance(
Config::get('mysql.host'),
Config::get('mysql.db'),
Config::get('mysql.username'),
Config::get('mysql.password'),
Config::get('mysql.port'),
$force_charset,
$force_collation
2022-06-13 22:02:59 -06:00
);
}
2022-05-11 12:14:37 +02:00
/**
* Get the underlying PDO instance.
*
* @return PDO The PDO instance.
*/
public function getPDO(): PDO
{
2022-05-11 12:14:37 +02:00
return $this->_pdo;
}
/**
* Begin a MySQL transaction.
*/
public function beginTransaction(): void
{
$this->_pdo->beginTransaction();
}
/**
* Commit a MySQL transaction.
*/
public function commitTransaction(): void
{
if ($this->_pdo->inTransaction()) {
$this->_pdo->commit();
}
}
/**
* Roll back a MySQL transaction.
*/
public function rollBackTransaction(): void
{
if ($this->_pdo->inTransaction()) {
$this->_pdo->rollBack();
}
}
2022-06-10 18:58:36 -06:00
/**
* Execute a database query within a MySQL transaction, and get the results of the query, if any.
2022-06-10 18:58:36 -06:00
*
* @param Closure(DB): mixed $closure The closure to pass this instance to and execute within a transaction context.
* @return mixed The results of the query, null if none.
2022-06-10 18:58:36 -06:00
*/
public function transaction(Closure $closure)
{
$result = null;
try {
$this->beginTransaction();
$result = $closure($this);
$this->commitTransaction();
} catch (Exception $exception) {
$this->rollBackTransaction();
}
return $result;
2022-06-10 18:58:36 -06:00
}
2022-05-11 12:14:37 +02:00
/**
* Get the first row of the result.
*
* @return object|null The result object, or null if no result was returned.
*/
public function first(): ?object
{
2022-05-11 12:14:37 +02:00
return $this->results()[0] ?? null;
}
/**
* Get all the results of the query.
*
* @return array The results of the query.
*/
public function results(): array
{
2022-05-11 12:14:37 +02:00
return $this->_results;
}
/**
* Get the number of rows affected by the last query.
*
* @return int The number of rows.
*/
public function count(): int
{
2022-05-11 12:14:37 +02:00
return $this->_count;
}
Reactions revamp & profile widgets (#3272) * Initial work on reactions revamp and profile widgets * Remove unneeded changes * Remove unneeded change * fix style * Render emojis in reaction list StaffCP * wip minecraft account profile widget, sticky widgets on profile page * Add server name and IP to last seen * remove * rename to reaction score to match member list * align reactions table contents center * fix * wip - nicer abstraction, widgets never query db, skin 3d viewer (undecided) * wip - make reaction modals ajax, copy new reaction bar to profile posts * wip reaction modals * wip proper sorting * wip modals * wip get reaction submission working on profile wall posts * add back helpful and creative default reactions * reduce query amount on view topic * remove debug statement * wip * wip * simplify ProfileWidget pages * Revert forum dropdown * fix css syntax * fix emoji title and alt being strange * add back order input * sort widgets by order in list * fix settings link always showing * phpdoc * add widget name to exception * insert default widget data if not found * missing end if * properly cache forum news per-group * fix * fix cache key, news permission check * fix styling * wip * respect reaction ordering * wip * wip * support custom reaction scores, update member list to properly calculate * don't hardcode words * wip * allow multiple reactions * redirect to post * code style * phpstan * wip * remove hardcoded terms + fix copy * use npm for skinview3d * create ReactionContext and refactor * fix cache * docblocks * fix spelling * pr amendments
2023-06-21 10:19:46 -07:00
/**
* Get whether any results exist.
*
* @return bool Whether any results exist.
*/
public function exists(): bool
{
Reactions revamp & profile widgets (#3272) * Initial work on reactions revamp and profile widgets * Remove unneeded changes * Remove unneeded change * fix style * Render emojis in reaction list StaffCP * wip minecraft account profile widget, sticky widgets on profile page * Add server name and IP to last seen * remove * rename to reaction score to match member list * align reactions table contents center * fix * wip - nicer abstraction, widgets never query db, skin 3d viewer (undecided) * wip - make reaction modals ajax, copy new reaction bar to profile posts * wip reaction modals * wip proper sorting * wip modals * wip get reaction submission working on profile wall posts * add back helpful and creative default reactions * reduce query amount on view topic * remove debug statement * wip * wip * simplify ProfileWidget pages * Revert forum dropdown * fix css syntax * fix emoji title and alt being strange * add back order input * sort widgets by order in list * fix settings link always showing * phpdoc * add widget name to exception * insert default widget data if not found * missing end if * properly cache forum news per-group * fix * fix cache key, news permission check * fix styling * wip * respect reaction ordering * wip * wip * support custom reaction scores, update member list to properly calculate * don't hardcode words * wip * allow multiple reactions * redirect to post * code style * phpstan * wip * remove hardcoded terms + fix copy * use npm for skinview3d * create ReactionContext and refactor * fix cache * docblocks * fix spelling * pr amendments
2023-06-21 10:19:46 -07:00
return $this->_count > 0;
}
2022-05-11 12:14:37 +02:00
/**
* Get the last inserted ID.
2022-05-11 12:14:37 +02:00
*
* @return string|false ID of the last inserted row or false on failure
*/
public function lastId()
{
2022-05-11 12:14:37 +02:00
return $this->_pdo->lastInsertId();
}
/**
* Whether there was an error during the last query.
*
* @return bool Whether there was an error.
*/
public function error(): bool
{
2022-05-11 12:14:37 +02:00
return $this->_error;
}
/**
* Perform a SELECT query on the database.
*
* @param string $table The table to select from.
* @param mixed $where The where clause. If not an array, it will be used for "id" column lookup.
2022-05-11 12:14:37 +02:00
* @return static|false This instance if successful, false otherwise.
*/
public function get(string $table, $where = [])
{
Reactions revamp & profile widgets (#3272) * Initial work on reactions revamp and profile widgets * Remove unneeded changes * Remove unneeded change * fix style * Render emojis in reaction list StaffCP * wip minecraft account profile widget, sticky widgets on profile page * Add server name and IP to last seen * remove * rename to reaction score to match member list * align reactions table contents center * fix * wip - nicer abstraction, widgets never query db, skin 3d viewer (undecided) * wip - make reaction modals ajax, copy new reaction bar to profile posts * wip reaction modals * wip proper sorting * wip modals * wip get reaction submission working on profile wall posts * add back helpful and creative default reactions * reduce query amount on view topic * remove debug statement * wip * wip * simplify ProfileWidget pages * Revert forum dropdown * fix css syntax * fix emoji title and alt being strange * add back order input * sort widgets by order in list * fix settings link always showing * phpdoc * add widget name to exception * insert default widget data if not found * missing end if * properly cache forum news per-group * fix * fix cache key, news permission check * fix styling * wip * respect reaction ordering * wip * wip * support custom reaction scores, update member list to properly calculate * don't hardcode words * wip * allow multiple reactions * redirect to post * code style * phpstan * wip * remove hardcoded terms + fix copy * use npm for skinview3d * create ReactionContext and refactor * fix cache * docblocks * fix spelling * pr amendments
2023-06-21 10:19:46 -07:00
if (!is_array($where)) {
$where = ['id', '=', $where];
}
2022-05-11 12:14:37 +02:00
return $this->action('SELECT *', $table, $where);
}
/**
2022-08-29 17:59:00 -04:00
* Perform a DELETE query on the database.
2022-05-11 12:14:37 +02:00
*
* @param string $table The table to delete from.
* @param mixed $where The where clause. If not an array, it will be used for "id" column lookup.
2022-05-11 12:14:37 +02:00
* @return static|false This instance if successful, false otherwise.
*/
public function delete(string $table, $where)
{
Reactions revamp & profile widgets (#3272) * Initial work on reactions revamp and profile widgets * Remove unneeded changes * Remove unneeded change * fix style * Render emojis in reaction list StaffCP * wip minecraft account profile widget, sticky widgets on profile page * Add server name and IP to last seen * remove * rename to reaction score to match member list * align reactions table contents center * fix * wip - nicer abstraction, widgets never query db, skin 3d viewer (undecided) * wip - make reaction modals ajax, copy new reaction bar to profile posts * wip reaction modals * wip proper sorting * wip modals * wip get reaction submission working on profile wall posts * add back helpful and creative default reactions * reduce query amount on view topic * remove debug statement * wip * wip * simplify ProfileWidget pages * Revert forum dropdown * fix css syntax * fix emoji title and alt being strange * add back order input * sort widgets by order in list * fix settings link always showing * phpdoc * add widget name to exception * insert default widget data if not found * missing end if * properly cache forum news per-group * fix * fix cache key, news permission check * fix styling * wip * respect reaction ordering * wip * wip * support custom reaction scores, update member list to properly calculate * don't hardcode words * wip * allow multiple reactions * redirect to post * code style * phpstan * wip * remove hardcoded terms + fix copy * use npm for skinview3d * create ReactionContext and refactor * fix cache * docblocks * fix spelling * pr amendments
2023-06-21 10:19:46 -07:00
if (!is_array($where)) {
$where = ['id', '=', $where];
}
2022-05-11 12:14:37 +02:00
return $this->action('DELETE', $table, $where);
}
/**
* Perform a raw SQL query on the database.
*
* @param string $sql The SQL query string to execute.
* @param array $params The parameters to bind to the query.
* @param bool $isSelect Whether the statement is a select, defaults to null
2022-05-11 12:14:37 +02:00
* @return static This DB instance.
*/
public function query(string $sql, array $params = [], ?bool $isSelect = null)
{
2022-05-11 12:14:37 +02:00
$this->_error = false;
if ($this->_statement = $this->_pdo->prepare($sql)) {
$x = 1;
foreach ($params as $param) {
// Convert "true" and "false" to 1 and 0 so that query params can be more fluent
if (is_bool($param)) {
$param = $param ? 1 : 0;
}
$this->_statement->bindValue(
$x,
$param,
is_int($param)
2022-05-11 12:14:37 +02:00
? PDO::PARAM_INT
: PDO::PARAM_STR
);
$x++;
}
$this->_query_recorder->pushQuery($sql, $params);
if ($this->_statement->execute()) {
// Only fetch the results if this is a SELECT query.
if ($isSelect || str_starts_with(strtoupper(ltrim($sql)), 'SELECT')) {
$this->_results = $this->_statement->fetchAll(PDO::FETCH_OBJ);
2022-05-11 12:14:37 +02:00
}
$this->_count = $this->_statement->rowCount();
} else {
print_r($this->_pdo->errorInfo());
$this->_error = true;
}
} else {
$this->_results = [];
2022-05-11 12:14:37 +02:00
}
return $this;
}
/**
* Execute some SQL action (which uses a where clause) on the database.
*
* @param string $action The action to perform (SELECT, DELETE).
* @param string $table The table to perform the action on.
* @param array $where The where clause.
2022-05-11 12:14:37 +02:00
* @return static|false This instance if successful, false otherwise.
*/
private function action(string $action, string $table, array $where = [])
{
[$where, $where_params] = self::makeWhere($where);
2022-05-11 12:14:37 +02:00
$table = $this->_prefix . $table;
$sql = "{$action} FROM {$table} {$where}";
2022-05-11 12:14:37 +02:00
if (!$this->query($sql, $where_params)->error()) {
2022-05-11 12:14:37 +02:00
return $this;
}
return false;
}
/**
* Insert a new row into a table within the database.
*
* @param string $table The table to insert into.
* @param array $fields Array of data in "column => value" format to insert.
* @return bool Whether an error occurred or not.
2022-05-11 12:14:37 +02:00
*/
public function insert(string $table, array $fields = []): bool
{
2022-05-11 12:14:37 +02:00
$keys = array_keys($fields);
$fieldCount = count($fields);
$values = '';
$x = 1;
for ($i = 0; $i < $fieldCount; $i++) {
$values .= '?';
if ($x < $fieldCount) {
$values .= ', ';
}
$x++;
}
$table = $this->_prefix . $table;
$sql = "INSERT INTO {$table} (`" . implode('`,`', $keys) . "`) VALUES ({$values})";
return !$this->query($sql, $fields)->error();
}
/**
* Perform an UPDATE query on a table.
*
* @param string $table The table to update.
* @param mixed $where The where clause. If not an array, it will be used for "id" column lookup.
* @param array $fields Array of data in "column => value" format to update.
* @return bool Whether an error occurred or not.
2022-05-11 12:14:37 +02:00
*/
public function update(string $table, $where, array $fields): bool
{
2022-05-11 12:14:37 +02:00
$set = '';
$x = 1;
foreach (array_keys($fields) as $column) {
$set .= "`{$column}` = ?";
if ($x < count($fields)) {
$set .= ', ';
}
$x++;
}
if (!is_array($where)) {
$where = ['id', '=', $where];
2022-05-11 12:14:37 +02:00
}
[$where, $where_params] = self::makeWhere($where);
2022-05-11 12:14:37 +02:00
$table = $this->_prefix . $table;
$sql = "UPDATE {$table} SET {$set} $where";
2022-05-11 12:14:37 +02:00
return !$this->query($sql, array_merge($fields, $where_params))->error();
2022-05-11 12:14:37 +02:00
}
/**
* Increment a numeric column value by 1.
*
* @param string $table The table to use.
* @param int $id The id of the row to increment a column in.
* @param string $field The field to increment.
* @return bool Whether an error occurred or not.
2022-05-11 12:14:37 +02:00
*/
public function increment(string $table, int $id, string $field): bool
{
2022-05-11 12:14:37 +02:00
$table = $this->_prefix . $table;
return !$this->query("UPDATE {$table} SET {$field} = {$field} + 1 WHERE id = ?", [$id])->error();
2022-06-04 09:43:51 -06:00
}
/**
* Decrement a numeric column value by 1.
*
* @param string $table The table to use.
* @param int $id The id of the row to decrement a column in.
* @param string $field The field to increment.
* @return bool Whether an error occurred or not.
2022-06-04 09:43:51 -06:00
*/
public function decrement(string $table, int $id, string $field): bool
{
2022-06-04 09:43:51 -06:00
$table = $this->_prefix . $table;
return !$this->query("UPDATE {$table} SET {$field} = {$field} - 1 WHERE id = ?", [$id])->error();
2022-05-11 12:14:37 +02:00
}
/**
* Select rows from the database, ordering by a specific column and sort type.
*
* @param string $table The table to use.
* @param string $order The column to order by.
* @param string $sort ASC or DESC
2022-05-11 12:14:37 +02:00
* @return static|false This instance if successful, false otherwise.
*/
public function orderAll(string $table, string $order, string $sort)
{
2022-05-11 12:14:37 +02:00
$table = $this->_prefix . $table;
$sql = "SELECT * FROM {$table} ORDER BY {$order} {$sort}";
if (!$this->query($sql)->error()) {
return $this;
}
return false;
}
/**
* Select rows from the database with a where clause, ordering by a specific column and sort type.
*
* @param string $table The table to use.
* @param string $order The column to order by.
* @param string $sort ASC or DESC
2022-05-11 12:14:37 +02:00
* @return static|false This instance if successful, false otherwise.
*/
public function orderWhere(string $table, string $where, string $order, string $sort)
{
2022-05-11 12:14:37 +02:00
$table = $this->_prefix . $table;
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY {$order} {$sort}";
if (!$this->query($sql)->error()) {
return $this;
}
return false;
}
/**
* Create a new table in the database.
*
* @param string $name The name of the table.
* @param string $table_schema The table SQL schema.
* @return bool Whether an error occurred or not.
2022-05-11 12:14:37 +02:00
*/
public function createTable(string $name, string $table_schema): bool
{
2022-05-11 12:14:37 +02:00
$name = $this->_prefix . $name;
$sql = "CREATE TABLE `{$name}` ({$table_schema}) ENGINE=InnoDB";
if ($this->_force_charset) {
$sql .= ' DEFAULT CHARSET=' . $this->_force_charset;
}
2022-05-11 12:14:37 +02:00
if ($this->_force_collation) {
$sql .= ' COLLATE=' . $this->_force_collation;
}
2022-05-11 12:14:37 +02:00
return !$this->query($sql)->error();
}
/**
* Perform a SHOW TABLES LIKE query.
*
* @param string $table Name of table to try and lookup.
2022-05-11 12:14:37 +02:00
* @return int|false The number of rows affected, or false on failure.
*/
public function showTables(string $table)
{
2022-05-11 12:14:37 +02:00
$table = $this->_prefix . $table;
$sql = "SHOW TABLES LIKE '{$table}'";
if (!$this->query($sql)->error()) {
return $this->_statement->rowCount();
}
return false;
}
/**
* Add a new column to a table.
*
* @param string $table Name of table to alter.
* @param string $column The column to add.
* @param string $attributes The attributes of the column.
* @return bool Whether an error occurred or not.
2022-05-11 12:14:37 +02:00
*/
public function addColumn(string $table, string $column, string $attributes): bool
{
2022-05-11 12:14:37 +02:00
$table = $this->_prefix . $table;
$sql = "ALTER TABLE {$table} ADD {$column} {$attributes}";
return !$this->query($sql)->error();
}
/**
* Convert an array of where clause data into a MySQL WHERE clause and params.
2022-05-11 12:14:37 +02:00
*
* @param array $clauses An array, or nested array, of
* column, operator (default =), value, and glue (default AND).
* @return array The where clause string, and parameters to bind.
2022-05-11 12:14:37 +02:00
*/
public static function makeWhere(array $clauses): array
{
if (count($clauses) === count($clauses, COUNT_RECURSIVE)) {
return self::makeWhere([$clauses]);
}
$where_clauses = [];
foreach ($clauses as $clause) {
if (!is_array($clause)) {
throw new InvalidArgumentException('Where clause must be an array');
}
if (count($clause) !== count($clause, COUNT_RECURSIVE)) {
self::makeWhere(...$clause);
continue;
}
$column = null;
2022-05-11 12:14:37 +02:00
$operator = '=';
$value = null;
$glue = 'AND';
switch (count($clause)) {
case 4:
[$column, $operator, $value, $glue] = $clause;
break;
case 3:
[$column, $operator, $value] = $clause;
break;
case 2:
[$column, $value] = $clause;
break;
default:
throw new InvalidArgumentException('Invalid where clause');
}
if (!in_array($operator, ['=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE'])) {
throw new InvalidArgumentException("Invalid operator: {$operator}");
}
$where_clauses[] = [
'column' => $column,
'operator' => $operator,
'value' => $value,
'glue' => $glue,
];
2022-05-11 12:14:37 +02:00
}
$first = true;
$where = '';
$params = [];
foreach ($where_clauses as $clause) {
if ($first) {
$where .= 'WHERE ';
$first = false;
} else {
$where .= " {$clause['glue']} ";
}
$where .= "`{$clause['column']}` {$clause['operator']} ?";
$params[] = $clause['value'];
2022-05-11 12:14:37 +02:00
}
return [$where, $params];
2022-05-11 12:14:37 +02:00
}
2020-12-13 19:38:04 +01:00
}