Issues (43)

src/Driver/Pdo/AbstractPdoCommand.php (1 issue)

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Driver\Pdo;
6
7
use PDO;
8
use PDOException;
9
use PDOStatement;
10
use Psr\Log\LoggerAwareInterface;
11
use Psr\Log\LoggerAwareTrait;
12
use Psr\Log\LogLevel;
13
use Throwable;
14
use Yiisoft\Db\Command\AbstractCommand;
15
use Yiisoft\Db\Command\Param;
16
use Yiisoft\Db\Command\ParamInterface;
17
use Yiisoft\Db\Exception\ConvertException;
18
use Yiisoft\Db\Exception\Exception;
19
use Yiisoft\Db\Exception\InvalidParamException;
20
use Yiisoft\Db\Profiler\Context\CommandContext;
21
use Yiisoft\Db\Profiler\ProfilerAwareInterface;
22
use Yiisoft\Db\Profiler\ProfilerAwareTrait;
23
use Yiisoft\Db\Query\Data\DataReader;
24
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
25
26
/**
27
 * Represents a database command that can be executed using a PDO (PHP Data Object) database connection.
28
 *
29
 * It's an abstract class that provides a common interface for building and executing various types of statements
30
 * such as {@see cancel()}, {@see execute()}, {@see insert()}, {@see update()}, {@see delete()}, etc., using a PDO
31
 * connection.
32
 *
33
 * It also provides methods for binding parameter values and retrieving query results.
34
 */
35
abstract class AbstractPdoCommand extends AbstractCommand implements PdoCommandInterface, LoggerAwareInterface, ProfilerAwareInterface
36
{
37
    use LoggerAwareTrait;
38
    use ProfilerAwareTrait;
39
40
    /**
41
     * @var PDOStatement|null Represents a prepared statement and, after the statement is executed, an associated
42
     * result set.
43
     *
44
     * @link https://www.php.net/manual/en/class.pdostatement.php
45
     */
46
    protected PDOStatement|null $pdoStatement = null;
47
48
    public function __construct(protected PdoConnectionInterface $db)
49
    {
50
    }
51
52
    /**
53
     * This method mainly sets {@see pdoStatement} to be `null`.
54
     */
55
    public function cancel(): void
56
    {
57
        $this->pdoStatement = null;
58
    }
59
60
    public function getPdoStatement(): PDOStatement|null
61
    {
62
        return $this->pdoStatement;
63
    }
64
65
    public function bindParam(
66
        int|string $name,
67
        mixed &$value,
68
        int|null $dataType = null,
69
        int|null $length = null,
70
        mixed $driverOptions = null
71
    ): static {
72
        $this->prepare();
73
74
        if ($dataType === null) {
75
            $dataType = $this->db->getSchema()->getDataType($value);
76
        }
77
78
        if ($length === null) {
79
            $this->pdoStatement?->bindParam($name, $value, $dataType);
80
        } elseif ($driverOptions === null) {
81
            $this->pdoStatement?->bindParam($name, $value, $dataType, $length);
82
        } else {
83
            $this->pdoStatement?->bindParam($name, $value, $dataType, $length, $driverOptions);
84
        }
85
86
        return $this;
87
    }
88
89
    public function bindValue(int|string $name, mixed $value, int|null $dataType = null): static
90
    {
91
        if ($dataType === null) {
92
            $dataType = $this->db->getSchema()->getDataType($value);
93
        }
94
95
        $this->params[$name] = new Param($value, $dataType);
96
97
        return $this;
98
    }
99
100
    public function bindValues(array $values): static
101
    {
102
        if (empty($values)) {
103
            return $this;
104
        }
105
106
        /**
107
         * @psalm-var array<string, int>|ParamInterface|int $value
108
         */
109
        foreach ($values as $name => $value) {
110
            if ($value instanceof ParamInterface) {
111
                $this->params[$name] = $value;
112
            } else {
113
                $type = $this->db->getSchema()->getDataType($value);
114
                $this->params[$name] = new Param($value, $type);
115
            }
116
        }
117
118
        return $this;
119
    }
120
121
    public function prepare(bool|null $forRead = null): void
122
    {
123
        if (isset($this->pdoStatement)) {
124
            $this->bindPendingParams();
125
126
            return;
127
        }
128
129
        $sql = $this->getSql();
130
131
        /**
132
         * If SQL is empty, there will be {@see \ValueError} on prepare pdoStatement.
133
         *
134
         * @link https://php.watch/versions/8.0/ValueError
135
         */
136
        if ($sql === '') {
137
            return;
138
        }
139
140
        $pdo = $this->db->getActivePDO($sql, $forRead);
141
142
        try {
143
            $this->pdoStatement = $pdo?->prepare($sql);
144
            $this->bindPendingParams();
145
        } catch (PDOException $e) {
146
            $message = $e->getMessage() . "\nFailed to prepare SQL: $sql";
147
            /** @psalm-var array|null $errorInfo */
148
            $errorInfo = $e->errorInfo ?? null;
149
150
            throw new Exception($message, $errorInfo, $e);
151
        }
152
    }
153
154
    /**
155
     * Binds pending parameters registered via {@see bindValue()} and {@see bindValues()}.
156
     *
157
     * Note that this method requires an active {@see pdoStatement}.
158
     */
159
    protected function bindPendingParams(): void
160
    {
161
        foreach ($this->params as $name => $value) {
162
            $this->pdoStatement?->bindValue($name, $value->getValue(), $value->getType());
163
        }
164
    }
165
166
    protected function getQueryBuilder(): QueryBuilderInterface
167
    {
168
        return $this->db->getQueryBuilder();
169
    }
170
171
    protected function getQueryMode(int $queryMode): string
172
    {
173
        return match ($queryMode) {
174
            self::QUERY_MODE_EXECUTE => 'execute',
175
            self::QUERY_MODE_ROW => 'queryOne',
176
            self::QUERY_MODE_ALL => 'queryAll',
177
            self::QUERY_MODE_COLUMN => 'queryColumn',
178
            self::QUERY_MODE_CURSOR => 'query',
179
            self::QUERY_MODE_SCALAR => 'queryScalar',
180
            self::QUERY_MODE_ROW | self::QUERY_MODE_EXECUTE => 'insertWithReturningPks'
181
        };
182
    }
183
184
    /**
185
     * Executes a prepared statement.
186
     *
187
     * It's a wrapper around {@see PDOStatement::execute()} to support transactions and retry handlers.
188
     *
189
     * @param string|null $rawSql Deprecated. Use `null` value. Will be removed in version 2.0.0.
190
     *
191
     * @throws Exception
192
     * @throws Throwable
193
     */
194
    protected function internalExecute(string|null $rawSql): void
195
    {
196
        $attempt = 0;
197
198
        while (true) {
199
            try {
200
                if (
201
                    ++$attempt === 1
202
                    && $this->isolationLevel !== null
203
                    && $this->db->getTransaction() === null
204
                ) {
205
                    $this->db->transaction(
206
                        fn () => $this->internalExecute($rawSql),
207
                        $this->isolationLevel
208
                    );
209
                } else {
210
                    $this->pdoStatement?->execute();
211
                }
212
                break;
213
            } catch (PDOException $e) {
214
                $rawSql ??= $this->getRawSql();
215
                $e = (new ConvertException($e, $rawSql))->run();
216
217
                if ($this->retryHandler === null || !($this->retryHandler)($e, $attempt)) {
218
                    throw $e;
219
                }
220
            }
221
        }
222
    }
223
224
    /**
225
     * @throws InvalidParamException
226
     */
227
    protected function internalGetQueryResult(int $queryMode): mixed
228
    {
229
        if ($queryMode === self::QUERY_MODE_CURSOR) {
230
            return new DataReader($this);
231
        }
232
233
        if ($queryMode === self::QUERY_MODE_EXECUTE) {
234
            return $this->pdoStatement?->rowCount() ?? 0;
235
        }
236
237
        if ($this->is($queryMode, self::QUERY_MODE_ROW)) {
238
            /** @psalm-var array|false $result */
239
            $result = $this->pdoStatement?->fetch(PDO::FETCH_ASSOC);
240
        } elseif ($this->is($queryMode, self::QUERY_MODE_SCALAR)) {
241
            /** @psalm-var mixed $result */
242
            $result = $this->pdoStatement?->fetchColumn();
243
        } elseif ($this->is($queryMode, self::QUERY_MODE_COLUMN)) {
244
            /** @psalm-var mixed $result */
245
            $result = $this->pdoStatement?->fetchAll(PDO::FETCH_COLUMN);
246
        } elseif ($this->is($queryMode, self::QUERY_MODE_ALL)) {
247
            /** @psalm-var mixed $result */
248
            $result = $this->pdoStatement?->fetchAll(PDO::FETCH_ASSOC);
249
        } else {
250
            throw new InvalidParamException("Unknown query mode '$queryMode'");
251
        }
252
253
        $this->pdoStatement?->closeCursor();
254
255
        return $result;
256
    }
257
258
    /**
259
     * Logs the current database query if query logging is on and returns the profiling token if profiling is on.
260
     */
261
    protected function logQuery(string $rawSql, string $category): void
262
    {
263
        $this->logger?->log(LogLevel::INFO, $rawSql, [$category, 'type' => LogType::QUERY]);
264
    }
265
266
    protected function queryInternal(int $queryMode): mixed
267
    {
268
        $logCategory = self::class . '::' . $this->getQueryMode($queryMode);
269
270
        if ($this->logger !== null) {
271
            $rawSql = $this->getRawSql();
272
            $this->logQuery($rawSql, $logCategory);
273
        }
274
275
        $queryContext = new CommandContext(__METHOD__, $logCategory, $this->getSql(), $this->getParams());
276
277
        /**
278
         * @psalm-var string $rawSql
279
         * @psalm-suppress RedundantConditionGivenDocblockType
280
         * @psalm-suppress DocblockTypeContradiction
281
         */
282
        $this->profiler?->begin($rawSql ??= $this->getRawSql(), $queryContext);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $rawSql does not seem to be defined for all execution paths leading up to this point.
Loading history...
283
        try {
284
            /** @psalm-var mixed $result */
285
            $result = parent::queryInternal($queryMode);
286
        } catch (Throwable $e) {
287
            $this->profiler?->end($rawSql, $queryContext->setException($e));
288
            throw $e;
289
        }
290
        $this->profiler?->end($rawSql, $queryContext);
291
292
        return $result;
293
    }
294
295
    /**
296
     * Refreshes table schema, which was marked by {@see requireTableSchemaRefresh()}.
297
     */
298
    protected function refreshTableSchema(): void
299
    {
300
        if ($this->refreshTableName !== null) {
301
            $this->db->getSchema()->refreshTableSchema($this->refreshTableName);
302
        }
303
    }
304
}
305