包含:登录失败锁定、90天密码有效期、30分钟会话超时、 强制改密、登录审计日志、屏幕水印、企业背景图、 备案信息固定底部、favicon、登录页JS修复等全部改动
This commit is contained in:
@@ -0,0 +1,684 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\DB;
|
||||
|
||||
use MRBS\Column;
|
||||
use MRBS\Columns;
|
||||
use MRBS\Exception;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use Throwable;
|
||||
use function MRBS\mrbs_ignore_user_abort;
|
||||
|
||||
|
||||
abstract class DB
|
||||
{
|
||||
const DB_SCHEMA_VERSION = 82;
|
||||
const DB_SCHEMA_VERSION_LOCAL = 1;
|
||||
|
||||
const DB_DEFAULT_PORT = null;
|
||||
const DB_DBO_DRIVER = null;
|
||||
const DB_CHARSET = 'UTF8';
|
||||
|
||||
protected $dbh = null;
|
||||
protected $mutex_locks = array();
|
||||
protected $version_string = null;
|
||||
|
||||
|
||||
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
|
||||
// The attribute is only recognised by PHP 8.2 and later.
|
||||
abstract public function __construct(
|
||||
string $db_host,
|
||||
#[\SensitiveParameter]
|
||||
string $db_username,
|
||||
#[\SensitiveParameter]
|
||||
string $db_password,
|
||||
#[\SensitiveParameter]
|
||||
string $db_name,
|
||||
bool $persist = false,
|
||||
?int $db_port = null,
|
||||
array $db_options = []
|
||||
);
|
||||
|
||||
|
||||
/**
|
||||
* Destructor. Cleans up the connection if there is one.
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
try {
|
||||
// Release any forgotten locks
|
||||
$this->mutex_unlock_all();
|
||||
|
||||
// Rollback any outstanding transactions
|
||||
$this->rollback();
|
||||
}
|
||||
catch (Throwable $e) {
|
||||
// Don't do anything, except raise an error. This is the destructor and if we get an
|
||||
// exception or error it's probably because the connection has been lost or timed out,
|
||||
// in which case the locks will have been released and the transaction rolled back anyway.
|
||||
trigger_error($e->getMessage(), E_USER_NOTICE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Build a DSN.
|
||||
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
|
||||
// The attribute is only recognised by PHP 8.2 and later.
|
||||
public static function dsn(
|
||||
string $db_host,
|
||||
#[\SensitiveParameter]
|
||||
string $db_name,
|
||||
?int $db_port = null
|
||||
) : string
|
||||
{
|
||||
// Early error handling
|
||||
if (is_null(static::DB_DBO_DRIVER) ||
|
||||
is_null(static::DB_DEFAULT_PORT)) {
|
||||
throw new Exception("Encountered a fatal bug in DB abstraction code!");
|
||||
}
|
||||
|
||||
// Prefix
|
||||
$result = static::DB_DBO_DRIVER . ':';
|
||||
|
||||
// Host
|
||||
if ($db_host !== '') {
|
||||
$result .= 'host=' . $db_host . ';';
|
||||
}
|
||||
|
||||
// Port
|
||||
if (empty($db_port)) {
|
||||
$db_port = static::DB_DEFAULT_PORT;
|
||||
}
|
||||
$result .= 'port=' . $db_port . ';';
|
||||
|
||||
// Database name
|
||||
$result .= 'dbname=' . $db_name;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
|
||||
// The attribute is only recognised by PHP 8.2 and later.
|
||||
// $driver_options is an optional array of options that supplements/overrides the
|
||||
// default options.
|
||||
protected function connect(
|
||||
string $db_host,
|
||||
#[\SensitiveParameter]
|
||||
string $db_username,
|
||||
#[\SensitiveParameter]
|
||||
string $db_password,
|
||||
#[\SensitiveParameter]
|
||||
string $db_name,
|
||||
bool $persist = false,
|
||||
?int $db_port = null,
|
||||
?array $driver_options = null
|
||||
): void
|
||||
{
|
||||
// Establish a database connection.
|
||||
$default_options = array(
|
||||
PDO::ATTR_PERSISTENT => $persist,
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
|
||||
);
|
||||
// The LHS of the array + operator overrides the RHS if the keys are the same
|
||||
$options = (empty($driver_options)) ? $default_options : $driver_options + $default_options;
|
||||
|
||||
$this->dbh = new PDO(
|
||||
static::dsn($db_host, $db_name, $db_port),
|
||||
$db_username,
|
||||
$db_password,
|
||||
$options
|
||||
);
|
||||
$this->command("SET NAMES '" . static::DB_CHARSET . "'");
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
public function error(): string
|
||||
{
|
||||
$error = "No database connection!";
|
||||
|
||||
if ($this->dbh) {
|
||||
$error_info = $this->dbh->errorInfo();
|
||||
$error = $error_info[2];
|
||||
}
|
||||
return $error;
|
||||
}
|
||||
|
||||
|
||||
public function getAttribute(int $attribute)
|
||||
{
|
||||
return $this->dbh->getAttribute($attribute);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Execute a non-SELECT SQL command (insert/update/delete).
|
||||
*
|
||||
* @return int The number of tuples matched (whether affected or not) if OK (a number >= 0)
|
||||
* @throws DBException
|
||||
*/
|
||||
public function command(string $sql, array $params = array()): int
|
||||
{
|
||||
try
|
||||
{
|
||||
$sth = $this->dbh->prepare($sql);
|
||||
$sth->execute($params);
|
||||
}
|
||||
catch (PDOException $e)
|
||||
{
|
||||
throw new DBException($e->getMessage(), 0, $e, $sql, $params);
|
||||
}
|
||||
|
||||
return $sth->rowCount();
|
||||
}
|
||||
|
||||
|
||||
// Execute an SQL query which should return a single non-negative integer value.
|
||||
// This is a lightweight alternative to query(), good for use with count(*)
|
||||
// and similar queries.
|
||||
// It returns -1 if the query returns no result, or a single NULL value, such as from
|
||||
// a MIN or MAX aggregate function applied over no rows.
|
||||
// Throws a DBException on error.
|
||||
public function query1(string $sql, array $params = array()) : int
|
||||
{
|
||||
$result = $this->query_scalar_non_bool($sql, $params);
|
||||
|
||||
if (is_null($result) || ($result === false))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Check that the result looks like an integer, even though it may be a string, and then cast
|
||||
// it to an integer. For example "2" is OK, but "2.0" is not.
|
||||
$result = filter_var($result, FILTER_VALIDATE_INT);
|
||||
|
||||
if ($result === false)
|
||||
{
|
||||
throw new \UnexpectedValueException("query1() should only be used for selecting integer values.");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Execute an SQL query which should return a single scalar value that can be anything
|
||||
* other than a boolean (because the function returns FALSE if there is no value).
|
||||
*
|
||||
* @return mixed The value returned by the query, or FALSE if there is none.
|
||||
* @throws DBException
|
||||
*/
|
||||
public function query_scalar_non_bool(string $sql, array $params = [])
|
||||
{
|
||||
try
|
||||
{
|
||||
$sth = $this->dbh->prepare($sql);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchColumn();
|
||||
}
|
||||
catch (PDOException $e)
|
||||
{
|
||||
throw new DBException($e->getMessage(), 0, $e, $sql, $params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Run an SQL query that returns a simple one-dimensional array of results.
|
||||
* The SQL query must select only one column.
|
||||
*
|
||||
* @return array The results, as an array of scalars, or an empty array if there are no results.
|
||||
* @throws DBException
|
||||
*/
|
||||
public function query_array(string $sql, array $params = []): array
|
||||
{
|
||||
$stmt = $this->query($sql, $params);
|
||||
|
||||
$result = [];
|
||||
|
||||
while (false !== ($row = $stmt->next_row()))
|
||||
{
|
||||
$result[] = $row[0];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Execute an SQL query.
|
||||
*
|
||||
* @throws DBException
|
||||
*/
|
||||
public function query(string $sql, array $params = []): DBStatement
|
||||
{
|
||||
try {
|
||||
$sth = $this->dbh->prepare($sql);
|
||||
$sth->execute($params);
|
||||
} catch (PDOException $e) {
|
||||
throw new DBException($e->getMessage(), 0, $e, $sql, $params);
|
||||
}
|
||||
|
||||
return new DBStatement($this, $sth);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Begin a transaction. If already inside a transaction, this is a no-op.
|
||||
*
|
||||
* @see PDO::beginTransaction()
|
||||
*/
|
||||
public function begin(): void
|
||||
{
|
||||
// Turn off ignore_user_abort until the transaction has been committed or rolled back.
|
||||
// See the warning at http://php.net/manual/en/features.persistent-connections.php
|
||||
// (Only applies to persistent connections, but we'll do it for all cases to keep
|
||||
// things simple)
|
||||
mrbs_ignore_user_abort(true);
|
||||
if (!$this->dbh->inTransaction()) {
|
||||
$this->dbh->beginTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Commit a transaction. If not already inside a transaction, this is a no-op.
|
||||
*
|
||||
* @see PDO::commit()
|
||||
*/
|
||||
public function commit(): void
|
||||
{
|
||||
if ($this->dbh->inTransaction()) {
|
||||
$this->dbh->commit();
|
||||
}
|
||||
mrbs_ignore_user_abort(false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Roll back a transaction. If not already inside a transaction, this is a no-op.
|
||||
*
|
||||
* @see PDO::rollBack()
|
||||
*/
|
||||
public function rollback(): void
|
||||
{
|
||||
if ($this->dbh && $this->dbh->inTransaction()) {
|
||||
$this->dbh->rollBack();
|
||||
}
|
||||
mrbs_ignore_user_abort(false);
|
||||
}
|
||||
|
||||
|
||||
// Checks if inside a transaction
|
||||
public function inTransaction(): bool
|
||||
{
|
||||
return $this->dbh->inTransaction();
|
||||
}
|
||||
|
||||
|
||||
// Dies with a message that the database version is lower than the minimum required
|
||||
protected function versionDie(string $database, string $this_version, string $min_version): void
|
||||
{
|
||||
$message = "MRBS requires $database version $min_version or higher. " .
|
||||
"This server is running version $this_version.";
|
||||
die($message);
|
||||
}
|
||||
|
||||
|
||||
// Returns the version string, eg "8.0.28",
|
||||
// "10.3.36-MariaDB-log-cll-lve" or
|
||||
// "PostgreSQL 14.2, compiled by Visual C++ build 1914, 64-bit".
|
||||
protected function versionString(): string
|
||||
{
|
||||
if (!isset($this->version_string)) {
|
||||
// Don't use getAttribute(PDO::ATTR_SERVER_VERSION) because that will
|
||||
// sometimes also give you the version prefix (so-called "replication
|
||||
// version hack") with MariaDB.
|
||||
$result = $this->query_scalar_non_bool("SELECT VERSION()");
|
||||
|
||||
$this->version_string = ($result === false) ? '' : $result;
|
||||
}
|
||||
|
||||
return $this->version_string;
|
||||
}
|
||||
|
||||
|
||||
// Replaces the keys in the array $array according to $key_map. Elements with
|
||||
// value NULL are dropped.
|
||||
protected static function replaceOptionKeys(array $array, array $key_map): array
|
||||
{
|
||||
$result = array();
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if (isset($value)) {
|
||||
if (array_key_exists($key, $key_map)) {
|
||||
$result[$key_map[$key]] = $value;
|
||||
}
|
||||
else {
|
||||
trigger_error("Unsupported database driver option '$key'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// Return a boolean depending on whether $field exists in $table
|
||||
public function field_exists(string $table, string $field): bool
|
||||
{
|
||||
$rows = $this->field_info($table);
|
||||
foreach ($rows as $row) {
|
||||
if ($row['name'] === $field) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Checks whether a table has duplicate values for a field
|
||||
public function tableHasDuplicates(string $table, string $field): bool
|
||||
{
|
||||
$sql = "SELECT $field, COUNT(*)
|
||||
FROM $table
|
||||
GROUP BY $field
|
||||
HAVING COUNT(*) > 1";
|
||||
$res = $this->query($sql);
|
||||
return ($res->count() > 0);
|
||||
}
|
||||
|
||||
// Quote a table or column name (which could be a qualified identifier, eg 'table.column')
|
||||
abstract public function quote(string $identifier): string;
|
||||
|
||||
// Return the value of an autoincrement field from the last insert.
|
||||
// Must be called right after an insert on that table!
|
||||
abstract public function insert_id(string $table, string $field) : int;
|
||||
|
||||
/**
|
||||
* Acquire a mutual-exclusion lock.
|
||||
*
|
||||
* WARNING: The use of this method should be avoided as GET_LOCK (used in the MySQL implementation) is not supported
|
||||
* by MariaDB Galera Cluster (and other cluster implementations?).
|
||||
*
|
||||
* @return bool Returns true if the lock is acquired successfully, otherwise false.
|
||||
*/
|
||||
abstract public function mutex_lock(string $name): bool;
|
||||
|
||||
/**
|
||||
* Release a mutual-exclusion lock.
|
||||
*
|
||||
* WARNING: The use of this method should be avoided as RELEASE_LOCK (used in the MySQL implementation) is not
|
||||
* supported by MariaDB Galera Cluster (and other cluster implementations?).
|
||||
*
|
||||
* @return bool Returns true if the lock is released successfully, otherwise false.
|
||||
*/
|
||||
abstract public function mutex_unlock(string $name): bool;
|
||||
|
||||
/**
|
||||
* Release all mutual-exclusion locks.
|
||||
*
|
||||
* WARNING: The use of this method should be avoided as RELEASE_ALL_LOCKS (used in the MySQL implementation) is not
|
||||
* supported by MariaDB Galera Cluster (and other cluster implementations?).
|
||||
*/
|
||||
abstract public function mutex_unlock_all(): void;
|
||||
|
||||
/**
|
||||
* Return a string identifying the database version and type.
|
||||
*/
|
||||
abstract public function version(): string;
|
||||
|
||||
/**
|
||||
* Check if a table exists.
|
||||
*/
|
||||
abstract public function table_exists(string $table): bool;
|
||||
|
||||
/**
|
||||
* Get information about the columns in a table.
|
||||
*
|
||||
* NOTE: the type mapping is incomplete and just covers the types commonly used by MRBS.
|
||||
*
|
||||
* @return array An array with the following keys for each column:
|
||||
* - **name** the column name
|
||||
* - **type** the type as reported by MySQL
|
||||
* - **nature** the type mapped onto one of a generic set of types
|
||||
* (boolean, integer, real, character, binary). This enables
|
||||
* the nature to be used by MRBS code when deciding how to
|
||||
* display fields, without MRBS having to worry about the
|
||||
* differences between MySQL and PostgreSQL type names.
|
||||
* - **length** the maximum length of the field in bytes, octets or characters
|
||||
* (Note: this could be NULL)
|
||||
* - **is_nullable** whether the column can be set to NULL (boolean)
|
||||
*/
|
||||
abstract public function field_info(string $table): array;
|
||||
|
||||
// Syntax methods
|
||||
|
||||
/**
|
||||
* Generate the SQL for LIMIT clauses.
|
||||
*/
|
||||
abstract public function syntax_limit(int $count, int $offset): string;
|
||||
|
||||
/**
|
||||
* Generate the SQL for converting a TIMESTAMP to a Unix timestamp.
|
||||
*/
|
||||
abstract public function syntax_timestamp_to_unix(string $fieldname): string;
|
||||
|
||||
/**
|
||||
* Generate the SQL for a case-sensitive string "equals" function.
|
||||
*
|
||||
* NB: This method is assumed to do a strict comparison, eg take account of trailing spaces.
|
||||
*
|
||||
* @param array &$params The SQL parameters, which will be modified by this function.
|
||||
*/
|
||||
abstract public function syntax_casesensitive_equals(string $fieldname, string $string, array &$params): string;
|
||||
|
||||
/**
|
||||
* Generate the SQL for a case-insensitive string "contains" function.
|
||||
*
|
||||
* @param string $string The (unescaped) string to search for.
|
||||
* @param array &$params The SQL parameters, which will be modified by this function.
|
||||
*/
|
||||
abstract public function syntax_caseless_contains(string $fieldname, string $string, array &$params): string;
|
||||
|
||||
/**
|
||||
* Generate the SQL to add a table column after another specified column.
|
||||
*/
|
||||
abstract public function syntax_addcolumn_after(string $fieldname): string;
|
||||
|
||||
/**
|
||||
* Generate the SQL to specify a column as an auto-incrementing integer while doing a CREATE TABLE.
|
||||
*/
|
||||
abstract public function syntax_createtable_autoincrementcolumn(): string;
|
||||
|
||||
/**
|
||||
* Generate the SQL for a bitwise XOR operator.
|
||||
*/
|
||||
abstract public function syntax_bitwise_xor(): string;
|
||||
|
||||
/**
|
||||
* Generate the syntax for a column being in a list of values.
|
||||
*/
|
||||
public function syntax_in_list(string $column_name, array $list, array &$params) : string
|
||||
{
|
||||
// Empty lists aren't allowed.
|
||||
if (count($list) === 0)
|
||||
{
|
||||
return 'FALSE';
|
||||
}
|
||||
|
||||
$params = array_merge($params, $list);
|
||||
|
||||
return $this->quote($column_name) . " IN (" . implode(',', array_fill(0, count($list), '?')) . ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the SQL for a simple split of a column's value into two parts, separated by a delimiter. Note: this
|
||||
* function assumes there is only one occurrence of the delimiter in the column's value.
|
||||
*
|
||||
* @param int $part The part to return, either 1 for the text to the left of the delimiter, or 2 for the text to the right.
|
||||
* @param array $params The SQL parameters, which will be modified by this function.
|
||||
*/
|
||||
abstract public function syntax_simple_split(string $fieldname, string $delimiter, int $part, array &$params): string;
|
||||
|
||||
/**
|
||||
* Generate the SQL for aggregating a number of rows as a delimited string.
|
||||
*/
|
||||
abstract public function syntax_group_array_as_string(string $fieldname, string $delimiter = ','): string;
|
||||
|
||||
// Returns the syntax for an "upsert" query. Unfortunately getting the id of the
|
||||
// last row differs between MySQL and PostgreSQL. In PostgreSQL the query will
|
||||
// return a row with the id in the 'id' column. However there isn't a corresponding
|
||||
// way of doing this in MySQL, but db()->insert_id() will work, regardless of whether
|
||||
// an insert or update was performed.
|
||||
//
|
||||
// $conflict_keys the key(s) which is/are unique; can be a scalar or an array
|
||||
// $assignments an array of assignments for the UPDATE clause
|
||||
// $has_id_column whether the table has an id column
|
||||
abstract public function syntax_on_duplicate_key_update(
|
||||
$conflict_keys,
|
||||
array $assignments,
|
||||
bool $has_id_column=false
|
||||
) : string;
|
||||
|
||||
/**
|
||||
* Determines whether the driver returns native types (eg a PHP int for an SQL INT).
|
||||
*/
|
||||
abstract public function returnsNativeTypes() : bool;
|
||||
|
||||
/**
|
||||
* Determines whether the database supports multiple locks. Note that:
|
||||
* - Use of this method should be avoided as RELEASE_ALL_LOCKS (used in the MySQL implementation) is not supported
|
||||
* by MariaDB Galera Cluster.
|
||||
* - This method should not be called for the first time while locks are in place, because it will release them.
|
||||
*/
|
||||
abstract public function supportsMultipleLocks(): bool;
|
||||
|
||||
/**
|
||||
* Constructs an SQL upsert (insert or update) query based on the provided data and parameters.
|
||||
*
|
||||
* Unfortunately, getting the id of the last row differs between MySQL and PostgreSQL. In PostgreSQL the query will
|
||||
* return a row with the id in the 'id' column. However, there isn't a corresponding way of doing this in MySQL, but
|
||||
* db()->insert_id() will work, regardless of whether an insert or update was performed.
|
||||
*
|
||||
* @param array $data An associative array of data to be inserted or updated, indexed by column name.
|
||||
* @param string $table The table name where the data should be inserted or updated.
|
||||
* @param array &$params A reference to an array where the generated SQL parameters will be stored.
|
||||
* @param array|string $conflict_keys A list of column names or a single column name that will be used to detect conflicts (e.g., unique constraints).
|
||||
* @param array $ignore_columns A list of columns to be excluded from the query.
|
||||
* @param bool $has_id_column Indicates whether the table includes an ID column that requires special handling.
|
||||
* @return string The constructed SQL upsert query string.
|
||||
*/
|
||||
public function syntax_upsert(array $data, string $table, array &$params, $conflict_keys=[], array $ignore_columns=[], bool $has_id_column = false): string
|
||||
{
|
||||
if (is_scalar($conflict_keys))
|
||||
{
|
||||
$conflict_keys = array($conflict_keys);
|
||||
}
|
||||
|
||||
list('columns' => $columns, 'values' => $values, 'sql_params' => $params) = $this->prepareData($data, $table, $ignore_columns);
|
||||
$quoted_columns = array_map(array(\MRBS\db(), 'quote'), $columns);
|
||||
$sql = "INSERT INTO " . $this->quote($table) . "
|
||||
(" . implode(', ', $quoted_columns) . ")
|
||||
VALUES (" . implode(', ', $values) . ") ";
|
||||
|
||||
// Go through the columns we've just found and turn them into assignments
|
||||
// for the update part
|
||||
$assignments = array();
|
||||
for ($i=0; $i<count($columns); $i++)
|
||||
{
|
||||
$column = $columns[$i];
|
||||
$value = $values[$i];
|
||||
$assignments[] = $this->quote($column) . "=$value";
|
||||
}
|
||||
|
||||
$sql .= \MRBS\db()->syntax_on_duplicate_key_update(
|
||||
$conflict_keys,
|
||||
$assignments,
|
||||
$has_id_column
|
||||
);
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Prepare data for an SQL query. If `$table` is given, then it will also sanitize values, eg by trimming and
|
||||
* truncating strings and converting booleans into 0/1.
|
||||
*/
|
||||
private function prepareData(array $data, ?string $table=null, array $ignore_columns=[]): array
|
||||
{
|
||||
$columns = array();
|
||||
$values = array();
|
||||
$sql_params = array();
|
||||
|
||||
$cols = (isset($table)) ? Columns::getInstance($table) : array_keys($data);
|
||||
|
||||
$i = 0;
|
||||
foreach ($cols as $col)
|
||||
{
|
||||
// We are only interested in those elements of $data that have a corresponding
|
||||
// column in the table - except for those that we have been told to ignore.
|
||||
// Examples might be 'id' which normally auto-increments, and 'timestamp' which
|
||||
// normally auto-updates.
|
||||
if (is_object($col) && in_array($col->name, $ignore_columns))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$column_name = (is_object($col)) ? $col->name : $col;
|
||||
$columns[] = $column_name;
|
||||
|
||||
if (!isset($data[$column_name]) && (!is_object($col) || $col->getIsNullable()))
|
||||
{
|
||||
$values[] = 'NULL';
|
||||
}
|
||||
else
|
||||
{
|
||||
// Need to make sure the placeholder only uses allowed characters which are
|
||||
// [a-zA-Z0-9_]. We can't use the column name because the column name might
|
||||
// contain characters which are not allowed. And we can't use '?' because
|
||||
// we may want to use the placeholders twice, once for an insert and once for an
|
||||
// update. Besides, debugging is easier with named parameters.
|
||||
$named_parameter = ":p$i";
|
||||
$values[] = $named_parameter;
|
||||
if (isset($data[$column_name]))
|
||||
{
|
||||
$sql_param = $data[$column_name];
|
||||
if (is_object($col))
|
||||
{
|
||||
// NB MariaDB doesn't support the JSON data type. It treats it as an
|
||||
// alias of LONG TEXT.
|
||||
if ($col->getNature() === Column::NATURE_JSON)
|
||||
{
|
||||
if (!is_string($sql_param) || !json_validate($sql_param))
|
||||
{
|
||||
throw new Exception('Invalid JSON string');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$sql_param = $col->sanitizeValue($sql_param);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// The column is not nullable and $col is an object if we got here
|
||||
$sql_param = $col->getDefault();
|
||||
}
|
||||
$sql_params[$named_parameter] = $sql_param;
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'columns' => $columns,
|
||||
'values' => $values,
|
||||
'sql_params' => $sql_params
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\DB;
|
||||
|
||||
use PDOException;
|
||||
|
||||
class DBException extends PDOException
|
||||
{
|
||||
|
||||
public function __construct(string $message, int $code=0, ?PDOException $previous=null, ?string $sql=null, ?array $params=null)
|
||||
{
|
||||
if (isset($sql))
|
||||
{
|
||||
$message .= "\n" .
|
||||
'SQL: ' . str_replace("\n", '', $sql) . "\n" .
|
||||
'Params: ' . print_r($params, true);
|
||||
}
|
||||
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\DB;
|
||||
|
||||
class DBExternalException extends DBException
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\DB;
|
||||
|
||||
|
||||
// A helper class to build a DB object, dependent on the database type required
|
||||
use Throwable;
|
||||
|
||||
class DBFactory
|
||||
{
|
||||
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
|
||||
// The attribute is only recognised by PHP 8.2 and later.
|
||||
public static function create(
|
||||
string $db_system,
|
||||
string $db_host,
|
||||
#[\SensitiveParameter]
|
||||
string $db_username,
|
||||
#[\SensitiveParameter]
|
||||
string $db_password,
|
||||
#[\SensitiveParameter]
|
||||
string $db_name,
|
||||
bool $persist=false,
|
||||
?int $db_port=null,
|
||||
array $db_options=[]) : DB
|
||||
{
|
||||
self::checkExtensionEnabled($db_system);
|
||||
$class_name = self::getClassName($db_system);
|
||||
return new $class_name($db_host, $db_username, $db_password, $db_name, $persist, $db_port, $db_options);
|
||||
}
|
||||
|
||||
|
||||
public static function createDsn(
|
||||
string $db_system,
|
||||
string $db_host,
|
||||
#[\SensitiveParameter]
|
||||
string $db_name,
|
||||
?int $db_port = null
|
||||
) : string
|
||||
{
|
||||
$class_name = self::getClassName($db_system);
|
||||
return $class_name::dsn($db_host, $db_name, $db_port);
|
||||
}
|
||||
|
||||
|
||||
// Check that the appropriate PDO extension is enabled. This can't always be
|
||||
// done in the constructor of the class itself because the class can refer to a
|
||||
// driver-specific constant.
|
||||
private static function checkExtensionEnabled(string $db_system) : void
|
||||
{
|
||||
// Check for the existence of a driver-specific constant
|
||||
switch ($db_system)
|
||||
{
|
||||
case 'mysql':
|
||||
case 'mysqli':
|
||||
$constant_name = 'Pdo\Mysql::ATTR_FOUND_ROWS';
|
||||
$extension = 'pdo_mysql';
|
||||
break;
|
||||
|
||||
case 'pgsql':
|
||||
$constant_name = 'Pdo\Pgsql::ATTR_DISABLE_PREPARES';
|
||||
$extension = 'pdo_pgsql';
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// We have to test for the constant in a try/catch block, because if we are using the Pdo\Mysql or
|
||||
// Pdo\Pgsql emulations (ie we are not running PHP 8.4 or later) then the emulations will throw
|
||||
// an error.
|
||||
try
|
||||
{
|
||||
if (!defined($constant_name))
|
||||
{
|
||||
throw new DBException("Undefined constant $constant_name.");
|
||||
}
|
||||
}
|
||||
catch (Throwable $e)
|
||||
{
|
||||
$message = "Undefined constant $constant_name. Check that the $extension extension is enabled " .
|
||||
"in your php.ini file.";
|
||||
throw new DBException($message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static function getClassName(string $db_system) : string
|
||||
{
|
||||
switch ($db_system)
|
||||
{
|
||||
case 'mysql':
|
||||
case 'mysqli':
|
||||
$class_name = 'DB_mysql';
|
||||
break;
|
||||
|
||||
case 'pgsql':
|
||||
$class_name = 'DB_pgsql';
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new DBException("Unsupported database driver '$db_system'");
|
||||
break;
|
||||
}
|
||||
|
||||
return __NAMESPACE__ . '\\' . $class_name;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\DB;
|
||||
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
|
||||
|
||||
class DBStatement
|
||||
{
|
||||
protected $db_object = null;
|
||||
protected $statement = null;
|
||||
|
||||
|
||||
public function __construct(DB $db_obj, PDOStatement $sth)
|
||||
{
|
||||
$this->db_object = $db_obj;
|
||||
$this->statement = $sth;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch the next row from a result set
|
||||
*
|
||||
* @return mixed[]|false An array indexed by column number as returned in the result set, starting at column 0,
|
||||
* or FALSE if there are no more rows.
|
||||
*/
|
||||
public function next_row()
|
||||
{
|
||||
return $this->statement->fetch(PDO::FETCH_NUM);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the next row from a statement as an associative array.
|
||||
*
|
||||
* @return array<string,mixed>|false The next row indexed by column name, or FALSE if there are no more rows.
|
||||
*/
|
||||
public function next_row_keyed()
|
||||
{
|
||||
return $this->statement->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return all the rows from a statement object, as an array of arrays keyed on the column name.
|
||||
*/
|
||||
public function all_rows_keyed() : array
|
||||
{
|
||||
$result = array();
|
||||
|
||||
while (false !== ($row = $this->next_row_keyed()))
|
||||
{
|
||||
$result[] = $row;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of rows affected by the last SQL statement.
|
||||
*
|
||||
* For DELETE, INSERT, or UPDATE statements the number of rows affected is returned, though note that this depends
|
||||
* on the setting of Pdo\Mysql::ATTR_FOUND_ROWS for MySQL.
|
||||
*
|
||||
* For statements that produce result sets, such as SELECT, the behaviour is undefined and can be different for each driver.
|
||||
*/
|
||||
public function count() : int
|
||||
{
|
||||
return $this->statement->rowCount();
|
||||
}
|
||||
|
||||
// Returns the number of fields in a statement.
|
||||
public function num_fields() : int
|
||||
{
|
||||
return $this->statement->columnCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\DB;
|
||||
|
||||
use Error;
|
||||
use MRBS\Errors\Errors;
|
||||
use PDO;
|
||||
use Pdo\Mysql;
|
||||
use PDOException;
|
||||
use function MRBS\get_vocab;
|
||||
|
||||
|
||||
class DB_mysql extends DB
|
||||
{
|
||||
const DB_DEFAULT_PORT = 3306;
|
||||
const DB_DBO_DRIVER = "mysql";
|
||||
const DB_CHARSET = "utf8mb4";
|
||||
|
||||
const DB_MARIADB = 0;
|
||||
const DB_MYSQL = 1;
|
||||
const DB_PERCONA = 2;
|
||||
const DB_OTHER = 3;
|
||||
|
||||
// For a full list of error codes see https://mariadb.com/kb/en/mariadb-error-codes/
|
||||
// (That page doesn't list codes only used by MySQL)
|
||||
const ER_CON_COUNT_ERROR = 1040; // Too many connections
|
||||
const ER_TOO_MANY_USER_CONNECTIONS = 1203; // User %s already has more than 'max_user_connections' active connections
|
||||
const ER_USER_LIMIT_REACHED = 1226; // User '%s' has exceeded the '%s' resource (current value: %ld)
|
||||
|
||||
private const OPTIONS = [
|
||||
Mysql::ATTR_FOUND_ROWS => true // Return the number of found (matched) rows, not the number of changed rows.
|
||||
];
|
||||
|
||||
private const MIN_VERSIONS = array(
|
||||
self::DB_MARIADB => '5.5.3', // '10.0.2' recommended for multiple lock support
|
||||
self::DB_MYSQL => '5.5.3', // '5.7.5' recommended for multiple lock support
|
||||
self::DB_PERCONA => '5.5.3' // '5.7.5' recommended for multiple lock support
|
||||
);
|
||||
|
||||
private const DB_NAMES = array(
|
||||
self::DB_MARIADB => 'MariaDB',
|
||||
self::DB_MYSQL => 'MySQL',
|
||||
self::DB_PERCONA => 'Percona'
|
||||
);
|
||||
|
||||
private $db_type = null;
|
||||
private $returns_native_types = null;
|
||||
private $supports_multiple_locks = null;
|
||||
private $version_comment = null;
|
||||
|
||||
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
|
||||
// The attribute is only recognised by PHP 8.2 and later.
|
||||
public function __construct(
|
||||
string $db_host,
|
||||
#[\SensitiveParameter]
|
||||
string $db_username,
|
||||
#[\SensitiveParameter]
|
||||
string $db_password,
|
||||
#[\SensitiveParameter]
|
||||
string $db_name,
|
||||
bool $persist=false,
|
||||
?int $db_port=null,
|
||||
array $db_options=[])
|
||||
{
|
||||
global $db_retries, $db_delay;
|
||||
|
||||
$driver_options = self::siteOptions() + self::OPTIONS;
|
||||
|
||||
// We allow retries if the connection fails due to a resource constraint, possibly because
|
||||
// this database user already has max_user_connections open (through other instances of users
|
||||
// accessing MRBS) or other database users on the same server have reached the maximum number of
|
||||
// connections for the database.
|
||||
$attempts_left = max(1, $db_retries + 1);
|
||||
|
||||
while ($attempts_left > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
$this->connect(
|
||||
$db_host,
|
||||
$db_username,
|
||||
$db_password,
|
||||
$db_name,
|
||||
$persist,
|
||||
$db_port,
|
||||
$driver_options
|
||||
);
|
||||
// Set $attempts_left to zero as we won't have got here if an exception has been thrown
|
||||
$attempts_left = 0;
|
||||
$this->checkVersion();
|
||||
// Turn off ONLY_FULL_GROUP_BY mode (which is the default in MySQL 5.7.5 and later) to prevent SQL
|
||||
// errors of the type "Syntax error or access violation: 1055 'mrbs.E.start_time' isn't in GROUP BY".
|
||||
// TODO: However the proper solution is probably to rewrite the offending queries.
|
||||
$this->command("SET sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))");
|
||||
// Set STRICT_TRANS_TABLES so that we can detect invalid values being inserted in the database
|
||||
$this->command("SET SESSION sql_mode = 'STRICT_TRANS_TABLES'");
|
||||
}
|
||||
catch (PDOException $e)
|
||||
{
|
||||
$code = $e->getCode();
|
||||
$message = $e->getMessage();
|
||||
|
||||
if (in_array($code, array(
|
||||
self::ER_CON_COUNT_ERROR,
|
||||
self::ER_TOO_MANY_USER_CONNECTIONS,
|
||||
self::ER_USER_LIMIT_REACHED
|
||||
)))
|
||||
{
|
||||
$attempts_left--;
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's some kind of error other than a resource error, so retrying won't help
|
||||
$attempts_left = 0;
|
||||
if ($code == 2054) // The server requested authentication method unknown to the client [MySQL specific]
|
||||
{
|
||||
$message .= ".\n[MRBS note] It looks like you may have an old style MySQL password stored, which cannot be " .
|
||||
"used with PDO (though it is possible that mysqli may have accepted it). Try " .
|
||||
"deleting the MySQL user and recreating it with the same password.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($attempts_left > 0)
|
||||
{
|
||||
trigger_error($message . ". Retrying ...", E_USER_NOTICE);
|
||||
usleep($db_delay * 1000);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new DBException($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Translates $db_options['mysql'] into an array of options indexed by their
|
||||
// PDO constants.
|
||||
// Note that we cannot declare a constant array to hold this mapping as not all
|
||||
// systems support all the PDO constants.
|
||||
private static function siteOptions() : array
|
||||
{
|
||||
global $db_options;
|
||||
|
||||
$result = array();
|
||||
|
||||
foreach ($db_options['mysql'] as $key => $value)
|
||||
{
|
||||
// Only try and set the option if we need to. Otherwise, we could trigger an
|
||||
// 'undefined class constant' error unnecessarily.
|
||||
if (isset($value))
|
||||
{
|
||||
try
|
||||
{
|
||||
switch ($key)
|
||||
{
|
||||
case 'ssl_ca':
|
||||
$index = Mysql::ATTR_SSL_CA;
|
||||
break;
|
||||
case 'ssl_capath':
|
||||
$index = Mysql::ATTR_SSL_CAPATH;
|
||||
break;
|
||||
case 'ssl_cert':
|
||||
$index = Mysql::ATTR_SSL_CERT;
|
||||
break;
|
||||
case 'ssl_cipher':
|
||||
$index = Mysql::ATTR_SSL_CIPHER;
|
||||
break;
|
||||
case 'ssl_key':
|
||||
$index = Mysql::ATTR_SSL_KEY;
|
||||
break;
|
||||
case 'ssl_verify_server_cert':
|
||||
$index = Mysql::ATTR_SSL_VERIFY_SERVER_CERT;
|
||||
break;
|
||||
default:
|
||||
$index = null;
|
||||
trigger_error("Unsupported option '$key'");
|
||||
break;
|
||||
}
|
||||
if (isset($index))
|
||||
{
|
||||
$result[$index] = $value;
|
||||
}
|
||||
}
|
||||
catch (Error $e)
|
||||
{
|
||||
$message = $e->getMessage() . ". Try using the 'nd_pdo_mysql' extension instead of 'pdo_mysql'.";
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
Errors::fatalError(get_vocab("fatal_error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
// Quote a table or column name (which could be a qualified identifier, eg 'table.column')
|
||||
public function quote(string $identifier) : string
|
||||
{
|
||||
$quote_char = '`';
|
||||
$parts = explode('.', $identifier);
|
||||
return $quote_char . implode($quote_char . '.' . $quote_char, $parts) . $quote_char;
|
||||
}
|
||||
|
||||
|
||||
// Return the value of an autoincrement field from the last insert.
|
||||
// Must be called right after an insert on that table!
|
||||
//
|
||||
// For MySQL we don't need to refer to the passed $table or $field
|
||||
public function insert_id(string $table, string $field): int
|
||||
{
|
||||
return (int)$this->dbh->lastInsertId();
|
||||
}
|
||||
|
||||
|
||||
// Checks the attribute PDO::ATTR_STRINGIFY_FETCHES
|
||||
private function getStringifyFetches() : bool
|
||||
{
|
||||
// Not all drivers support PDO::ATTR_STRINGIFY_FETCHES
|
||||
try {
|
||||
return $this->getAttribute(PDO::ATTR_STRINGIFY_FETCHES);
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function returnsNativeTypes() : bool
|
||||
{
|
||||
if (!isset($this->returns_native_types))
|
||||
{
|
||||
// MySQL will return native types if PDO::ATTR_STRINGIFY_FETCHES is false
|
||||
// and we're using a native driver and (the PHP version is at least 8.1 or
|
||||
// PDO::ATTR_EMULATE_PREPARES is false).
|
||||
// See https://stackoverflow.com/questions/1197005/how-to-get-numeric-types-from-mysql-using-pdo
|
||||
// and https://stackoverflow.com/questions/20079320/how-do-i-return-integer-and-numeric-columns-from-mysql-as-integers-and-numerics
|
||||
$this->returns_native_types =
|
||||
!$this->getStringifyFetches()&&
|
||||
str_contains($this->getAttribute(PDO::ATTR_CLIENT_VERSION), 'mysqlnd') &&
|
||||
((version_compare(PHP_VERSION, '8.1.0') >= 0) || !$this->getAttribute(PDO::ATTR_EMULATE_PREPARES));
|
||||
}
|
||||
|
||||
return $this->returns_native_types;
|
||||
}
|
||||
|
||||
|
||||
public function supportsMultipleLocks() : bool
|
||||
{
|
||||
// TODO: avoid the use of RELEASE_ALL_LOCKS for MariaDB Galera Cluster (and possibly other cluster implementations?).
|
||||
if (!isset($this->supports_multiple_locks))
|
||||
{
|
||||
if (!empty($this->mutex_locks))
|
||||
{
|
||||
throw new Exception(__METHOD__ . " called when there are locks in place.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// We could check version numbers, but then we have to test for different
|
||||
// version numbers in MySQL and MariaDB, and possibly others. It's
|
||||
// probably cleaner to check for the capability to RELEASE_ALL_LOCKS(), which
|
||||
// was introduced at the same time as support for multiple locks.
|
||||
$this->query("SELECT RELEASE_ALL_LOCKS()");
|
||||
$this->supports_multiple_locks = true;
|
||||
}
|
||||
catch (DBException $e)
|
||||
{
|
||||
$this->supports_multiple_locks = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->supports_multiple_locks;
|
||||
}
|
||||
|
||||
|
||||
private static function hash(string $name) : string
|
||||
{
|
||||
// Since MySQL 5.7.5 lock names have been restricted to 64 characters.
|
||||
// Truncating them is probably sufficient to ensure uniqueness.
|
||||
return substr($name, 0, 64);
|
||||
}
|
||||
|
||||
|
||||
public function mutex_lock(string $name) : bool
|
||||
{
|
||||
// TODO: avoid the use of GET_LOCK as it is not supported by MariaDB Galera Cluster (or else get rid of the need
|
||||
// TODO: for this method).
|
||||
$timeout = 20; // seconds
|
||||
|
||||
if (!$this->supportsMultipleLocks() && !empty($this->mutex_locks))
|
||||
{
|
||||
$message = "Trying to set lock '$name', but lock '" . $this->mutex_locks[0] .
|
||||
"' already exists. Only one lock is allowed at any one time.";
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
// GET_LOCK returns 1 if the lock was obtained successfully, 0 if the attempt
|
||||
// timed out (for example, because another client has previously locked the name),
|
||||
// or NULL if an error occurred (such as running out of memory or the thread was
|
||||
// killed with mysqladmin kill)
|
||||
try
|
||||
{
|
||||
$sql_params = array(':str' => self::hash($name),
|
||||
':timeout' => $timeout);
|
||||
$stmt = $this->query("SELECT GET_LOCK(:str, :timeout)", $sql_params);
|
||||
}
|
||||
catch (DBException $e)
|
||||
{
|
||||
trigger_error($e->getMessage(), E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($stmt->count() != 1) ||
|
||||
($stmt->num_fields() != 1))
|
||||
{
|
||||
trigger_error("Unexpected number of rows and columns in result", E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $stmt->next_row()[0];
|
||||
|
||||
if ($result == '1')
|
||||
{
|
||||
$this->mutex_locks[] = $name;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise there's been some kind of failure to get a lock
|
||||
switch ($result)
|
||||
{
|
||||
case '0':
|
||||
$message = "GET_LOCK timed out after $timeout seconds";
|
||||
break;
|
||||
case null:
|
||||
$message = "GET_LOCK: an error occurred (such as running out of memory " .
|
||||
"or the thread was killed with mysqladmin kill)";
|
||||
break;
|
||||
default:
|
||||
$message = "GET_LOCK: unexpected result '$result'";
|
||||
break;
|
||||
}
|
||||
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public function mutex_unlock(string $name) : bool
|
||||
{
|
||||
// TODO: avoid the use of RELEASE_LOCK as it is not supported by MariaDB Galera Cluster (or else get rid of the need
|
||||
// TODO: for this method).
|
||||
// First do some sanity checking before executing the SQL query
|
||||
if (!in_array($name, $this->mutex_locks))
|
||||
{
|
||||
trigger_error("Trying to release a lock ('$name') which hasn't been set", E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this request looks OK, then execute the SQL query
|
||||
try
|
||||
{
|
||||
$stmt = $this->query("SELECT RELEASE_LOCK(?)", array(self::hash($name)));
|
||||
}
|
||||
catch (DBException $e)
|
||||
{
|
||||
trigger_error($e->getMessage(), E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($stmt->count() != 1) ||
|
||||
($stmt->num_fields() != 1))
|
||||
{
|
||||
trigger_error("Unexpected number of rows and columns in result", E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $stmt->next_row()[0];
|
||||
|
||||
if ($result == '1')
|
||||
{
|
||||
if (($key = array_search($name, $this->mutex_locks)) !== false)
|
||||
{
|
||||
unset($this->mutex_locks[$key]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise there's been some kind of failure to release a lock. These should in theory
|
||||
// have been caught by the sanity checking above, but just in case ...
|
||||
switch ($result)
|
||||
{
|
||||
case '0':
|
||||
$message = "RELEASE_LOCK: the lock '$name' was not established by this thread and so could not be released";
|
||||
break;
|
||||
case null:
|
||||
$message = "RELEASE_LOCK: the lock '$name' does not exist";
|
||||
break;
|
||||
default:
|
||||
$message = "RELEASE_LOCK: unexpected result '$result'";
|
||||
break;
|
||||
}
|
||||
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public function mutex_unlock_all() : void
|
||||
{
|
||||
// TODO: avoid the use of RELEASE_ALL_LOCKS as it is not supported by MariaDB Galera Cluster (or else get rid of the need
|
||||
// TODO: for this method).
|
||||
if ($this->supportsMultipleLocks())
|
||||
{
|
||||
$this->query("SELECT RELEASE_ALL_LOCKS()");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach ($this->mutex_locks as $lock)
|
||||
{
|
||||
$this->mutex_unlock($lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function dbType() : ?int
|
||||
{
|
||||
global $debug;
|
||||
|
||||
if (!isset($this->db_type))
|
||||
{
|
||||
if ((false !== mb_stripos($this->versionComment(), 'maria')) || (false !== mb_stripos($this->version(), 'maria')))
|
||||
{
|
||||
$this->db_type = self::DB_MARIADB;
|
||||
}
|
||||
elseif ((false !== mb_stripos($this->versionComment(), 'mysql')) || (false !== mb_stripos($this->version(), 'mysql')))
|
||||
{
|
||||
$this->db_type = self::DB_MYSQL;
|
||||
}
|
||||
// Most Ubuntu packages will identify the database type - see https://github.com/meeting-room-booking-system/mrbs-code/issues/72.
|
||||
// But there are some packages that don't seem to include the database type in any of the version information, for example
|
||||
// see SF Bugs #545 (https://sourceforge.net/p/mrbs/bugs/545/). Let's assume that they are MySQL databases, though this isn't
|
||||
// necessarily true as it seems Ubuntu can be packaged with either MySQL or MariaDB - see for example https://launchpad.net/ubuntu.
|
||||
// However, if we assume MySQL then the required MySQL version number will be less than or equal to the required MariaDB version
|
||||
// number and the initial version check will pass, though the code may fail later on when it tries to use an unsupported feature.
|
||||
// TODO: something better. Perhaps we could also look at version numbers and then make some assumptions about whether the database
|
||||
// TODO: is MySQL or MariaDB, but that could become dangerous in the future. Or perhaps there's some other way.
|
||||
elseif ((false !== mb_stripos($this->versionComment(), 'ubuntu')) || (false !== mb_stripos($this->version(), 'ubuntu')))
|
||||
{
|
||||
$this->db_type = self::DB_MYSQL;
|
||||
}
|
||||
elseif ((false !== mb_stripos($this->versionComment(), 'percona')) || (false !== mb_stripos($this->version(), 'percona')))
|
||||
{
|
||||
$this->db_type = self::DB_PERCONA;
|
||||
}
|
||||
// The Altervista.org hosting platform will give this version comment
|
||||
elseif ($this->versionComment() == 'Source distribution')
|
||||
{
|
||||
$this->db_type = self::DB_MYSQL;
|
||||
}
|
||||
else
|
||||
{
|
||||
if ($debug)
|
||||
{
|
||||
trigger_error("Unknown database type '" . $this->versionComment() . "'");
|
||||
}
|
||||
$this->db_type = self::DB_OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->db_type;
|
||||
}
|
||||
|
||||
|
||||
// Checks that the database version meets the minimum requirement and dies if not
|
||||
private function checkVersion() : void
|
||||
{
|
||||
$db_version = $this->versionNumber();
|
||||
$db_type = $this->dbType();
|
||||
|
||||
if (isset(self::MIN_VERSIONS[$db_type]) &&
|
||||
(version_compare($db_version, self::MIN_VERSIONS[$db_type]) < 0))
|
||||
{
|
||||
$this->versionDie(self::DB_NAMES[$db_type], $db_version, self::MIN_VERSIONS[$db_type]);
|
||||
}
|
||||
// If it's another type of database we'll have to add some minimum version requirements fot it
|
||||
}
|
||||
|
||||
|
||||
// Returns the version_comment variable, eg "MySQL Community Server - GPL"
|
||||
// or "MariaDB Server".
|
||||
private function versionComment() : string
|
||||
{
|
||||
if (!isset($this->version_comment))
|
||||
{
|
||||
$sql = "SHOW variables LIKE 'version_comment'";
|
||||
$res = $this->query($sql);
|
||||
$row = $res->next_row_keyed();
|
||||
|
||||
$this->version_comment = ($row === false) ? '' : $row['Value'];
|
||||
}
|
||||
|
||||
return $this->version_comment;
|
||||
}
|
||||
|
||||
|
||||
// Returns the database version number as a string
|
||||
private function versionNumber() : string
|
||||
{
|
||||
$result = $this->versionString();
|
||||
|
||||
// Extract the version number
|
||||
preg_match('/^\d+(\.\d+)+/', $result, $matches);
|
||||
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
|
||||
public function version() : string
|
||||
{
|
||||
return $this->versionComment() . ' DB_mysql.php' . $this->versionString();
|
||||
}
|
||||
|
||||
|
||||
public function table_exists(string $table) : bool
|
||||
{
|
||||
$res = $this->query("SHOW TABLES LIKE ?", array($table));
|
||||
|
||||
return ($res->count() > 0);
|
||||
}
|
||||
|
||||
|
||||
public function field_info(string $table) : array
|
||||
{
|
||||
// Map MySQL types on to a set of generic types
|
||||
$nature_map = array(
|
||||
'bigint' => 'integer',
|
||||
'blob' => 'binary',
|
||||
'char' => 'character',
|
||||
'date' => 'timestamp',
|
||||
'datetime' => 'timestamp',
|
||||
'decimal' => 'decimal',
|
||||
'double' => 'real',
|
||||
'float' => 'real',
|
||||
'int' => 'integer',
|
||||
'longblob' => 'binary',
|
||||
'longtext' => 'character',
|
||||
'mediumblob' => 'binary',
|
||||
'mediumint' => 'integer',
|
||||
'mediumtext' => 'character',
|
||||
'numeric' => 'decimal',
|
||||
'smallint' => 'integer',
|
||||
'text' => 'character',
|
||||
'time' => 'timestamp',
|
||||
'timestamp' => 'timestamp',
|
||||
'tinyblob' => 'binary',
|
||||
'tinyint' => 'integer',
|
||||
'tinytext' => 'character',
|
||||
'varchar' => 'character',
|
||||
'year' => 'timestamp'
|
||||
);
|
||||
|
||||
// Length in bytes of MySQL integer types
|
||||
$int_bytes = array(
|
||||
'bigint' => 8, // bytes
|
||||
'int' => 4,
|
||||
'mediumint' => 3,
|
||||
'smallint' => 2,
|
||||
'tinyint' => 1
|
||||
);
|
||||
|
||||
$stmt = $this->query("SHOW COLUMNS FROM $table", array());
|
||||
|
||||
$fields = array();
|
||||
|
||||
while (false !== ($row = $stmt->next_row_keyed()))
|
||||
{
|
||||
$name = $row['Field'];
|
||||
$type = $row['Type'];
|
||||
$default = $row['Default'];
|
||||
// Get the type and optionally length in parentheses, ignoring any attributes. Note that the
|
||||
// length could be of the form (6,2) for a decimal. Examples that we have to cope with:
|
||||
// tinyint
|
||||
// tinyint unsigned
|
||||
// decimal(6,2)
|
||||
// varchar(255)
|
||||
// mediumint(4) unsigned zerofill
|
||||
// The type will be in the first group and the length in the optional second group
|
||||
preg_match('/(\w+)[\s(]?([\d,]+)?/', $type, $matches);
|
||||
$short_type = $matches[1];
|
||||
// map the type onto one of the generic natures, if a mapping exists
|
||||
$nature = (array_key_exists($short_type, $nature_map)) ? $nature_map[$short_type] : $short_type;
|
||||
// now work out the length
|
||||
if ($nature == 'integer')
|
||||
{
|
||||
// Convert the default to an int (unless it's NULL)
|
||||
if (isset($default))
|
||||
{
|
||||
$default = (int) $default;
|
||||
}
|
||||
// if it's one of the ints, then look up the length in bytes
|
||||
$length = (array_key_exists($short_type, $int_bytes)) ? $int_bytes[$short_type] : 0;
|
||||
}
|
||||
elseif (($nature == 'character') || ($nature == 'decimal'))
|
||||
{
|
||||
// if it's a character or decimal type then use the length that was in parentheses
|
||||
// eg if it was a varchar(25), we want the 25 and if a decimal(6,2) we want the 6,2
|
||||
if (isset($matches[2]))
|
||||
{
|
||||
$length = $matches[2];
|
||||
}
|
||||
// otherwise it could be any length (eg if it was a 'text')
|
||||
else
|
||||
{
|
||||
$length = defined('PHP_INT_MAX') ? PHP_INT_MAX : 9999;
|
||||
}
|
||||
}
|
||||
else // we're only dealing with a few simple cases at the moment
|
||||
{
|
||||
$length = null;
|
||||
}
|
||||
// Convert the is_nullable field to a boolean
|
||||
$is_nullable = (mb_strtolower($row['Null']) == 'yes');
|
||||
|
||||
$fields[] = array(
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'nature' => $nature,
|
||||
'length' => $length,
|
||||
'is_nullable' => $is_nullable,
|
||||
'default' => $default
|
||||
);
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
// Syntax methods
|
||||
|
||||
public function syntax_limit(int $count, int $offset) : string
|
||||
{
|
||||
return "LIMIT $offset,$count";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_timestamp_to_unix(string $fieldname) : string
|
||||
{
|
||||
return "UNIX_TIMESTAMP($fieldname)";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_casesensitive_equals(string $fieldname, string $string, array &$params) : string
|
||||
{
|
||||
$params[] = $string;
|
||||
|
||||
// The '=' comparison in MySQL allows trailing spaces, eg 'john' = 'john ', so we cannot just use that.
|
||||
// Also, by default MySQL is case-insensitive, so we force a binary comparison.
|
||||
|
||||
// We cannot assume that the database column has utf8 collation. We may, for example, be
|
||||
// authenticating a user against an external database. See the post at
|
||||
// https://stackoverflow.com/questions/5629111/how-can-i-make-sql-case-sensitive-string-comparison-on-mysql#answer-56283818
|
||||
// for an explanation of the query.
|
||||
return $this->quote($fieldname) . "=CONVERT(? using utf8mb4) COLLATE utf8mb4_bin";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_caseless_contains(string $fieldname, string $string, array &$params) : string
|
||||
{
|
||||
// In MySQL, REGEXP seems to be case-sensitive, so use LIKE instead. But this
|
||||
// requires quoting of % and _ in addition to the usual.
|
||||
$string = str_replace("\\", "\\\\", $string);
|
||||
$string = str_replace("%", "\\%", $string);
|
||||
$string = str_replace("_", "\\_", $string);
|
||||
|
||||
$params[] = "%$string%";
|
||||
|
||||
return "$fieldname LIKE ?";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_addcolumn_after(string $fieldname) : string
|
||||
{
|
||||
return "AFTER $fieldname";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_createtable_autoincrementcolumn() : string
|
||||
{
|
||||
return "int NOT NULL auto_increment";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_bitwise_xor() : string
|
||||
{
|
||||
return "^";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_simple_split(string $fieldname, string $delimiter, int $part, array &$params) : string
|
||||
{
|
||||
switch ($part)
|
||||
{
|
||||
case 1:
|
||||
$count = 1;
|
||||
break;
|
||||
case 2:
|
||||
$count = -1;
|
||||
break;
|
||||
default:
|
||||
throw new Exception("Invalid value ($part) given for " . '$part.');
|
||||
break;
|
||||
}
|
||||
|
||||
$params[] = $delimiter;
|
||||
return "SUBSTRING_INDEX($fieldname, ?, $count)";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_group_array_as_string(string $fieldname, string $delimiter=',') : string
|
||||
{
|
||||
// Use DISTINCT to eliminate duplicates which can arise when the query
|
||||
// has joins on two or more junction tables. Maybe a different query
|
||||
// would eliminate the duplicates and the need for DISTINCT, and it may
|
||||
// or may not be more efficient.
|
||||
return "GROUP_CONCAT(DISTINCT $fieldname SEPARATOR '$delimiter')";
|
||||
}
|
||||
|
||||
|
||||
// Returns the syntax for an "upsert" query. Unfortunately getting the id of the
|
||||
// last row differs between MySQL and PostgreSQL. In PostgreSQL the query will
|
||||
// return a row with the id in the 'id' column. However there isn't a corresponding
|
||||
// way of doing this in MySQL, but db()->insert_id() will work, regardless of whether
|
||||
// an insert or update was performed.
|
||||
//
|
||||
// $conflict_keys the key(s) which is/are unique; can be a scalar or an array
|
||||
// (ignored in MySQL)
|
||||
// $assignments an array of assignments for the UPDATE clause
|
||||
// $has_id_column whether the table has an id column
|
||||
public function syntax_on_duplicate_key_update($conflict_keys, array $assignments, bool $has_id_column=false) : string
|
||||
{
|
||||
if ($has_id_column)
|
||||
{
|
||||
// In order to make lastInsertId() work even after an UPDATE
|
||||
$assignments[] = "id=LAST_INSERT_ID(id)";
|
||||
}
|
||||
|
||||
return "ON DUPLICATE KEY UPDATE " . implode(', ', $assignments);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\DB;
|
||||
|
||||
use PDOException;
|
||||
|
||||
|
||||
class DB_pgsql extends DB
|
||||
{
|
||||
const DB_DEFAULT_PORT = 5432;
|
||||
const DB_DBO_DRIVER = "pgsql";
|
||||
|
||||
private const MIN_VERSION = '8.2';
|
||||
|
||||
private const OPTIONS = array();
|
||||
private const OPTIONS_MAP = array();
|
||||
|
||||
|
||||
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
|
||||
// The attribute is only recognised by PHP 8.2 and later.
|
||||
public function __construct(
|
||||
string $db_host,
|
||||
#[\SensitiveParameter]
|
||||
string $db_username,
|
||||
#[\SensitiveParameter]
|
||||
string $db_password,
|
||||
#[\SensitiveParameter]
|
||||
string $db_name,
|
||||
bool $persist=false,
|
||||
?int $db_port=null,
|
||||
array $db_options=[])
|
||||
{
|
||||
$driver_options = self::OPTIONS;
|
||||
|
||||
// If user-defined driver options exist add them in to the standard driver options, having
|
||||
// first replaced the keys with their PDO values.
|
||||
if (!empty($db_options['pgsql']))
|
||||
{
|
||||
$driver_options = self::replaceOptionKeys($db_options['pgsql'], self::OPTIONS_MAP) + $driver_options;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
$this->connect(
|
||||
$db_host,
|
||||
$db_username,
|
||||
$db_password,
|
||||
$db_name,
|
||||
$persist,
|
||||
$db_port,
|
||||
$driver_options
|
||||
);
|
||||
$this->checkVersion();
|
||||
}
|
||||
catch (PDOException $e)
|
||||
{
|
||||
$message = $e->getMessage();
|
||||
// This can be a problem when migrating to the PDO version of MRBS from an earlier version.
|
||||
if (($e->getCode() == 7) && ($db_host === ''))
|
||||
{
|
||||
$message .= ".\n[MRBS note] Try setting " . '$db_host' . " to '127.0.0.1'.";
|
||||
}
|
||||
throw new DBException($message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// A small utility function (not part of the DB abstraction API) to
|
||||
// resolve a qualified table name into its schema and table components.
|
||||
// Returns an array indexed by 'table_schema' and 'table_name'.
|
||||
// 'table_schema' can be NULL
|
||||
private static function resolve_table(string $table) : array
|
||||
{
|
||||
if (mb_strpos($table, '.') === false)
|
||||
{
|
||||
$table_schema = null;
|
||||
$table_name = $table;
|
||||
}
|
||||
else
|
||||
{
|
||||
list($table_schema, $table_name) = explode('.', $table, 2);
|
||||
}
|
||||
|
||||
return array('table_schema' => $table_schema,
|
||||
'table_name' => $table_name);
|
||||
}
|
||||
|
||||
|
||||
// Quote a table or column name (which could be a qualified identifier, eg 'table.column')
|
||||
|
||||
// NOTE: We fold the identifier to lower case here even though it is quoted. Unlike MySQL,
|
||||
// PostgreSQL folds identifiers to lower case, unless they are quoted. However in MRBS we
|
||||
// normally want to quote an identifier in case it has characters such as spaces in it, as
|
||||
// could be the case with user generated column names for custom fields. But if we were also
|
||||
// to quote the table name, then queries such as 'SELECT * FROM mrbs_entry E WHERE "E"."id"=2'
|
||||
// would fail because the alias 'E' is folded to 'e', but the WHERE clause gives 'E.id'.
|
||||
// This means that we won't be able to distinguish in PostgreSQL between column names that just
|
||||
// differ in case. But having column names differing in case would be confusing anyway and so
|
||||
// should be discouraged. And a PostgreSQL user generating custom fields would expect them to
|
||||
// be folded to lower case anyway, so presumably wouldn't try and create column names differing
|
||||
// only in case.
|
||||
public function quote(string $identifier) : string
|
||||
{
|
||||
$quote_char = '"';
|
||||
$parts = explode('.', strtolower($identifier));
|
||||
return $quote_char . implode($quote_char . '.' . $quote_char, $parts) . $quote_char;
|
||||
}
|
||||
|
||||
|
||||
// Return the value of an autoincrement field from the last insert.
|
||||
// For PostgreSQL, this must be a SERIAL type field.
|
||||
public function insert_id(string $table, string $field): int
|
||||
{
|
||||
$seq_name = $table . "_" . $field . "_seq";
|
||||
return (int)$this->dbh->lastInsertId($seq_name);
|
||||
}
|
||||
|
||||
|
||||
// Hash a string into an int.
|
||||
// In PostgreSQL advisory lock keys are BIGINTs.
|
||||
private static function hash(string $name) : int
|
||||
{
|
||||
return crc32($name);
|
||||
}
|
||||
|
||||
|
||||
public function returnsNativeTypes() : bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function supportsMultipleLocks(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function mutex_lock(string $name) : bool
|
||||
{
|
||||
// pg_advisory_lock() will block indefinitely by default until a lock
|
||||
// is obtained or a deadlock detected.
|
||||
// TODO: should we set a lock timeout?
|
||||
try
|
||||
{
|
||||
$this->query("SELECT pg_advisory_lock(" . self::hash($name) . ")");
|
||||
}
|
||||
catch (DBException $e)
|
||||
{
|
||||
trigger_error($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->mutex_locks[] = $name;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function mutex_unlock(string $name) : bool
|
||||
{
|
||||
$sql = "SELECT pg_advisory_unlock(" . self::hash($name) . ")";
|
||||
$res = $this->query($sql);
|
||||
$row = $res->next_row();
|
||||
|
||||
if ($row === false)
|
||||
{
|
||||
throw new DBException("Unexpected pg_advisory_unlock() error");
|
||||
}
|
||||
|
||||
$result = $row[0];
|
||||
|
||||
if ($result)
|
||||
{
|
||||
if (($key = array_search($name, $this->mutex_locks)) !== false)
|
||||
{
|
||||
unset($this->mutex_locks[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
public function mutex_unlock_all() : void
|
||||
{
|
||||
$this->query("SELECT pg_advisory_unlock_all()");
|
||||
}
|
||||
|
||||
|
||||
// Checks that the database version meets the minimum requirement and dies if not
|
||||
private function checkVersion() : void
|
||||
{
|
||||
$this_version = $this->versionNumber();
|
||||
if (version_compare($this_version, self::MIN_VERSION) < 0)
|
||||
{
|
||||
$this->versionDie('PostgreSQL', $this_version, self::MIN_VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function version() : string
|
||||
{
|
||||
return $this->versionString();
|
||||
}
|
||||
|
||||
|
||||
// Just returns a version number, eg "9.2.24"
|
||||
private function versionNumber() : string
|
||||
{
|
||||
$result = $this->query_scalar_non_bool("SHOW SERVER_VERSION");
|
||||
|
||||
if ($result === false)
|
||||
{
|
||||
throw new Exception("Could not get PostgreSQL server version");
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
public function table_exists(string $table) : bool
|
||||
{
|
||||
// $table can be a qualified name. We need to resolve it if necessary into its component
|
||||
// parts, the schema and table names
|
||||
$table_parts = self::resolve_table($table);
|
||||
|
||||
$sql_params = array();
|
||||
$sql = "SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = ?";
|
||||
$sql_params[] = $table_parts['table_name'];
|
||||
if (isset($table_parts['table_schema']))
|
||||
{
|
||||
$sql .= " AND table_schema = ?";
|
||||
$sql_params[] = $table_parts['table_schema'];
|
||||
}
|
||||
|
||||
$res = $this->query1($sql, $sql_params);
|
||||
|
||||
if ($res == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
elseif ($res == 1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
elseif (($res > 1) && !isset($table_parts['table_schema']))
|
||||
{
|
||||
$message = "More than one table called '$table'. You need to set " . '$db_schema in the config file.';
|
||||
throw new DBException($message);
|
||||
}
|
||||
else
|
||||
{
|
||||
$message = "Unexpected result from SELECT COUNT(*) query.";
|
||||
throw new DBException($message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function field_info(string $table) : array
|
||||
{
|
||||
$fields = array();
|
||||
|
||||
// Map PostgreSQL types on to a set of generic types
|
||||
$nature_map = array(
|
||||
'bigint' => 'integer',
|
||||
'boolean' => 'boolean',
|
||||
'bytea' => 'binary',
|
||||
'character' => 'character',
|
||||
'character varying' => 'character',
|
||||
'date' => 'timestamp',
|
||||
'decimal' => 'decimal',
|
||||
'double precision' => 'real',
|
||||
'integer' => 'integer',
|
||||
'numeric' => 'decimal',
|
||||
'real' => 'real',
|
||||
'smallint' => 'integer',
|
||||
'text' => 'character',
|
||||
'time with time zone' => 'timestamp',
|
||||
'time without time zone' => 'timestamp',
|
||||
'timestamp with time zone' => 'timestamp'
|
||||
);
|
||||
|
||||
// $table can be a qualified name. We need to resolve it if necessary into its component
|
||||
// parts, the schema and table names
|
||||
$table_parts = self::resolve_table($table);
|
||||
|
||||
$sql_params = array();
|
||||
|
||||
// $table_name and $table_schema should be trusted but escape them anyway for good measure
|
||||
$sql = "SELECT column_name, column_default, data_type, numeric_precision, numeric_scale,
|
||||
character_maximum_length, character_octet_length, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = ?";
|
||||
$sql_params[] = $table_parts['table_name'];
|
||||
if (isset($table_parts['table_schema']))
|
||||
{
|
||||
$sql .= " AND table_schema = ?";
|
||||
$sql_params[] = $table_parts['table_schema'];
|
||||
}
|
||||
$sql .= " ORDER BY ordinal_position";
|
||||
|
||||
$stmt = $this->query($sql, $sql_params);
|
||||
|
||||
while (false !== ($row = $stmt->next_row_keyed()))
|
||||
{
|
||||
$name = $row['column_name'];
|
||||
$type = $row['data_type'];
|
||||
$parsed_default = $this->parseDefault($row['column_default']);
|
||||
$default = $parsed_default['value'];
|
||||
// map the type onto one of the generic natures, if a mapping exists
|
||||
$nature = (array_key_exists($type, $nature_map)) ? $nature_map[$type] : $type;
|
||||
// Convert the default to be of the correct type
|
||||
if (isset($default) && ($nature == 'integer'))
|
||||
{
|
||||
$default = (int) $default;
|
||||
}
|
||||
|
||||
// Get a length value; one of these values should be set
|
||||
if (isset($row['numeric_precision']))
|
||||
{
|
||||
if ($nature == 'decimal')
|
||||
{
|
||||
$length = $row['numeric_precision'] . ',' . $row['numeric_scale'];
|
||||
}
|
||||
else
|
||||
{
|
||||
$length = (int) floor($row['numeric_precision'] / 8); // precision is in bits
|
||||
}
|
||||
}
|
||||
elseif (isset($row['character_maximum_length']))
|
||||
{
|
||||
$length = $row['character_maximum_length'];
|
||||
}
|
||||
elseif (isset($row['character_octet_length']))
|
||||
{
|
||||
$length = $row['character_octet_length'];
|
||||
}
|
||||
// Convert the is_nullable field to a boolean
|
||||
$is_nullable = (mb_strtolower($row['is_nullable']) == 'yes');
|
||||
|
||||
$fields[] = array(
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'nature' => $nature,
|
||||
'length' => $length,
|
||||
'is_nullable' => $is_nullable,
|
||||
'default' => $default
|
||||
);
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
// Syntax methods
|
||||
|
||||
public function syntax_limit(int $count, int $offset) : string
|
||||
{
|
||||
return "LIMIT $count OFFSET $offset";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_timestamp_to_unix(string $fieldname) : string
|
||||
{
|
||||
// A PostgreSQL timestamp can be a float. We need to round it
|
||||
// to the nearest integer. Note that ROUND still returns a float type
|
||||
// even though the value is an integer, so we need to cast it as well.
|
||||
// (But the casting may round as well? If so the round is redundant.)
|
||||
return "CAST(ROUND(DATE_PART('epoch', $fieldname)) AS integer)";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_casesensitive_equals(string $fieldname, string $string, array &$params) : string
|
||||
{
|
||||
$params[] = $string;
|
||||
|
||||
return $this->quote($fieldname) . "=?";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_caseless_contains(string $fieldname, string $string, array &$params) : string
|
||||
{
|
||||
// In PostgreSQL, we can do case-insensitive regexp with ~*, but not case-insensitive LIKE matching.
|
||||
// Quotemeta escapes everything we need except for single quotes.
|
||||
$params[] = quotemeta($string);
|
||||
|
||||
return "$fieldname ~* ?";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_addcolumn_after(string $fieldname) : string
|
||||
{
|
||||
// Can't be done in PostgreSQL without dropping and re-creating the table.
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
public function syntax_createtable_autoincrementcolumn() : string
|
||||
{
|
||||
return "serial";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_bitwise_xor() : string
|
||||
{
|
||||
return "#";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_simple_split(string $fieldname, string $delimiter, int $part, array &$params) : string
|
||||
{
|
||||
switch ($part)
|
||||
{
|
||||
case 1:
|
||||
case 2:
|
||||
$count = $part;
|
||||
break;
|
||||
default:
|
||||
throw new Exception("Invalid value ($part) given for " . '$part.');
|
||||
break;
|
||||
}
|
||||
|
||||
$params[] = $delimiter;
|
||||
return "SPLIT_PART($fieldname, ?, $count)";
|
||||
}
|
||||
|
||||
|
||||
public function syntax_group_array_as_string(string $fieldname, string $delimiter=',') : string
|
||||
{
|
||||
// array_agg introduced in PostgreSQL version 8.4
|
||||
//
|
||||
// Use DISTINCT to eliminate duplicates which can arise when the query
|
||||
// has joins on two or more junction tables. Maybe a different query
|
||||
// would eliminate the duplicates and the need for DISTINCT, and it may
|
||||
// or may not be more efficient.
|
||||
return "array_to_string(array_agg(DISTINCT $fieldname), '$delimiter')";
|
||||
}
|
||||
|
||||
|
||||
// Returns the syntax for an "upsert" query. Unfortunately getting the id of the
|
||||
// last row differs between MySQL and PostgreSQL. In PostgreSQL the query will
|
||||
// return a row with the id in the 'id' column. However there isn't a corresponding
|
||||
// way of doing this in MySQL, but db()->insert_id() will work, regardless of whether
|
||||
// an insert or update was performed. In PostgreSQL insert_id() returns the sequence
|
||||
// number and not the id of the row. Because the sequence number is updated on every
|
||||
// INSERT in Postgres, regardless of whether a row was actually inserted, the value
|
||||
// won't be the id of the row in the case of an update. Note that one side effect of
|
||||
// this behaviour is that there will be gaps in the sequence numbers of the rows, but
|
||||
// this doesn't matter.
|
||||
//
|
||||
// $conflict_keys the key(s) which is/are unique; can be a scalar or an array
|
||||
// $assignments an array of assignments for the UPDATE clause
|
||||
// $has_id_column whether the table has an id column
|
||||
public function syntax_on_duplicate_key_update($conflict_keys, array $assignments, bool $has_id_column=false) : string
|
||||
{
|
||||
$conflict_keys = array_map(array($this, 'quote'), $conflict_keys);
|
||||
$sql = "ON CONFLICT (" . implode(', ', $conflict_keys) . ")";
|
||||
$sql .= " DO UPDATE SET " . implode(', ', $assignments);
|
||||
if ($has_id_column)
|
||||
{
|
||||
$sql .= " RETURNING id";
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
|
||||
// Parse the contents of column_default to get the default value.
|
||||
// Examples of column_default are "nextval('mrbs_users_id_seq'::regclass)", "NULL",
|
||||
// "0" and "'E'::bpchar"
|
||||
// WARNING: this is a very rough and ready parser and only deals with simple cases.
|
||||
// TODO: do something better
|
||||
private function parseDefault($default)
|
||||
{
|
||||
if (is_null($default) || str_starts_with($default, 'NULL::'))
|
||||
{
|
||||
$value = null;
|
||||
}
|
||||
elseif (preg_match("/^'(.*)'::/", $default, $matches))
|
||||
{
|
||||
$value = $matches[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
$value = $default;
|
||||
}
|
||||
|
||||
return ['value' => $value];
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user