* @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; } }