Query/src/QueryBuilderBase.php

530 lines
12 KiB
PHP

<?php declare(strict_types=1);
/**
* Query
*
* SQL Query Builder / Database Abstraction Layer
*
* PHP version 8.1
*
* @package Query
* @author Timothy J. Warren <tim@timshome.page>
* @copyright 2012 - 2023 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @link https://git.timshomepage.net/aviat/Query
* @version 4.1.0
*/
namespace Query;
use BadMethodCallException;
use PDO;
use PDOStatement;
use PHPUnit\Framework\Attributes\CodeCoverageIgnore;
use Query\Drivers\DriverInterface;
use function is_string;
use function regexInArray;
/**
* @method affectedRows(): int
* @method beginTransaction(): bool
* @method commit(): bool
* @method errorCode(): string
* @method errorInfo(): array
* @method exec(string $statement): int
* @method getAttribute(int $attribute)
* @method getColumns(string $table): array | null
* @method getDbs(): array | null
* @method getFks(string $table): array | null
* @method getIndexes(string $table): array | null
* @method getLastQuery(): string
* @method getSchemas(): array | null
* @method getSequences(): array | null
* @method getSystemTables(): array | null
* @method getTables(): array
* @method getTypes(): array | null
* @method getUtil(): \Query\Drivers\AbstractUtil
* @method getVersion(): string
* @method getViews(): array | null
* @method inTransaction(): bool
* @method lastInsertId(string $name = NULL): string
* @method numRows(): int | null
* @method prepare(string $statement, array $driver_options = []): PDOStatement
* @method prepareExecute(string $sql, array $params): PDOStatement
* @method prepareQuery(string $sql, array $data): PDOStatement
* @method query(string $statement): PDOStatement
* @method quote(string $string, int $parameter_type = PDO::PARAM_STR): string
* @method rollback(): bool
* @method setAttribute(int $attribute, $value): bool
* @method setTablePrefix(string $prefix): void
* @method truncate(string $table): PDOStatement
*/
class QueryBuilderBase
{
/**
* Convenience property for connection management
*/
public string $connName = '';
/**
* List of queries executed
*/
public array $queries = [
'total_time' => 0,
];
/**
* Whether to do only an explain on the query
*/
protected bool $explain = FALSE;
/**
* Whether to return data from a modification query
*/
protected bool $returning = FALSE;
/**
* Query Builder state
*/
protected State $state;
// --------------------------------------------------------------------------
// ! Methods
// --------------------------------------------------------------------------
public function __construct(protected ?DriverInterface $driver, protected QueryParser $parser)
{
// Create new State object
$this->state = new State();
}
#[CodeCoverageIgnore]
public function __destruct()
{
$this->driver = NULL;
}
/**
* Calls a function further down the inheritance chain.
* 'Implements' methods on the driver object
*
* @throws BadMethodCallException
* @return mixed
*/
public function __call(string $name, array $params)
{
if (method_exists($this->driver, $name))
{
return $this->driver->$name(...$params);
}
throw new BadMethodCallException('Method does not exist');
}
/**
* Clear out the class variables, so the next query can be run
*/
public function resetQuery(): void
{
$this->state = new State();
$this->explain = FALSE;
$this->returning = FALSE;
}
/**
* Method to simplify select_ methods
*/
protected function _select(string $field, bool|string $as = FALSE): string
{
// Escape the identifiers
$field = $this->driver->quoteIdent($field);
if ( ! is_string($as))
{
// @codeCoverageIgnoreStart
return $field;
// @codeCoverageIgnoreEnd
}
$as = $this->driver->quoteIdent($as);
return "({$field}) AS {$as} ";
}
/**
* Helper function for returning sql strings
*/
protected function _getCompile(QueryType $type, string $table, bool $reset): string
{
$sql = $this->_compile($type, $table);
// Reset the query builder for the next query
if ($reset)
{
$this->resetQuery();
}
return $sql;
}
/**
* Simplify 'like' methods
*/
protected function _like(string $field, mixed $val, LikeType $pos, string $like = 'LIKE', string $conj = 'AND'): self
{
$field = $this->driver->quoteIdent($field);
// Add the like string into the order map
$like = $field . " {$like} ?";
if ($pos === LikeType::BEFORE)
{
$val = "%{$val}";
}
elseif ($pos === LikeType::AFTER)
{
$val = "{$val}%";
}
else
{
$val = "%{$val}%";
}
$conj = empty($this->state->getQueryMap()) ? ' WHERE ' : " {$conj} ";
$this->state->appendMap($conj, $like, MapType::LIKE);
// Add to the values array
$this->state->appendWhereValues($val);
return $this;
}
/**
* Simplify building having clauses
*/
protected function _having(mixed $key, mixed $values = [], string $conj = 'AND'): self
{
$where = $this->_where($key, $values);
// Create key/value placeholders
foreach ($where as $f => $val)
{
// Split each key by spaces, in case there
// is an operator such as >, <, !=, etc.
$fArray = explode(' ', trim($f));
$item = $this->driver->quoteIdent($fArray[0]);
// Simple key value, or an operator
$item .= (count($fArray) === 1) ? '=?' : " {$fArray[1]} ?";
// Put in the having map
$this->state->appendHavingMap([
'conjunction' => empty($this->state->getHavingMap())
? ' HAVING '
: " {$conj} ",
'string' => $item,
]);
}
return $this;
}
/**
* Do all the redundant stuff for where/having type methods
*/
protected function _where(mixed $key, mixed $val = []): array
{
$where = [];
$pairs = [];
if (is_scalar($key))
{
$pairs[$key] = $val;
}
else
{
$pairs = $key;
}
foreach ($pairs as $k => $v)
{
$where[$k] = $v;
$this->state->appendWhereValues($v);
}
return $where;
}
/**
* Simplify generating where string
*/
protected function _whereString(mixed $key, mixed $values = [], string $defaultConj = 'AND'): self
{
// Create key/value placeholders
foreach ($this->_where($key, $values) as $f => $val)
{
$queryMap = $this->state->getQueryMap();
// Split each key by spaces, in case there
// is an operator such as >, <, !=, etc.
$fArray = explode(' ', trim($f));
$item = $this->driver->quoteIdent($fArray[0]);
// Simple key value, or an operator
$item .= (count($fArray) === 1) ? '=?' : " {$fArray[1]} ?";
$lastItem = end($queryMap);
// Determine the correct conjunction
$conjunctionList = array_column($queryMap, 'conjunction');
if (empty($queryMap) || ( ! regexInArray($conjunctionList, "/^ ?\n?WHERE/i")))
{
$conj = "\nWHERE ";
}
elseif ($lastItem['type'] === MapType::GROUP_START)
{
$conj = '';
}
else
{
$conj = " {$defaultConj} ";
}
$this->state->appendMap($conj, $item, MapType::WHERE);
}
return $this;
}
/**
* Simplify where_in methods
*
* @param string $in - The (not) in fragment
* @param string $conj - The where in conjunction
*/
protected function _whereIn(mixed $key, mixed $val = [], string $in = 'IN', string $conj = 'AND'): self
{
$key = $this->driver->quoteIdent($key);
$params = array_fill(0, is_countable($val) ? count($val) : 0, '?');
$this->state->appendWhereValues($val);
$conjunction = empty($this->state->getQueryMap()) ? ' WHERE ' : " {$conj} ";
$str = $key . " {$in} (" . implode(',', $params) . ') ';
$this->state->appendMap($conjunction, $str, MapType::WHERE_IN);
return $this;
}
/**
* Executes the compiled query
*/
protected function _run(QueryType $type, string $table, ?string $sql = NULL, ?array $vals = NULL, bool $reset = TRUE): PDOStatement
{
if ($sql === NULL)
{
$sql = $this->_compile($type, $table);
}
if ($vals === NULL)
{
$vals = array_merge($this->state->getValues(), $this->state->getWhereValues());
}
$startTime = microtime(TRUE);
$res = empty($vals)
? $this->driver->query($sql)
: $this->driver->prepareExecute($sql, $vals);
$endTime = microtime(TRUE);
$totalTime = number_format($endTime - $startTime, 5);
// Add this query to the list of executed queries
$this->_appendQuery($vals, $sql, (int) $totalTime);
// Reset class state for next query
if ($reset)
{
$this->resetQuery();
}
return $res;
}
/**
* Convert the prepared statement into readable sql
*/
protected function _appendQuery(array $values, string $sql, int $totalTime): void
{
$evals = is_array($values) ? $values : [];
$esql = str_replace('?', '%s', $sql);
// Quote string values
foreach ($evals as &$v)
{
$v = (is_numeric($v))
? $v
: htmlentities((string) $this->driver->quote($v), ENT_NOQUOTES, 'utf-8');
}
unset($v);
// Add the query onto the array of values to pass
// as arguments to sprintf
array_unshift($evals, $esql);
// Add the interpreted query to the list of executed queries
$this->queries[] = [
'time' => $totalTime,
'sql' => sprintf(...$evals),
];
$this->queries['total_time'] += $totalTime;
// Set the last query to get rowcounts properly
$this->driver->setLastQuery($sql);
}
/**
* Sub-method for generating sql strings
*/
#[CodeCoverageIgnore]
protected function _compileType(QueryType $type = QueryType::SELECT, string $table = ''): string
{
$setArrayKeys = $this->state->getSetArrayKeys();
switch ($type)
{
case QueryType::INSERT:
$paramCount = is_countable($setArrayKeys) ? count($setArrayKeys) : 0;
$params = array_fill(0, $paramCount, '?');
$sql = "INSERT INTO {$table} ("
. implode(',', $setArrayKeys)
. ")\nVALUES (" . implode(',', $params) . ')';
break;
case QueryType::UPDATE:
$setString = $this->state->getSetString();
$sql = "UPDATE {$table}\nSET {$setString}";
break;
case QueryType::DELETE:
$sql = "DELETE FROM {$table}";
break;
case QueryType::SELECT:
default:
$fromString = $this->state->getFromString();
$selectString = $this->state->getSelectString();
$sql = "SELECT * \nFROM {$fromString}";
// Set the select string
if ( ! empty($selectString))
{
// Replace the star with the selected fields
$sql = str_replace('*', $selectString, $sql);
}
break;
}
return $sql;
}
/**
* String together the sql statements for sending to the db
*/
protected function _compile(QueryType $type, string $table = ''): string
{
// Get the base clause for the query
$sql = $this->_compileType($type, $this->driver->quoteTable($table));
$clauses = [
'queryMap',
'groupString',
'orderString',
'havingMap',
];
// Set each type of subclause
foreach ($clauses as $clause)
{
$func = 'get' . ucfirst($clause);
$param = $this->state->$func();
if (is_array($param))
{
foreach ($param as $q)
{
$sql .= $q['conjunction'] . $q['string'];
}
}
else
{
$sql .= $param;
}
}
// Set the limit via the class variables
$limit = $this->state->getLimit();
if (is_numeric($limit))
{
$sql = $this->driver->getSql()->limit($sql, $limit, $this->state->getOffset());
}
// Set the returning clause, if applicable
$sql = $this->_compileReturning($sql, $type);
// See if the query plan, rather than the
// query data should be returned
if ($this->explain === TRUE)
{
$sql = $this->driver->getSql()->explain($sql);
}
return $sql;
}
/**
* Generate returning clause of query
*/
protected function _compileReturning(string $sql, QueryType $type): string
{
if ($this->returning === FALSE)
{
return $sql;
}
$rawSelect = $this->state->getSelectString();
$selectString = ($rawSelect === '') ? '*' : $rawSelect;
$returningSQL = $this->driver->returning($sql, $selectString);
if ($returningSQL === $sql)
{
// If the driver doesn't support the returning clause, it returns the original query.
// Fake the same result with a transaction and a select query
if ( ! $this->inTransaction())
{
$this->beginTransaction();
}
// Generate the appropriate select query for the returning clause fallback
// @TODO figure out how to do a proper fallback
switch ($type)
{
case QueryType::INSERT:
case QueryType::UPDATE:
case QueryType::INSERT_BATCH:
case QueryType::UPDATE_BATCH:
default:
// On Delete queries, what would we return?
break;
}
}
return $returningSQL;
}
}