phpOMS/DataStorage/Database/Query/Builder.php
2022-11-09 22:55:39 +01:00

1470 lines
30 KiB
PHP
Executable File

<?php
/**
* Karaka
*
* PHP Version 8.1
*
* @package phpOMS\DataStorage\Database\Query
* @copyright Dennis Eichhorn
* @license OMS License 1.0
* @version 1.0.0
* @link https://karaka.app
*/
declare(strict_types=1);
namespace phpOMS\DataStorage\Database\Query;
use phpOMS\Algorithm\Graph\DependencyResolver;
use phpOMS\Contract\SerializableInterface;
use phpOMS\DataStorage\Database\BuilderAbstract;
use phpOMS\DataStorage\Database\Connection\ConnectionAbstract;
/**
* Database query builder.
*
* @package phpOMS\DataStorage\Database\Query
* @license OMS License 1.0
* @link https://karaka.app
* @since 1.0.0
*/
class Builder extends BuilderAbstract
{
/**
* Log queries.
*
* @var bool
* @since 1.0.0
*/
public static bool $log = false;
/**
* Is read only.
*
* @var bool
* @since 1.0.0
*/
protected bool $isReadOnly = false;
/**
* Columns.
*
* @var array
* @since 1.0.0
*/
public array $selects = [];
/**
* Columns.
*
* @var array
* @since 1.0.0
*/
public array $random;
/**
* Columns.
*
* @var array
* @since 1.0.0
*/
public array $updates = [];
/**
* Stupid work around because value needs to be not null for it to work in Grammar.
*
* @var array
* @since 1.0.0
*/
public array $deletes = [1];
/**
* Into.
*
* @var string
* @since 1.0.0
*/
public string $into = '';
/**
* Into columns.
*
* @var array
* @since 1.0.0
*/
public array $inserts = [];
/**
* Into columns.
*
* @var array<int, mixed>
* @since 1.0.0
*/
public array $values = [];
/**
* Into columns.
*
* @var array
* @since 1.0.0
*/
public array $sets = [];
/**
* Distinct.
*
* @var bool
* @since 1.0.0
*/
public bool $distinct = false;
/**
* From.
*
* @var array
* @since 1.0.0
*/
public array $from = [];
/**
* Joins.
*
* @var array
* @since 1.0.0
*/
public array $joins = [];
/**
* Ons of joins.
*
* @var array
* @since 1.0.0
*/
public array $ons = [];
/**
* Where.
*
* @var array
* @since 1.0.0
*/
public array $wheres = [];
/**
* Group.
*
* @var string[]|self[]
* @since 1.0.0
*/
public array $groups = [];
/**
* Order.
*
* @var array
* @since 1.0.0
*/
public array $orders = [];
/**
* Limit.
*
* @var null|int
* @since 1.0.0
*/
public ?int $limit = null;
/**
* Offset.
*
* @var null|int
* @since 1.0.0
*/
public ?int $offset = null;
/**
* Binds.
*
* @var array
* @since 1.0.0
*/
private array $binds = [];
/**
* Union.
*
* @var array
* @since 1.0.0
*/
public array $unions = [];
/**
* Lock.
*
* @var bool
* @since 1.0.0
*/
public bool $lock = false;
/**
* Comparison OPERATORS.
*
* @var string[]
* @since 1.0.0
*/
public const OPERATORS = [
'=',
'<',
'>',
'<=',
'>=',
'<>',
'!=',
'like',
'like binary',
'not like',
'between',
'ilike',
'&',
'|',
'^',
'<<',
'>>',
'rlike',
'regexp',
'not regexp',
'~',
'~*',
'!~',
'!~*',
'similar to',
'not similar to',
'in',
];
/**
* Constructor.
*
* @param ConnectionAbstract $connection Database connection
* @param bool $readOnly Query is read only
*
* @since 1.0.0
*/
public function __construct(ConnectionAbstract $connection, bool $readOnly = false)
{
$this->isReadOnly = $readOnly;
$this->setConnection($connection);
}
/**
* Set connection for grammar.
*
* @param ConnectionAbstract $connection Database connection
*
* @return void
*
* @since 1.0.0
*/
public function setConnection(ConnectionAbstract $connection) : void
{
$this->connection = $connection;
$this->grammar = $connection->getGrammar();
}
/**
* Select.
*
* @param mixed ...$columns Columns
*
* @return Builder
*
* @since 1.0.0
*/
public function select(mixed ...$columns) : self
{
$this->type = QueryType::SELECT;
/** @var mixed[] $columns */
/** @var mixed $column */
foreach ($columns as $column) {
if (\is_string($column) || $column instanceof self) {
$this->selects[] = $column;
} else {
throw new \InvalidArgumentException();
}
}
return $this;
}
/**
* Select with alias.
*
* @param mixed $column Column query
* @param string $alias Alias
*
* @return Builder
*
* @since 1.0.0
*/
public function selectAs(mixed $column, string $alias) : self
{
$this->type = QueryType::SELECT;
$this->selects[$alias] = $column;
return $this;
}
/**
* Select.
*
* @param array ...$columns Columns
*
* @return Builder
*
* @since 1.0.0
*/
public function random(mixed ...$columns) : self
{
$this->select(...$columns);
$this->type = QueryType::RANDOM;
$this->random = &$this->selects;
return $this;
}
/**
* Bind parameter.
*
* @param string|array $binds Binds
*
* @return Builder
*
* @since 1.0.0
*/
public function bind(string | array $binds) : self
{
if (\is_array($binds)) {
$this->binds += $binds;
} else {
$this->binds[] = $binds;
}
return $this;
}
/**
* Parsing to sql string.
*
* @return string
*
* @since 1.0.0
*/
public function toSql() : string
{
if (!empty($this->joins)) {
$this->resolveJoinDependencies();
}
$query = $this->grammar->compileQuery($this);
if (self::$log) {
\phpOMS\Log\FileLogger::getInstance()->debug($query);
}
return $query;
}
/**
* Resolves join dependencies
*
* @return void
*
* @since 1.0.0
*/
private function resolveJoinDependencies() : void
{
// create dependencies
$dependencies = [];
foreach ($this->joins as $table => $join) {
$dependencies[$table] = [];
foreach ($this->ons[$table] as $on) {
if (\stripos($on['column'], '.')) {
$dependencies[$table][] = \explode('.', $on['column'])[0];
}
if (\stripos($on['value'], '.')) {
$dependencies[$table][] = \explode('.', $on['value'])[0];
}
}
}
// add from to existing dependencies
foreach ($this->from as $table => $from) {
$dependencies[$table] = [];
}
$resolved = DependencyResolver::resolve($dependencies);
// cyclomatic dependencies
if ($resolved === null) {
return;
}
$temp = $this->joins;
$this->joins = [];
foreach ($resolved as $table) {
if (isset($temp[$table])) {
$this->joins[$table] = $temp[$table];
}
}
}
/**
* Set raw query.
*
* @param string $raw Raw query
*
* @return Builder
*
* @throws \Exception
*
* @since 1.0.0
*/
public function raw(string $raw) : self
{
if (!$this->isValidReadOnly($raw)) {
throw new \Exception();
}
$this->type = QueryType::RAW;
$this->raw = \rtrim($raw, ';');
return $this;
}
/**
* Tests if a string contains a non read only component in case the builder is read only.
* If the builder is not read only it will always return true
*
* @param string $raw Raw query
*
* @return bool
*
* @since 1.0.0
*/
private function isValidReadOnly(string $raw) : bool
{
if (!$this->isReadOnly) {
return true;
}
$raw = \strtolower($raw);
if (\stripos($raw, 'insert') !== false
|| \stripos($raw, 'update') !== false
|| \stripos($raw, 'drop') !== false
|| \stripos($raw, 'delete') !== false
|| \stripos($raw, 'create') !== false
|| \stripos($raw, 'alter') !== false
) {
return false;
}
return true;
}
/**
* Is distinct.
*
* @return Builder
*
* @since 1.0.0
*/
public function distinct() : self
{
$this->distinct = true;
return $this;
}
/**
* From.
*
* @param mixed ...$tables Tables
*
* @return Builder
*
* @since 1.0.0
*/
public function from(mixed ...$tables) : self
{
/** @var mixed[] $tables */
/** @var mixed $table */
foreach ($tables as $key => $table) {
if (\is_string($table) || $table instanceof self) {
$this->from[] = $table;
} else {
throw new \InvalidArgumentException();
}
}
return $this;
}
/**
* From with alias.
*
* @param mixed $column Column query
* @param string $alias Alias
*
* @return Builder
*
* @since 1.0.0
*/
public function fromAs(mixed $column, string $alias) : self
{
$this->from[$alias] = $column;
return $this;
}
/**
* Where.
*
* @param string|array|Where $columns Columns
* @param string|array $operator Operator
* @param mixed $values Values
* @param string|array $boolean Boolean condition
*
* @return Builder
*
* @throws \InvalidArgumentException
*
* @since 1.0.0
*/
public function where(string | array | Builder $columns, string | array $operator = null, mixed $values = null, string | array $boolean = 'and') : self
{
if (!\is_array($columns)) {
$columns = [$columns];
$operator = [$operator];
$values = [$values];
$boolean = [$boolean];
}
$i = 0;
foreach ($columns as $column) {
if (isset($operator[$i]) && !\in_array(\strtolower($operator[$i]), self::OPERATORS)) {
throw new \InvalidArgumentException('Unknown operator: "' . $operator[$i] . '"');
}
$this->wheres[self::getPublicColumnName($column)][] = [
'column' => $column,
'operator' => $operator[$i],
'value' => $values[$i],
'boolean' => $boolean[$i],
];
++$i;
}
return $this;
}
/**
* Where and sub condition.
*
* @param string|array|Where $where Where sub condition
* @param string|array $operator Operator
* @param mixed $values Values
*
* @return Builder
*
* @since 1.0.0
*/
public function andWhere(string | array | Where $where, string | array $operator = null, mixed $values = null) : self
{
return $this->where($where, $operator, $values, 'and');
}
/**
* Where or sub condition.
*
* @param string|array|Where $where Where sub condition
* @param string|array $operator Operator
* @param mixed $values Values
*
* @return Builder
*
* @since 1.0.0
*/
public function orWhere(string | array | Builder $where, string | array $operator = null, mixed $values = null) : self
{
return $this->where($where, $operator, $values, 'or');
}
/**
* Where in.
*
* @param string|array|Where $column Column
* @param string|array $values Values
* @param string $boolean Boolean condition
*
* @return Builder
*
* @since 1.0.0
*/
public function whereIn(string | array | Where $column, mixed $values = null, string $boolean = 'and') : self
{
$this->where($column, 'in', $values, $boolean);
return $this;
}
/**
* Where null.
*
* @param string|array|Where $column Column
* @param string $boolean Boolean condition
*
* @return Builder
*
* @since 1.0.0
*/
public function whereNull(string | array | Where $column, string $boolean = 'and') : self
{
$this->where($column, '=', null, $boolean);
return $this;
}
/**
* Where not null.
*
* @param string|array|Where $column Column
* @param string $boolean Boolean condition
*
* @return Builder
*
* @since 1.0.0
*/
public function whereNotNull(string | array | Where $column, string $boolean = 'and') : self
{
$this->where($column, '!=', null, $boolean);
return $this;
}
/**
* Group by.
*
* @param mixed ...$columns Grouping result
*
* @return Builder
*
* @since 1.0.0
*/
public function groupBy(mixed ...$columns) : self
{
/** @var mixed[] $columns */
/** @var mixed $column */
foreach ($columns as $column) {
if (\is_string($column) || $column instanceof self) {
$this->groups[] = $column;
} else {
throw new \InvalidArgumentException();
}
}
return $this;
}
/**
* Order by newest.
*
* @param string $column Column
*
* @return Builder
*
* @since 1.0.0
*/
public function newest(string $column) : self
{
$this->orderBy($column, OrderType::DESC);
return $this;
}
/**
* Order by oldest.
*
* @param string $column Column
*
* @return Builder
*
* @since 1.0.0
*/
public function oldest(string $column) : self
{
$this->orderBy($column, OrderType::ASC);
return $this;
}
/**
* Order by.
*
* @param string|array $columns Columns
* @param string|string[] $order Orders
*
* @return Builder
*
* @since 1.0.0
*/
public function orderBy(string | array $columns, string | array $order = OrderType::DESC) : self
{
if (\is_string($columns)) {
$columns = [$columns];
}
foreach ($columns as $key => $column) {
$tOrder = \is_string($order) ? $order : $order[$key];
$this->orders[$column] = $tOrder;
}
return $this;
}
/**
* Offset.
*
* @param int $offset Offset
*
* @return Builder
*
* @since 1.0.0
*/
public function offset(int $offset) : self
{
$this->offset = $offset;
return $this;
}
/**
* Limit.
*
* @param int $limit Limit
*
* @return Builder
*
* @since 1.0.0
*/
public function limit(int $limit) : self
{
$this->limit = $limit;
return $this;
}
/**
* Union.
*
* @param mixed $query Query
*
* @return Builder
*
* @since 1.0.0
*/
public function union(mixed $query) : self
{
if (!\is_array($query)) {
$this->unions[] = $query;
} else {
$this->unions += $query;
}
return $this;
}
/**
* Lock query.
*
* @return void
*
* @since 1.0.0
*/
public function lock() : void
{
}
/**
* Lock for update query.
*
* @return void
*
* @since 1.0.0
*/
public function lockUpdate() : void
{
}
/**
* Create query string.
*
* @return string
*
* @since 1.0.0
*/
public function __toString()
{
return $this->grammar->compileQuery($this);
}
/**
* Find query.
*
* @return void
*
* @since 1.0.0
*/
public function find() : void
{
}
/**
* Count results.
*
* @param string $table Table to count the result set
*
* @return Builder
*
* @since 1.0.0
*/
public function count(string $table = '*') : self
{
/**
* @todo Karaka/phpOMS#??? [p:low] [t:optimization] [d:medium]
* Don't do this as a string, create a new object $this->select(new Count($table)).
* The parser should be able to handle this much better
*/
return $this->select('COUNT(' . $table . ')');
}
/**
* Select minimum.
*
* @return void
*
* @since 1.0.0
*/
public function min() : void
{
}
/**
* Select maximum.
*
* @return void
*
* @since 1.0.0
*/
public function max() : void
{
}
/**
* Select sum.
*
* @return void
*
* @since 1.0.0
*/
public function sum() : void
{
}
/**
* Select average.
*
* @return void
*
* @since 1.0.0
*/
public function avg() : void
{
}
/**
* Insert into columns.
*
* @param array ...$columns Columns
*
* @return Builder
*
* @throws \Exception
*
* @since 1.0.0
*/
public function insert(mixed ...$columns) : self
{
if ($this->isReadOnly) {
throw new \Exception();
}
$this->type = QueryType::INSERT;
foreach ($columns as $column) {
$this->inserts[] = $column;
}
return $this;
}
/**
* Table to insert into.
*
* @param string $table Table
*
* @return Builder
*
* @since 1.0.0
*/
public function into(string $table) : self
{
$this->into = $table;
return $this;
}
/**
* Values to insert.
*
* @param array ...$values Values
*
* @return Builder
*
* @since 1.0.0
*/
public function values(mixed ...$values) : self
{
$this->values[] = $values;
return $this;
}
/**
* Get insert values
*
* @return array
*
* @since 1.0.0
*/
public function getValues() : array
{
return $this->values;
}
/**
* Values to insert.
*
* @param mixed $value Values
*
* @return Builder
*
* @since 1.0.0
*/
public function value(mixed $value) : self
{
\end($this->values);
$key = \key($this->values);
$key ??= 0;
if (\is_array($value)) {
$this->values[$key + 1] = $value;
} else {
$this->values[$key][] = $value;
}
\reset($this->values);
return $this;
}
/**
* Values to insert.
*
* @param mixed ...$sets Values
*
* @return Builder
*
* @since 1.0.0
*/
public function sets(mixed ...$sets) : self
{
$this->sets[$sets[0]] = $sets[1] ?? null;
return $this;
}
/**
* Values to insert.
*
* @param mixed $set Values
*
* @return Builder
*
* @since 1.0.0
*/
public function set(mixed $set) : self
{
$this->sets[\key($set)] = \current($set);
return $this;
}
/**
* Update columns.
*
* @param mixed ...$tables Column names to update
*
* @return Builder
*
* @throws \Exception
*
* @since 1.0.0
*/
public function update(mixed ...$tables) : self
{
if ($this->isReadOnly) {
throw new \Exception();
}
$this->type = QueryType::UPDATE;
/** @var mixed[] $tables */
/** @var mixed $table */
foreach ($tables as $table) {
if (\is_string($table) || $table instanceof self) {
$this->updates[] = $table;
} else {
throw new \InvalidArgumentException();
}
}
return $this;
}
/**
* Delete query
*
* @return Builder
*
* @since 1.0.0
*/
public function delete() : self
{
if ($this->isReadOnly) {
throw new \Exception();
}
$this->type = QueryType::DELETE;
return $this;
}
/**
* Increment value.
*
* @return void
*
* @since 1.0.0
*/
public function increment() : void
{
}
/**
* Decrement value.
*
* @return void
*
* @since 1.0.0
*/
public function decrement() : void
{
}
/**
* Join.
*
* @param string|self $table Join query
* @param string $type Join type
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function join(string | self $table, string $type = JoinType::JOIN, string $alias = null) : self
{
$this->joins[$alias ?? $table] = ['type' => $type, 'table' => $table, 'alias' => $alias];
return $this;
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function leftJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::LEFT_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function leftOuterJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::LEFT_OUTER_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function leftInnerJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::LEFT_INNER_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function rightJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::RIGHT_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function rightOuterJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::RIGHT_OUTER_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function rightInnerJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::RIGHT_INNER_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function outerJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::OUTER_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function innerJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::INNER_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function crossJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::CROSS_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function fullJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::FULL_JOIN, $alias);
}
/**
* Join.
*
* @param string|self $table Join query
* @param null|string $alias Alias name (empty = none)
*
* @return Builder
*
* @since 1.0.0
*/
public function fullOuterJoin(string | self $table, string $alias = null) : self
{
return $this->join($table, JoinType::FULL_OUTER_JOIN, $alias);
}
/**
* Rollback.
*
* @return Builder
*
* @since 1.0.0
*/
public function rollback() : self
{
return $this;
}
/**
* On.
*
* @param string|array $columns Columns to join on
* @param null|string|array $operator Comparison operator
* @param null|string|array $values Values to compare with
* @param string|array $boolean Concatonator
* @param null|string $table Table this belongs to
*
* @return Builder
*
* @since 1.0.0
*/
public function on(string | array $columns, string | array $operator = null, string | array $values = null, string | array $boolean = 'and', string $table = null) : self
{
if (!\is_array($columns)) {
$columns = [$columns];
$operator = [$operator];
$values = [$values];
$boolean = [$boolean];
}
$joinCount = \count($this->joins) - 1;
$i = 0;
$table ??= \array_keys($this->joins)[$joinCount];
foreach ($columns as $column) {
if (isset($operator[$i]) && !\in_array(\strtolower($operator[$i]), self::OPERATORS)) {
throw new \InvalidArgumentException('Unknown operator.');
}
$this->ons[$table][] = [
'column' => $column,
'operator' => $operator[$i],
'value' => $values[$i],
'boolean' => $boolean[$i],
];
++$i;
}
return $this;
}
/**
* On.
*
* @param string|array $columns Columns to join on
* @param null|string|array $operator Comparison operator
* @param null|string|array $values Values to compare with
*
* @return Builder
*
* @since 1.0.0
*/
public function orOn(string | array $columns, string | array $operator = null, string | array $values = null) : self
{
return $this->on($columns, $operator, $values, 'or');
}
/**
* On.
*
* @param string|array $columns Columns to join on
* @param null|string|array $operator Comparison operator
* @param null|string|array $values Values to compare with
*
* @return Builder
*
* @since 1.0.0
*/
public function andOn(string | array $columns, string | array $operator = null, string | array $values = null) : self
{
return $this->on($columns, $operator, $values, 'and');
}
/**
* Merging query.
*
* Merging query in order to remove database query volume
*
* @return Builder
*
* @since 1.0.0
*/
public function merge() : self
{
return clone($this);
}
/**
* Execute query.
*
* @return ?\PDOStatement
*
* @since 1.0.0
*/
public function execute() : ?\PDOStatement
{
$sth = null;
try {
$sth = $this->connection->con->prepare($this->toSql());
if ($sth === false) {
return null;
}
foreach ($this->binds as $key => $bind) {
$type = self::getBindParamType($bind);
$sth->bindParam($key, $bind, $type);
}
$sth->execute();
} catch (\Throwable $t) {
// @codeCoverageIgnoreStart
\var_dump($t->getMessage());
\var_dump($this->toSql());
$sth = null;
// @codeCoverageIgnoreEnd
}
return $sth;
}
/**
* Get bind parameter type.
*
* @param mixed $value Value to bind
*
* @return int
*
* @throws \Exception
*
* @since 1.0.0
*/
public static function getBindParamType(mixed $value) : int
{
if (\is_int($value)) {
return \PDO::PARAM_INT;
} elseif (\is_string($value) || \is_float($value)) {
return \PDO::PARAM_STR;
}
throw new \Exception();
}
/**
* Get column name
*
* @param mixed $column Column name
*
* @return string
*
* @throws \Exception
*
* @since 1.0.0
*/
public static function getPublicColumnName(mixed $column) : string
{
if (\is_string($column)) {
return $column;
} elseif ($column instanceof Column) {
return $column->getColumn();
} elseif ($column instanceof SerializableInterface) {
return $column->serialize();
} elseif ($column instanceof self) {
return \md5($column->toSql());
}
throw new \Exception();
}
}