'i', ParameterType::INTEGER => 'i', ParameterType::LARGE_OBJECT => 's', ParameterType::NULL => 's', ParameterType::STRING => 's', ]; /** * Column names from the executed statement. * * @var array|boolean|null * @since 2.0.0 */ protected $columnNames; /** * The database connection resource. * * @var \mysqli * @since 2.0.0 */ protected $connection; /** * The default fetch mode for the statement. * * @var integer * @since 2.0.0 */ protected $defaultFetchStyle = FetchMode::MIXED; /** * The query string being prepared. * * @var string * @since 2.0.0 */ protected $query; /** * Internal tracking flag to set whether there is a result set available for processing * * @var boolean * @since 2.0.0 */ private $result = false; /** * Values which have been bound to the rows of each result set. * * @var array * @since 2.0.0 */ protected $rowBindedValues; /** * The prepared statement. * * @var \mysqli_stmt * @since 2.0.0 */ protected $statement; /** * Bound parameter types. * * @var array * @since 2.0.0 */ protected $typesKeyMapping; /** * Constructor. * * @param \mysqli $connection The database connection resource * @param string $query The query this statement will process * * @since 2.0.0 * @throws PrepareStatementFailureException */ public function __construct(\mysqli $connection, string $query) { $this->connection = $connection; $this->query = $query; $query = $this->prepareParameterKeyMapping($query); $this->statement = $connection->prepare($query); if (!$this->statement) { throw new PrepareStatementFailureException($this->connection->error, $this->connection->errno); } } /** * Replace named parameters with numbered parameters * * @param string $sql The SQL statement to prepare. * * @return string The processed SQL statement. * * @since 2.0.0 */ public function prepareParameterKeyMapping($sql) { $escaped = false; $startPos = 0; $quoteChar = ''; $literal = ''; $mapping = []; $replace = []; $matches = []; $pattern = '/([:][a-zA-Z0-9_]+)/'; if (!preg_match($pattern, $sql, $matches)) { return $sql; } $sql = trim($sql); $n = \strlen($sql); while ($startPos < $n) { if (!preg_match($pattern, $sql, $matches, 0, $startPos)) { break; } $j = strpos($sql, "'", $startPos); $k = strpos($sql, '"', $startPos); if (($k !== false) && (($k < $j) || ($j === false))) { $quoteChar = '"'; $j = $k; } else { $quoteChar = "'"; } if ($j === false) { $j = $n; } // Search for named prepared parameters and replace it with ? and save its position $substring = substr($sql, $startPos, $j - $startPos); if (preg_match_all($pattern, $substring, $matches, PREG_PATTERN_ORDER + PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $i => $match) { if ($i === 0) { $literal .= substr($substring, 0, $match[1]); } $mapping[$match[0]] = \count($mapping); $endOfPlaceholder = $match[1] + strlen($match[0]); $beginOfNextPlaceholder = $matches[0][$i + 1][1] ?? strlen($substring); $beginOfNextPlaceholder -= $endOfPlaceholder; $literal .= '?' . substr($substring, $endOfPlaceholder, $beginOfNextPlaceholder); } } else { $literal .= $substring; } $startPos = $j; $j++; if ($j >= $n) { break; } // Quote comes first, find end of quote while (true) { $k = strpos($sql, $quoteChar, $j); $escaped = false; if ($k === false) { break; } $l = $k - 1; while ($l >= 0 && $sql[$l] === '\\') { $l--; $escaped = !$escaped; } if ($escaped) { $j = $k + 1; continue; } break; } if ($k === false) { // Error in the query - no end quote; ignore it break; } $literal .= substr($sql, $startPos, $k - $startPos + 1); $startPos = $k + 1; } if ($startPos < $n) { $literal .= substr($sql, $startPos, $n - $startPos); } $this->parameterKeyMapping = $mapping; return $literal; } /** * Binds a parameter to the specified variable name. * * @param string|integer $parameter Parameter identifier. For a prepared statement using named placeholders, this will be a parameter * name of the form `:name`. For a prepared statement using question mark placeholders, this will be * the 1-indexed position of the parameter. * @param mixed $variable Name of the PHP variable to bind to the SQL statement parameter. * @param integer $dataType Constant corresponding to a SQL datatype, this should be the processed type from the QueryInterface. * @param integer $length The length of the variable. Usually required for OUTPUT parameters. * @param array $driverOptions Optional driver options to be used. * * @return boolean * * @since 2.0.0 */ public function bindParam($parameter, &$variable, string $dataType = ParameterType::STRING, ?int $length = null, ?array $driverOptions = null) { $this->bindedValues[$parameter] =& $variable; // Validate parameter type if (!isset($this->parameterTypeMapping[$dataType])) { throw new \InvalidArgumentException(sprintf('Unsupported parameter type `%s`', $dataType)); } $this->typesKeyMapping[$parameter] = $this->parameterTypeMapping[$dataType]; return true; } /** * Binds a array of values to bound parameters. * * @param array $values The values to bind to the statement * * @return boolean * * @since 2.0.0 */ private function bindValues(array $values) { $params = []; $types = str_repeat('s', \count($values)); if (!empty($this->parameterKeyMapping)) { foreach ($values as $key => &$value) { $params[$this->parameterKeyMapping[$key]] =& $value; } ksort($params); } else { foreach ($values as $key => &$value) { $params[] =& $value; } } array_unshift($params, $types); return \call_user_func_array([$this->statement, 'bind_param'], $params); } /** * Closes the cursor, enabling the statement to be executed again. * * @return void * * @since 2.0.0 */ public function closeCursor(): void { $this->statement->free_result(); $this->result = false; } /** * Fetches the SQLSTATE associated with the last operation on the statement handle. * * @return string * * @since 2.0.0 */ public function errorCode() { return $this->statement->errno; } /** * Fetches extended error information associated with the last operation on the statement handle. * * @return array * * @since 2.0.0 */ public function errorInfo() { return $this->statement->error; } /** * Executes a prepared statement * * @param array|null $parameters An array of values with as many elements as there are bound parameters in the SQL statement being executed. * * @return boolean * * @since 2.0.0 */ public function execute(?array $parameters = null) { if ($this->bindedValues !== null) { $params = []; $types = []; if (!empty($this->parameterKeyMapping)) { foreach ($this->bindedValues as $key => &$value) { $params[$this->parameterKeyMapping[$key]] =& $value; $types[$this->parameterKeyMapping[$key]] = $this->typesKeyMapping[$key]; } } else { foreach ($this->bindedValues as $key => &$value) { $params[] =& $value; $types[$key] = $this->typesKeyMapping[$key]; } } ksort($params); ksort($types); array_unshift($params, implode('', $types)); if (!\call_user_func_array([$this->statement, 'bind_param'], $params)) { throw new PrepareStatementFailureException($this->statement->error, $this->statement->errno); } } elseif ($parameters !== null) { if (!$this->bindValues($parameters)) { throw new PrepareStatementFailureException($this->statement->error, $this->statement->errno); } } if (!$this->statement->execute()) { throw new ExecutionFailureException($this->query, $this->statement->error, $this->statement->errno); } if ($this->columnNames === null) { $meta = $this->statement->result_metadata(); if ($meta !== false) { $columnNames = []; foreach ($meta->fetch_fields() as $col) { $columnNames[] = $col->name; } $meta->free(); $this->columnNames = $columnNames; } else { $this->columnNames = false; } } if ($this->columnNames !== false) { $this->statement->store_result(); $this->rowBindedValues = array_fill(0, \count($this->columnNames), null); $refs = []; foreach ($this->rowBindedValues as $key => &$value) { $refs[$key] =& $value; } if (!\call_user_func_array([$this->statement, 'bind_result'], $refs)) { throw new \RuntimeException($this->statement->error, $this->statement->errno); } } $this->result = true; return true; } /** * Fetches the next row from a result set * * @param integer|null $fetchStyle Controls how the next row will be returned to the caller. This value must be one of the * FetchMode constants, defaulting to value of FetchMode::MIXED. * @param integer $cursorOrientation For a StatementInterface object representing a scrollable cursor, this value determines which row * will be returned to the caller. This value must be one of the FetchOrientation constants, * defaulting to FetchOrientation::NEXT. * @param integer $cursorOffset For a StatementInterface object representing a scrollable cursor for which the cursorOrientation * parameter is set to FetchOrientation::ABS, this value specifies the absolute number of the row in * the result set that shall be fetched. For a StatementInterface object representing a scrollable * cursor for which the cursorOrientation parameter is set to FetchOrientation::REL, this value * specifies the row to fetch relative to the cursor position before `fetch()` was called. * * @return mixed The return value of this function on success depends on the fetch type. In all cases, boolean false is returned on failure. * * @since 2.0.0 */ public function fetch(?int $fetchStyle = null, int $cursorOrientation = FetchOrientation::NEXT, int $cursorOffset = 0) { if (!$this->result) { return false; } $fetchStyle = $fetchStyle ?: $this->defaultFetchStyle; if ($fetchStyle === FetchMode::COLUMN) { return $this->fetchColumn(); } $values = $this->fetchData(); if ($values === null) { return false; } if ($values === false) { throw new \RuntimeException($this->statement->error, $this->statement->errno); } switch ($fetchStyle) { case FetchMode::NUMERIC: return $values; case FetchMode::ASSOCIATIVE: return array_combine($this->columnNames, $values); case FetchMode::MIXED: $ret = array_combine($this->columnNames, $values); $ret += $values; return $ret; case FetchMode::STANDARD_OBJECT: return (object) array_combine($this->columnNames, $values); default: throw new \InvalidArgumentException("Unknown fetch type '{$fetchStyle}'"); } } /** * Returns a single column from the next row of a result set * * @param integer $columnIndex 0-indexed number of the column you wish to retrieve from the row. * If no value is supplied, the first column is retrieved. * * @return mixed Returns a single column from the next row of a result set or boolean false if there are no more rows. * * @since 2.0.0 */ public function fetchColumn($columnIndex = 0) { $row = $this->fetch(FetchMode::NUMERIC); if ($row === false) { return false; } return $row[$columnIndex] ?? null; } /** * Fetch the data from the statement. * * @return array|boolean * * @since 2.0.0 */ private function fetchData() { $return = $this->statement->fetch(); if ($return === true) { $values = []; foreach ($this->rowBindedValues as $v) { $values[] = $v; } return $values; } return $return; } /** * Returns the number of rows affected by the last SQL statement. * * @return integer * * @since 2.0.0 */ public function rowCount(): int { if ($this->columnNames === false) { return $this->statement->affected_rows; } return $this->statement->num_rows; } /** * Sets the fetch mode to use while iterating this statement. * * @param integer $fetchMode The fetch mode, must be one of the FetchMode constants. * @param mixed ...$args Optional mode-specific arguments. * * @return void * * @since 2.0.0 */ public function setFetchMode(int $fetchMode, ...$args): void { $this->defaultFetchStyle = $fetchMode; } }