MAPG-43 initial classes for DB handling

This commit is contained in:
Bence Pőcze 2020-05-27 01:04:44 +02:00
parent b821d4a045
commit 17c0ec73df
10 changed files with 881 additions and 0 deletions

View File

@ -0,0 +1,114 @@
<?php namespace MapGuesser\Database\Mysql;
use MapGuesser\Interfaces\Database\IConnection;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Interfaces\Database\IStatement;
use mysqli;
class Connection implements IConnection
{
private mysqli $connection;
public function __construct(string $host, string $user, string $password, string $db, int $port = -1, string $socket = null)
{
if ($port < 0) {
$port = ini_get('mysqli.default_port');
}
if ($socket === null) {
$socket = ini_get('mysqli.default_socket');
}
$this->connection = new mysqli($host, $user, $password, $db, $port, $socket);
if ($this->connection->connect_error) {
throw new \Exception('Connection failed: ' . $this->connection->connect_error);
}
if (!$this->connection->set_charset('utf8mb4')) {
throw new \Exception($this->connection->error);
}
}
public function __destruct()
{
$this->connection->close();
}
public function startTransaction(): void
{
if (!$this->connection->autocommit(false)) {
throw new \Exception($this->connection->error);
}
}
public function commit(): void
{
if (!$this->connection->commit() || !$this->connection->autocommit(true)) {
throw new \Exception($this->connection->error);
}
}
public function rollback(): void
{
if (!$this->connection->rollback() || !$this->connection->autocommit(true)) {
throw new \Exception($this->connection->error);
}
}
public function query(string $query): ?IResultSet
{
if (!($result = $this->connection->query($query))) {
throw new \Exception($this->connection->error . '. Query: ' . $query);
}
if ($result !== true) {
return new ResultSet($result);
}
return null;
}
public function multiQuery(string $query): array
{
if (!$this->connection->multi_query($query)) {
throw new \Exception($this->connection->error . '. Query: ' . $query);
}
$ret = [];
do {
if ($result = $this->connection->store_result()) {
$ret[] = new ResultSet($result);
} else {
$ret[] = null;
}
$this->connection->more_results();
} while ($this->connection->next_result());
if ($this->connection->error) {
throw new \Exception($this->connection->error . '. Query: ' . $query);
}
return $ret;
}
public function prepare(string $query): IStatement
{
if (!($stmt = $this->connection->prepare($query))) {
throw new \Exception($this->connection->error . '. Query: ' . $query);
}
return new Statement($stmt);
}
public function lastId(): int
{
return $this->connection->insert_id;
}
public function getAffectedRows(): int
{
return $this->connection->affected_rows;
}
}

View File

@ -0,0 +1,62 @@
<?php namespace MapGuesser\Database\Mysql;
use MapGuesser\Interfaces\Database\IResultSet;
use mysqli_result;
class ResultSet implements IResultSet
{
private mysqli_result $result;
public function __construct(mysqli_result $result)
{
$this->result = $result;
}
public function fetch(int $type = IResultSet::FETCH_ASSOC)
{
return $this->result->fetch_array($this->convertFetchType($type));
}
public function fetchAll(int $type = IResultSet::FETCH_ASSOC)
{
return $this->result->fetch_all($this->convertFetchType($type));
}
public function fetchOneColumn(string $valueName, string $keyName = null)
{
$array = [];
while ($r = $this->fetch(IResultSet::FETCH_ASSOC)) {
if (isset($keyName)) {
$array[$r[$keyName]] = $r[$valueName];
} else {
$array[] = $r[$valueName];
}
}
return $array;
}
private function convertFetchType(int $type): int
{
switch ($type) {
case IResultSet::FETCH_ASSOC:
$internal_type = MYSQLI_ASSOC;
break;
case IResultSet::FETCH_BOTH:
$internal_type = MYSQLI_BOTH;
break;
case IResultSet::FETCH_NUM:
$internal_type = MYSQLI_NUM;
break;
default:
$internal_type = MYSQLI_BOTH;
break;
}
return $internal_type;
}
}

View File

@ -0,0 +1,79 @@
<?php namespace MapGuesser\Database\Mysql;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Interfaces\Database\IStatement;
use mysqli_stmt;
class Statement implements IStatement
{
private mysqli_stmt $stmt;
public function __construct(mysqli_stmt $stmt)
{
$this->stmt = $stmt;
}
public function __destruct()
{
$this->stmt->close();
}
public function execute(array $params = []): ?IResultSet
{
if ($params) {
$ref_params = [''];
foreach ($params as &$param) {
$type = gettype($param);
switch ($type) {
case 'integer':
case 'double':
case 'string':
$t = $type[0];
break;
case 'NULL':
$t = 's';
break;
case 'boolean':
$param = (string) (int) $param;
$t = 's';
break;
case 'array':
$param = json_encode($param);
$t = 's';
break;
}
if (!isset($t)) {
throw new \Exception('Data type ' . $type . ' not supported!');
}
$ref_params[] = &$param;
$ref_params[0] .= $t;
}
if (!call_user_func_array([$this->stmt, 'bind_param'], $ref_params)) {
throw new \Exception($this->stmt->error);
}
}
if (!$this->stmt->execute()) {
throw new \Exception($this->stmt->error);
}
if ($result_set = $this->stmt->get_result()) {
return new ResultSet($result_set);
}
return null;
}
public function getAffectedRows(): int
{
return $this->stmt->affected_rows;
}
}

157
src/Database/Query/Modify.php Executable file
View File

@ -0,0 +1,157 @@
<?php namespace MapGuesser\Database\Query;
use MapGuesser\Interfaces\Database\IConnection;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Database\Utils;
class Modify
{
private IConnection $connection;
private string $table;
private string $idName = 'id';
private array $attributes = [];
private array $original = [];
private bool $autoIncrement = false;
public function __construct(IConnection $connection, string $table)
{
$this->connection = $connection;
$this->table = $table;
}
public function setIdName(string $idName): Modify
{
$this->idName = $idName;
return $this;
}
public function setAutoIncrement(bool $autoIncrement = true): Modify
{
$this->autoIncrement = $autoIncrement;
return $this;
}
public function fill(array $attributes): Modify
{
$this->attributes = array_merge($this->attributes, $attributes);
return $this;
}
public function set(string $name, $value): Modify
{
$this->attributes[$name] = $value;
return $this;
}
public function setId($id): Modify
{
$this->attributes[$this->idName] = $id;
return $this;
}
public function save(): void
{
if (isset($this->attributes[$this->idName])) {
$this->update();
} else {
$this->insert();
}
}
public function delete(): void
{
if (!isset($this->attributes[$this->idName])) {
throw new \Exception('No primary key specified!');
}
$query = 'DELETE FROM ' . Utils::backtick($this->table) . ' WHERE ' . Utils::backtick($this->idName) . '=?';
$stmt = $this->connection->prepare($query);
$stmt->execute([$this->idName => $this->attributes[$this->idName]]);
}
private function insert(): void
{
if (!$this->autoIncrement) {
$this->attributes[$this->idName] = $this->generateKey();
}
$set = $this->generateColumnsWithBinding(array_keys($this->attributes));
$query = 'INSERT INTO ' . Utils::backtick($this->table) . ' SET ' . $set;
$stmt = $this->connection->prepare($query);
$stmt->execute($this->attributes);
if ($this->autoIncrement) {
$this->attributes[$this->idName] = $this->connection->lastId();
}
}
private function update(): void
{
$diff = $this->generateDiff();
if (count($diff) === 0) {
return;
}
$set = $this->generateColumnsWithBinding(array_keys($diff));
$query = 'UPDATE ' . Utils::backtick($this->table) . ' SET ' . $set . ' WHERE ' . Utils::backtick($this->idName) . '=?';
$stmt = $this->connection->prepare($query);
$stmt->execute(array_merge($diff, [$this->idName => $this->attributes[$this->idName]]));
}
private function readFromDB(array $columns): void
{
$select = (new Select($this->connection, $this->table))
->setIdName($this->idName)
->whereId($this->attributes[$this->idName])
->columns($columns);
$this->original = $select->execute()->fetch(IResultSet::FETCH_ASSOC);
}
private function generateDiff(): array
{
$this->readFromDB(array_keys($this->attributes));
$diff = [];
foreach ($this->attributes as $name => $value) {
$original = $this->original[$name];
if ($original != $value) {
$diff[$name] = $value;
}
}
return $diff;
}
public static function generateColumnsWithBinding(array $columns): string
{
array_walk($columns, function(&$value, $key) {
$value = Utils::backtick($value) . '=?';
});
return implode(',', $columns);
}
private function generateKey(): string
{
return substr(hash('sha256', serialize($this->attributes) . random_bytes(10) . microtime()), 0, 7);
}
}

View File

@ -0,0 +1,402 @@
<?php namespace MapGuesser\Database\Query;
use Closure;
use MapGuesser\Interfaces\Database\IConnection;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Database\RawExpression;
use MapGuesser\Database\Utils;
class Select
{
const CONDITION_WHERE = 0;
const CONDITION_HAVING = 1;
private IConnection $connection;
private string $table;
private string $idName = 'id';
private array $tableAliases = [];
private array $joins = [];
private array $columns = [];
private array $conditions = [self::CONDITION_WHERE => [], self::CONDITION_HAVING => []];
private array $groups = [];
private array $orders = [];
private array $limit;
public function __construct(IConnection $connection, string $table)
{
$this->connection = $connection;
$this->table = $table;
}
public function setIdName(string $idName): Select
{
$this->idName = $idName;
return $this;
}
public function setTableAliases(array $tableAliases): Select
{
$this->tableAliases = array_merge($this->tableAliases, $tableAliases);
return $this;
}
public function columns(array $columns): Select
{
$this->columns = array_merge($this->columns, $columns);
return $this;
}
public function innerJoin($table, $column1, string $relation, $column2): Select
{
$this->addJoin('INNER', $table, $column1, $relation, $column2);
return $this;
}
public function leftJoin($table, $column1, string $relation, $column2): Select
{
$this->addJoin('LEFT', $table, $column1, $relation, $column2);
return $this;
}
public function whereId($value): Select
{
$this->addWhereCondition('AND', $this->idName, '=', $value);
return $this;
}
public function where($column, string $relation = null, $value = null): Select
{
$this->addWhereCondition('AND', $column, $relation, $value);
return $this;
}
public function orWhere($column, string $relation = null, $value = null): Select
{
$this->addWhereCondition('OR', $column, $relation, $value);
return $this;
}
public function having($column, string $relation = null, $value = null): Select
{
$this->addHavingCondition('AND', $column, $relation, $value);
return $this;
}
public function orHaving($column, string $relation = null, $value = null): Select
{
$this->addHavingCondition('OR', $column, $relation, $value);
return $this;
}
public function groupBy($column): Select
{
$this->groups[] = $column;
return $this;
}
public function orderBy($column, string $type = 'asc'): Select
{
$this->orders[] = [$column, $type];
return $this;
}
public function limit(int $limit, int $offset = 0): Select
{
$this->limit = [$limit, $offset];
return $this;
}
public function resetLimit(): void
{
$this->limit = null;
}
public function paginate(int $page, int $itemsPerPage)
{
$this->limit($itemsPerPage, ($page - 1) * $itemsPerPage);
return $this;
}
public function execute(): IResultSet
{
list($query, $params) = $this->generateQuery();
return $this->connection->prepare($query)->execute($params);
}
public function count(): int
{
if (count($this->groups) > 0 || count($this->conditions[self::CONDITION_HAVING]) > 0) {
$orders = $this->orders;
$this->orders = [];
list($query, $params) = $this->generateQuery();
$result = $this->connection->prepare('SELECT COUNT(*) num_rows FROM (' . $query . ') x')
->execute($params)
->fetch(IResultSet::FETCH_NUM);
$this->orders = $orders;
return $result[0];
} else {
$columns = $this->columns;
$orders = $this->orders;
$this->columns = [new RawExpression('COUNT(*) num_rows')];
$this->orders = [];
list($query, $params) = $this->generateQuery();
$result = $this->connection->prepare($query)
->execute($params)
->fetch(IResultSet::FETCH_NUM);
$this->columns = $columns;
$this->orders = $orders;
return $result[0];
}
}
private function addJoin(string $type, $table, $column1, string $relation, $column2): void
{
$this->joins[] = [$type, $table, $column1, $relation, $column2];
}
private function addWhereCondition(string $logic, $column, string $relation, $value): void
{
$this->conditions[self::CONDITION_WHERE][] = [$logic, $column, $relation, $value];
}
private function addHavingCondition(string $logic, $column, string $relation, $value): void
{
$this->conditions[self::CONDITION_HAVING][] = [$logic, $column, $relation, $value];
}
private function generateQuery(): array
{
$queryString = 'SELECT ' . $this->generateColumns() . ' FROM ' . $this->generateTable($this->table, true);
if (count($this->joins) > 0) {
$queryString .= ' ' . $this->generateJoins();
}
if (count($this->conditions[self::CONDITION_WHERE]) > 0) {
list($wheres, $whereParams) = $this->generateConditions(self::CONDITION_WHERE);
$queryString .= ' WHERE ' . $wheres;
} else {
$whereParams = [];
}
if (count($this->groups) > 0) {
$queryString .= ' GROUP BY ' . $this->generateGroupBy();
}
if (count($this->conditions[self::CONDITION_HAVING]) > 0) {
list($havings, $havingParams) = $this->generateConditions(self::CONDITION_HAVING);
$queryString .= ' HAVING ' . $havings;
} else {
$havingParams = [];
}
if (count($this->orders) > 0) {
$queryString .= ' ORDER BY ' . $this->generateOrderBy();
}
if (isset($this->limit)) {
$queryString .= ' LIMIT ' . $this->limit[1] . ', ' . $this->limit[0];
}
return [$queryString, array_merge($whereParams, $havingParams)];
}
private function generateTable($table, bool $defineAlias = false): string
{
if ($table instanceof RawExpression) {
return (string) $table;
}
if (isset($this->tableAliases[$table])) {
return ($defineAlias ? Utils::backtick($this->tableAliases[$table]) . ' ' . Utils::backtick($table) : Utils::backtick($table));
}
return Utils::backtick($table);
}
private function generateColumn($column): string
{
if ($column instanceof RawExpression) {
return (string) $column;
}
if (is_array($column)) {
$out = '';
if ($column[0]) {
$out .= $this->generateTable($column[0]) . '.';
}
$out .= Utils::backtick($column[1]);
if (!empty($column[2])) {
$out .= ' ' . Utils::backtick($column[2]);
}
return $out;
} else {
return Utils::backtick($column);
}
}
private function generateColumns(): string
{
$columns = $this->columns;
array_walk($columns, function (&$value, $key) {
$value = $this->generateColumn($value);
});
return implode(',', $columns);
}
private function generateJoins(): string
{
$joins = $this->joins;
array_walk($joins, function (&$value, $key) {
$value = $value[0] . ' JOIN ' . $this->generateTable($value[1], true) . ' ON ' . $this->generateColumn($value[2]) . ' ' . $value[3] . ' ' . $this->generateColumn($value[4]);
});
return implode(' ', $joins);
}
private function generateConditions(string $type): array
{
$conditions = '';
$params = [];
foreach ($this->conditions[$type] as $condition) {
list($logic, $column, $relation, $value) = $condition;
if ($column instanceof Closure) {
list($conditionsStringFragment, $paramsFragment) = $this->generateComplexConditionFragment($type, $column);
} else {
list($conditionsStringFragment, $paramsFragment) = $this->generateConditionFragment($condition);
}
if ($conditions !== '') {
$conditions .= ' ' . $logic . ' ';
}
$conditions .= $conditionsStringFragment;
$params = array_merge($params, $paramsFragment);
}
return [$conditions, $params];
}
private function generateConditionFragment(array $condition): array
{
list($logic, $column, $relation, $value) = $condition;
if ($column instanceof RawExpression) {
return [(string) $column, []];
}
$conditionsString = $this->generateColumn($column) . ' ';
if ($value === null) {
return [$conditionsString . ($relation == '=' ? 'IS NULL' : 'IS NOT NULL'), []];
}
$conditionsString .= strtoupper($relation) . ' ';;
switch ($relation = strtolower($relation)) {
case 'between':
$params = [$value[0], $value[1]];
$conditionsString .= '? AND ?';
break;
case 'in':
case 'not in':
$params = $value;
if (count($value) > 0) {
$conditionsString .= '(' . implode(', ', array_fill(0, count($value), '?')) . ')';
} else {
$conditionsString = $relation == 'in' ? '0' : '1';
}
break;
default:
$params = [$value];
$conditionsString .= '?';
}
return [$conditionsString, $params];
}
private function generateComplexConditionFragment(string $type, Closure $conditionCallback): array
{
$instance = new static($this->connection, $this->table);
$instance->tableAliases = $this->tableAliases;
$conditionCallback($instance);
list($conditions, $params) = $instance->generateConditions($type);
return ['(' . $conditions . ')', $params];
}
private function generateGroupBy(): string
{
$groups = $this->groups;
array_walk($groups, function (&$value, $key) {
$value = $this->generateColumn($value);
});
return implode(',', $groups);
}
private function generateOrderBy(): string
{
$orders = $this->orders;
array_walk($orders, function (&$value, $key) {
$value = $this->generateColumn($value[0]) . ' ' . $value[1];
});
return implode(',', $orders);
}
}

View File

@ -0,0 +1,16 @@
<?php namespace MapGuesser\Database;
class RawExpression
{
private string $expression;
public function __construct(string $expression)
{
$this->expression = $expression;
}
public function __toString(): string
{
return $this->expression;
}
}

7
src/Database/Utils.php Normal file
View File

@ -0,0 +1,7 @@
<?php namespace MapGuesser\Database;
class Utils {
public static function backtick(string $name) {
return '`' . $name . '`';
}
}

View File

@ -0,0 +1,20 @@
<?php namespace MapGuesser\Interfaces\Database;
interface IConnection
{
public function startTransaction(): void;
public function commit(): void;
public function rollback(): void;
public function query(string $query): ?IResultSet;
public function multiQuery(string $query): array;
public function prepare(string $query): IStatement;
public function lastId(): int;
public function getAffectedRows(): int;
}

View File

@ -0,0 +1,16 @@
<?php namespace MapGuesser\Interfaces\Database;
interface IResultSet
{
const FETCH_ASSOC = 0;
const FETCH_NUM = 1;
const FETCH_BOTH = 2;
public function fetch(int $type);
public function fetchAll(int $type);
public function fetchOneColumn(string $valueName, string $keyName);
}

View File

@ -0,0 +1,8 @@
<?php namespace MapGuesser\Interfaces\Database;
interface IStatement
{
public function execute(array $params): ?IResultSet;
public function getAffectedRows(): int;
}