├── LICENSE.md
├── composer.json
└── src
├── BetterWPDB.php
├── Exception
├── NoMatchingRowFound.php
└── QueryException.php
├── KeysetPagination
├── LeftOff.php
├── Lock.php
├── Query.php
└── ResultSet.php
├── MysqliFactory.php
├── QueryInfo.php
└── QueryLogger.php
/LICENSE.md:
--------------------------------------------------------------------------------
1 | #### Copyright (c) Calvin Alkan
2 |
3 | This software package is licensed under the terms of the GNU LGPLv3. See below for the license text.
4 |
5 | ### GNU LESSER GENERAL PUBLIC LICENSE
6 |
7 | Version 3, 29 June 2007
8 |
9 | Copyright (C) 2007 Free Software Foundation, Inc.
10 |
11 |
12 | Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
13 |
14 | This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU
15 | General Public License, supplemented by the additional permissions listed below.
16 |
17 | #### 0. Additional Definitions.
18 |
19 | As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to
20 | version 3 of the GNU General Public License.
21 |
22 | "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined
23 | below.
24 |
25 | An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on
26 | the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by
27 | the Library.
28 |
29 | A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of
30 | the Library with which the Combined Work was made is also called the "Linked Version".
31 |
32 | The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding
33 | any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not
34 | on the Linked Version.
35 |
36 | The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application,
37 | including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the
38 | System Libraries of the Combined Work.
39 |
40 | #### 1. Exception to Section 3 of the GNU GPL.
41 |
42 | You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.
43 |
44 | #### 2. Conveying Modified Versions.
45 |
46 | If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied
47 | by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may
48 | convey a copy of the modified version:
49 |
50 | - a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not
51 | supply the function or data, the facility still operates, and performs whatever part of its purpose remains
52 | meaningful, or
53 | - b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy.
54 |
55 | #### 3. Object Code Incorporating Material from Library Header Files.
56 |
57 | The object code form of an Application may incorporate material from a header file that is part of the Library. You may
58 | convey such object code under terms of your choice, provided that, if the incorporated material is not limited to
59 | numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates
60 | (ten or fewer lines in length), you do both of the following:
61 |
62 | - a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its
63 | use are covered by this License.
64 | - b) Accompany the object code with a copy of the GNU GPL and this license document.
65 |
66 | #### 4. Combined Works.
67 |
68 | You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification
69 | of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications,
70 | if you also do each of the following:
71 |
72 | - a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and
73 | its use are covered by this License.
74 | - b) Accompany the Combined Work with a copy of the GNU GPL and this license document.
75 | - c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library
76 | among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
77 | - d) Do one of the following:
78 | -
79 | 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application
80 | Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application
81 | with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by
82 | section 6 of the GNU GPL for conveying Corresponding Source.
83 | -
84 | 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a)
85 | uses at run time a copy of the Library already present on the user's computer system, and (b) will operate
86 | properly with a modified version of the Library that is interface-compatible with the Linked Version.
87 | - e) Provide Installation Information, but only if you would otherwise be required to provide such information under
88 | section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified
89 | version of the Combined Work produced by recombining or relinking the Application with a modified version of the
90 | Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source
91 | and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner
92 | specified by section 6 of the GNU GPL for conveying Corresponding Source.)
93 |
94 | #### 5. Combined Libraries.
95 |
96 | You may place library facilities that are a work based on the Library side by side in a single library together with
97 | other library facilities that are not Applications and are not covered by this License, and convey such a combined
98 | library under terms of your choice, if you do both of the following:
99 |
100 | - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library
101 | facilities, conveyed under the terms of this License.
102 | - b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining
103 | where to find the accompanying uncombined form of the same work.
104 |
105 | #### 6. Revised Versions of the GNU Lesser General Public License.
106 |
107 | The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time
108 | to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new
109 | problems or concerns.
110 |
111 | Each version is given a distinguishing version number. If the Library as you received it specifies that a certain
112 | numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of
113 | following the terms and conditions either of that published version or of any later version published by the Free
114 | Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General
115 | Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software
116 | Foundation.
117 |
118 | If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General
119 | Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for
120 | you to choose that version for the Library.
121 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "snicco/better-wpdb",
3 | "description": "Keeps you safe and sane when working with custom tables in WordPress.",
4 | "authors": [
5 | {
6 | "name": "Calvin Alkan",
7 | "email": "calvin@snicco.de"
8 | }
9 | ],
10 | "require": {
11 | "php": "^7.4|^8.0"
12 | },
13 | "autoload": {
14 | "psr-4": {
15 | "Snicco\\Component\\BetterWPDB\\": "src"
16 | }
17 | },
18 | "autoload-dev": {
19 | "psr-4": {
20 | "Snicco\\Component\\BetterWPDB\\Tests\\": "tests"
21 | }
22 | },
23 | "conflict": {
24 | "snicco/http-routing": "<1.10.1",
25 | "snicco/testable-clock": "<1.10.1",
26 | "snicco/signed-url": "<1.10.1",
27 | "snicco/templating": "<1.10.1",
28 | "snicco/psr7-error-handler": "<1.10.1",
29 | "snicco/str-arr": "<1.10.1",
30 | "snicco/better-wp-hooks": "<1.10.1",
31 | "snicco/event-dispatcher": "<1.10.1",
32 | "snicco/eloquent": "<1.10.1",
33 | "snicco/better-wp-mail": "<1.10.1",
34 | "snicco/better-wp-cache": "<1.10.1",
35 | "snicco/session": "<1.10.1",
36 | "snicco/better-wp-api": "<1.10.1",
37 | "snicco/kernel": "<1.10.1",
38 | "snicco/session-wp-bridge": "<1.10.1",
39 | "snicco/session-psr16-bridge": "<1.10.1",
40 | "snicco/blade-bridge": "<1.10.1",
41 | "snicco/signed-url-psr16-bridge": "<1.10.1",
42 | "snicco/illuminate-container-bridge": "<1.10.1",
43 | "snicco/signed-url-psr15-bridge": "<1.10.1",
44 | "snicco/pimple-bridge": "<1.10.1",
45 | "snicco/no-robots-middleware": "<1.10.1",
46 | "snicco/open-redirect-protection-middleware": "<1.10.1",
47 | "snicco/wp-capapility-middleware": "<1.0.0",
48 | "snicco/wp-nonce-middleware": "<1.10.1",
49 | "snicco/payload-middleware": "<1.10.1",
50 | "snicco/default-headers-middleware": "<1.10.1",
51 | "snicco/wp-auth-only-middleware": "<1.10.1",
52 | "snicco/content-negotiation-middleware": "<1.10.1",
53 | "snicco/redirect-middleware": "<1.10.1",
54 | "snicco/must-match-route-middleware": "<1.10.1",
55 | "snicco/method-override-middleware": "<1.10.1",
56 | "snicco/share-cookies-middleware": "<1.10.1",
57 | "snicco/https-only-middleware": "<1.10.1",
58 | "snicco/guests-only-middleware": "<1.0.0",
59 | "snicco/trailing-slash-middleware": "<1.10.1",
60 | "snicco/testing-bundle": "<1.10.1",
61 | "snicco/http-routing-bundle": "<1.10.1",
62 | "snicco/debug-bundle": "<1.10.1",
63 | "snicco/templating-bundle": "<1.10.1",
64 | "snicco/better-wpdb-bundle": "<1.10.1",
65 | "snicco/better-wp-hooks-bundle": "<1.10.1",
66 | "snicco/blade-bundle": "<1.10.1",
67 | "snicco/better-wp-mail-bundle": "<1.10.1",
68 | "snicco/better-wp-cache-bundle": "<1.10.1",
69 | "snicco/session-bundle": "<1.10.1",
70 | "snicco/encryption-bundle": "<1.10.1",
71 | "snicco/wp-guests-only-middleware": "<1.10.1",
72 | "snicco/wp-capability-middleware": "<1.10.1",
73 | "snicco/better-wp-mail-testing": "<1.10.1",
74 | "snicco/session-testing": "<1.10.1",
75 | "snicco/kernel-testing": "<1.10.1",
76 | "snicco/signed-url-testing": "<1.10.1",
77 | "snicco/http-routing-testing": "<1.10.1",
78 | "snicco/event-dispatcher-testing": "<1.10.1",
79 | "snicco/better-wp-cli": "<1.10.1",
80 | "snicco/signed-url-wp-bridge": "<1.10.1",
81 | "snicco/minimal-logger": "<1.10.1",
82 | "snicco/better-wp-cli-testing": "<1.10.1"
83 | },
84 | "require-dev": {
85 | "codeception/codeception": "^4.1.29",
86 | "lucatume/wp-browser": "~3.1.4"
87 | },
88 | "license": "LGPL-3.0-only",
89 | "minimum-stability": "dev",
90 | "prefer-stable": true
91 | }
92 |
--------------------------------------------------------------------------------
/src/BetterWPDB.php:
--------------------------------------------------------------------------------
1 | mysqli = $mysqli;
65 | $this->logger = $logger
66 | ?: new class() implements QueryLogger {
67 | public function log(QueryInfo $info): void
68 | {
69 | // do nothing
70 | }
71 | };
72 | }
73 |
74 | /**
75 | * @throws ReflectionException
76 | */
77 | public static function fromWpdb(?QueryLogger $logger = null): self
78 | {
79 | return new self(MysqliFactory::fromWpdbConnection(), $logger);
80 | }
81 |
82 | /**
83 | * @param array $bindings
84 | * @param bool $auto_reset_error_handling This needs to be set to false ONLY if you are running SELECT queries.
85 | * In that case error handling needs to be reset by manually
86 | * calling
87 | * {@see BetterWPDB::restoreErrorHandling()}. All other
88 | * methods handle this automatically, so unless you have a
89 | * good reason not to you should stick to one of the many select methods.
90 | *
91 | * @throws InvalidArgumentException
92 | * @throws QueryException
93 | */
94 | public function preparedQuery(
95 | string $sql,
96 | array $bindings = [],
97 | bool $auto_reset_error_handling = true
98 | ): mysqli_stmt {
99 | $this->assertStringNotEmpty($sql);
100 |
101 | return $this->runWithErrorHandling(function () use ($sql, $bindings): mysqli_stmt {
102 | $bindings = $this->convertBindings($bindings);
103 |
104 | try {
105 | $stmt = $this->createPreparedStatement($sql, $bindings);
106 | } catch (mysqli_sql_exception $e) {
107 | throw QueryException::fromMysqliE($sql, $bindings, $e);
108 | }
109 |
110 | $start = microtime(true);
111 |
112 | try {
113 | $stmt->execute();
114 | } catch (mysqli_sql_exception $e) {
115 | throw QueryException::fromMysqliE($sql, $bindings, $e);
116 | }
117 |
118 | $end = microtime(true);
119 | $this->log(new QueryInfo($start, $end, $sql, $bindings));
120 |
121 | return $stmt;
122 | }, $auto_reset_error_handling);
123 | }
124 |
125 | /**
126 | * @throws QueryException
127 | *
128 | * @return mysqli_result|true {@see mysqli::query()}
129 | */
130 | public function unprepared(string $sql, bool $auto_reset_error_handling = true)
131 | {
132 | $this->assertStringNotEmpty($sql);
133 |
134 | return $this->runWithErrorHandling(function () use ($sql) {
135 | $start = microtime(true);
136 |
137 | try {
138 | /** @var mysqli_result|true $res */
139 | $res = $this->mysqli->query($sql);
140 | } catch (mysqli_sql_exception $e) {
141 | throw QueryException::fromMysqliE($sql, [], $e);
142 | }
143 |
144 | $end = microtime(true);
145 | $this->log(new QueryInfo($start, $end, $sql, []));
146 |
147 | return $res;
148 | }, $auto_reset_error_handling);
149 | }
150 |
151 | /**
152 | * Runs the callback inside a database transaction that automatically
153 | * commits on success and rolls back if any errors happen.
154 | *
155 | * @template T
156 | *
157 | * @param Closure(BetterWPDB):T $callback
158 | *
159 | * @throws QueryException
160 | *
161 | * @psalm-return T
162 | */
163 | public function transactional(Closure $callback)
164 | {
165 | if ($this->in_transaction) {
166 | throw new LogicException('Nested transactions are currently not supported.');
167 | }
168 |
169 | return $this->runWithErrorHandling(function () use ($callback) {
170 | try {
171 | $this->in_transaction = true;
172 |
173 | $start = microtime(true);
174 |
175 | try {
176 | $this->mysqli->begin_transaction();
177 | } // @codeCoverageIgnoreStart
178 | catch (mysqli_sql_exception $e) {
179 | throw QueryException::fromMysqliE('START TRANSACTION', [], $e);
180 | }
181 | // @codeCoverageIgnoreEnd
182 | $end = microtime(true);
183 |
184 | $this->log(new QueryInfo($start, $end, 'START TRANSACTION', []));
185 |
186 | $res = $callback($this);
187 |
188 | $start = microtime(true);
189 |
190 | try {
191 | $this->mysqli->commit();
192 | } // @codeCoverageIgnoreStart
193 | catch (mysqli_sql_exception $e) {
194 | throw QueryException::fromMysqliE('COMMIT', [], $e);
195 | }
196 | // @codeCoverageIgnoreEnd
197 | $end = microtime(true);
198 |
199 | $this->log(new QueryInfo($start, $end, 'COMMIT', []));
200 |
201 | $this->in_transaction = false;
202 |
203 | return $res;
204 | } catch (Throwable $e) {
205 | $this->mysqli->rollback();
206 | $this->in_transaction = false;
207 |
208 | throw $e;
209 | }
210 | });
211 | }
212 |
213 | /**
214 | * @param int|non-empty-array|non-empty-string $primary_key
215 | * @param non-empty-array $changes !!! IMPORTANT !!!
216 | * Keys of $data
217 | * MUST never be
218 | * user provided
219 | *
220 | * @throws InvalidArgumentException
221 | * @throws QueryException
222 | */
223 | public function updateByPrimary(string $table, $primary_key, array $changes): int
224 | {
225 | /** @psalm-suppress DocblockTypeContradiction */
226 | if ('' === $primary_key) {
227 | throw new InvalidArgumentException('$primary_key can not be an empty-string.');
228 | }
229 |
230 | $primary_key = is_array($primary_key)
231 | ? $primary_key
232 | : [
233 | 'id' => $primary_key,
234 | ];
235 |
236 | return $this->update($table, $primary_key, $changes);
237 | }
238 |
239 | /**
240 | * @param non-empty-array $conditions
241 | * @param non-empty-array $changes !!! IMPORTANT !!!
242 | * Keys of $data MUST never be user provided
243 | *
244 | * @throws InvalidArgumentException
245 | * @throws QueryException
246 | */
247 | public function update(string $table, array $conditions, array $changes): int
248 | {
249 | $this->assertStringNotEmpty($table);
250 |
251 | $this->validateProvidedColumnNames(array_keys($conditions));
252 | $this->validateProvidedColumnNames(array_keys($changes));
253 |
254 | $table = $this->escIdentifier($table);
255 | $sql = sprintf('update %s set ', $table);
256 |
257 | $updates = [];
258 | $bindings = [];
259 |
260 | foreach ($changes as $col_name => $value) {
261 | $updates[] = $this->escIdentifier($col_name) . ' = ?';
262 | $bindings[] = $value;
263 | }
264 |
265 | [$wheres, $where_bindings] = $this->buildWhereArray($conditions);
266 |
267 | $sql .= implode(', ', $updates);
268 | $sql .= ' where ' . implode(' and ', $wheres);
269 |
270 | $stmt = $this->preparedQuery($sql, [...$bindings, ...$where_bindings]);
271 |
272 | return $stmt->affected_rows;
273 | }
274 |
275 | /**
276 | * @param non-empty-array $conditions
277 | *
278 | * @throws InvalidArgumentException
279 | * @throws QueryException
280 | *
281 | * @return int The number of deleted records
282 | */
283 | public function delete(string $table, array $conditions): int
284 | {
285 | $this->assertStringNotEmpty($table);
286 |
287 | $table = $this->escIdentifier($table);
288 | $sql = sprintf('delete from %s where ', $table);
289 |
290 | [$wheres, $bindings] = $this->buildWhereArray($conditions);
291 |
292 | $sql .= implode(' and ', $wheres);
293 |
294 | $stmt = $this->preparedQuery($sql, $bindings);
295 |
296 | return $stmt->affected_rows;
297 | }
298 |
299 | /**
300 | * @param array $bindings
301 | *
302 | * @throws InvalidArgumentException
303 | * @throws QueryException
304 | */
305 | public function select(string $sql, array $bindings): mysqli_result
306 | {
307 | try {
308 | $stmt = $this->preparedQuery($sql, $bindings, false);
309 |
310 | return $stmt->get_result();
311 | } finally {
312 | $this->restoreErrorHandling();
313 | }
314 | }
315 |
316 | /**
317 | * Returns the entire result set as associative array. This method is
318 | * preferred for small result sets. For large result sets this method will
319 | * cause memory issues, and it's better to use.
320 | *
321 | * {@see BetterWPDB::selectAll()}.
322 | *
323 | * @param array $bindings
324 | *
325 | * @throws InvalidArgumentException
326 | * @throws QueryException
327 | *
328 | * @return list>
329 | */
330 | public function selectAll(string $sql, array $bindings): array
331 | {
332 | $val = $this->select($sql, $bindings)
333 | ->fetch_all(MYSQLI_ASSOC);
334 | /**
335 | * @var array> $val
336 | */
337 | return array_values($val);
338 | }
339 |
340 | /**
341 | * @param array $bindings
342 | *
343 | * @throws InvalidArgumentException
344 | * @throws NoMatchingRowFound
345 | * @throws QueryException
346 | *
347 | * @return array Booleans are returned as (int) 1 or (int) 0
348 | */
349 | public function selectRow(string $sql, array $bindings): array
350 | {
351 | $stmt = $this->select($sql, $bindings);
352 |
353 | $res = $stmt->fetch_assoc();
354 |
355 | if (! is_array($res)) {
356 | throw new NoMatchingRowFound('No matching row found', $sql, $bindings);
357 | }
358 |
359 | /**
360 | * @var array $res
361 | */
362 | return $res;
363 | }
364 |
365 | /**
366 | * @param array $bindings
367 | *
368 | * @throws InvalidArgumentException
369 | * @throws NoMatchingRowFound
370 | * @throws QueryException
371 | *
372 | * @return mixed
373 | */
374 | public function selectValue(string $sql, array $bindings)
375 | {
376 | $res = $this->selectRow($sql, $bindings);
377 |
378 | return array_values($res)[0] ?? null;
379 | }
380 |
381 | /**
382 | * @param non-empty-array $conditions
383 | *
384 | * @throws InvalidArgumentException
385 | * @throws QueryException
386 | */
387 | public function exists(string $table, array $conditions): bool
388 | {
389 | $this->assertStringNotEmpty($table);
390 |
391 | $this->validateProvidedColumnNames(array_keys($conditions));
392 |
393 | $table = $this->escIdentifier($table);
394 | $sql = sprintf('select count(1) from %s where ', $table);
395 |
396 | $bindings = [];
397 | $wheres = [];
398 |
399 | foreach ($conditions as $col_name => $value) {
400 | $col_name = $this->escIdentifier($col_name);
401 | if (null === $value) {
402 | $wheres[] = sprintf('%s is null', $col_name);
403 | } else {
404 | $wheres[] = sprintf('%s = ?', $col_name);
405 | $bindings[] = $value;
406 | }
407 | }
408 |
409 | $sql .= implode(' and ', $wheres) . ' limit 1';
410 |
411 | try {
412 | $stmt = $this->preparedQuery($sql, $bindings, false);
413 |
414 | $stmt->bind_result($found);
415 | $stmt->fetch();
416 | } finally {
417 | if (isset($stmt)) {
418 | $stmt->close();
419 | }
420 | $this->restoreErrorHandling();
421 | }
422 |
423 | return (int) $found > 0;
424 | }
425 |
426 | /**
427 | * This method should be used if you want to iterate over a big number of
428 | * records.
429 | *
430 | * @param array $bindings
431 | *
432 | * @throws InvalidArgumentException
433 | * @throws QueryException
434 | *
435 | * @return Generator>
436 | */
437 | public function selectLazy(string $sql, array $bindings): Generator
438 | {
439 | try {
440 | $stmt = $this->preparedQuery($sql, $bindings, false);
441 |
442 | $meta = $stmt->result_metadata();
443 |
444 | $row = [];
445 | $references = [];
446 |
447 | foreach ($meta->fetch_fields() as $field) {
448 | /**
449 | * {@see mysqli_stmt::bind_result()} Can only accept variables
450 | * passed by reference. But since we don't know which columns
451 | * are selected at runtime the only way we can achieve
452 | * this is by using the trick below:.
453 | *
454 | * We need two arrays. One for storing the columns and one
455 | * to bind the values by reference. We can't use one array that
456 | * both holds the column names as keys and the values by reference
457 | * since that will not work on PHP8+ where bind_result will
458 | * fail because it interprets array keys as named arguments
459 | * in combination with call_user_func_array.
460 | *
461 | * @see https://www.php.net/manual/en/mysqli-stmt.fetch.php
462 | *
463 | * @psalm-suppress MixedArrayOffset
464 | */
465 | $row[$field->name] = null;
466 | $references[] = &$row[$field->name];
467 | }
468 |
469 | call_user_func_array([$stmt, 'bind_result'], $references);
470 |
471 | /**
472 | * @var array $row
473 | */
474 | while ($stmt->fetch()) {
475 | yield $row;
476 | }
477 | } finally {
478 | // This will run once all rows have been iterated over.
479 | if (isset($stmt)) {
480 | $stmt->close();
481 | }
482 | $this->restoreErrorHandling();
483 | }
484 | }
485 |
486 | /**
487 | * This method allows batch processing of a large number of records. Each
488 | * batch of records is passed to the provided callable for processing.
489 | * Optionally, each batch can be executed inside a database transaction if
490 | * a.
491 | *
492 | * {@see Lock} object is provided.
493 | *
494 | * A typical scenario for using this method is fetching records from the
495 | * database and updating them based on logic that can only be performed in
496 | * PHP.
497 | *
498 | * @template T
499 | *
500 | * @param callable(list>):T $process_batch
501 | *
502 | * @psalm-return list
503 | */
504 | public function batchProcess(Query $query, callable $process_batch, ?Lock $lock = null): array
505 | {
506 | /*
507 | * We create a wrapping closure around our "unit of work"
508 | * so that we can fetch batches inside transactions if needed.
509 | */
510 | $wrapper = (null === $lock)
511 | ? static fn (Closure $do): array => (array) $do()
512 | : [$this, 'transactional'];
513 |
514 | $left_off = null;
515 |
516 | $fetch_batch = function () use ($query, $process_batch, $lock, &$left_off): array {
517 | /** @var LeftOff|null $left_off */
518 | $result_set = $this->internalPaginate($query, $left_off, $lock);
519 |
520 | if (! count($result_set)) {
521 | return [false, null];
522 | }
523 |
524 | // Pass the batch of records to the user defined callback.
525 | $user_return_value = ($process_batch)($result_set->records);
526 |
527 | $left_off = $result_set->left_off;
528 |
529 | return [true, $user_return_value];
530 | };
531 |
532 | $return_values = [];
533 |
534 | do {
535 | [$has_more, $user_return_value] = ($wrapper)($fetch_batch);
536 |
537 | if ($has_more) {
538 | /** @var T $user_return_value */
539 | $return_values[] = $user_return_value;
540 | }
541 | } while ($has_more);
542 |
543 | return $return_values;
544 | }
545 |
546 | public function keysetPaginate(Query $cursor, ?LeftOff $left_off = null): ResultSet
547 | {
548 | return $this->internalPaginate($cursor, $left_off);
549 | }
550 |
551 | /**
552 | * @param non-empty-array $data !!! IMPORTANT !!!
553 | * Keys of $data MUST never be user provided
554 | *
555 | * @throws InvalidArgumentException
556 | * @throws QueryException
557 | */
558 | public function insert(string $table, array $data): mysqli_stmt
559 | {
560 | $this->assertStringNotEmpty($table);
561 |
562 | $column_names = array_keys($data);
563 | $this->validateProvidedColumnNames($column_names);
564 |
565 | $sql = $this->buildInsertSql($table, $column_names);
566 |
567 | return $this->preparedQuery($sql, array_values($data));
568 | }
569 |
570 | /**
571 | * Runs a bulk insert of records in a transaction. If any record can't be
572 | * inserted the entire transaction will be rolled back.
573 | *
574 | * @param iterable> $records !!! IMPORTANT !!!
575 | * Keys of $data MUST never be user provided
576 | *
577 | * @throws InvalidArgumentException
578 | * @throws QueryException
579 | *
580 | * @return int The number of inserted records
581 | */
582 | public function bulkInsert(string $table, iterable $records): int
583 | {
584 | $this->assertStringNotEmpty($table);
585 |
586 | return $this->transactional(function () use ($table, $records): int {
587 | $stmt = null;
588 | $sql = null;
589 | $expected_types = null;
590 | $inserted = 0;
591 |
592 | try {
593 | foreach ($records as $record) {
594 | if (empty($record)) {
595 | throw new InvalidArgumentException('Each record has to be a non-empty-array.');
596 | }
597 |
598 | $col_names = array_keys($record);
599 | $this->validateProvidedColumnNames($col_names);
600 |
601 | // only create the insert sql once.
602 | $sql ??= $this->buildInsertSql($table, $col_names);
603 |
604 | // only create one prepared statement
605 | $stmt ??= $this->mysqli->prepare($sql);
606 |
607 | $bindings = $this->convertBindings($record);
608 |
609 | // Retrieve the expected types from the first record.
610 | if (null === $expected_types) {
611 | $expected_types = (string) $this->paramTypes($bindings);
612 | }
613 |
614 | $record_types = (string) $this->paramTypes($bindings);
615 | if ($expected_types !== $record_types) {
616 | throw new InvalidArgumentException(
617 | sprintf(
618 | "Records are not of consistent type.\nExpected: [%s] and got [%s] for record %d.",
619 | rtrim(
620 | strtr($expected_types, [
621 | 's' => 'string,',
622 | 'd' => 'double,',
623 | 'i' => 'integer,',
624 | ]),
625 | ','
626 | ),
627 | rtrim(
628 | strtr($record_types, [
629 | 's' => 'string,',
630 | 'd' => 'double,',
631 | 'i' => 'integer,',
632 | ]),
633 | ','
634 | ),
635 | $inserted + 1
636 | )
637 | );
638 | }
639 |
640 | $stmt->bind_param($record_types, ...$bindings);
641 |
642 | $start = microtime(true);
643 | $stmt->execute();
644 | $end = microtime(true);
645 |
646 | /** @var array $bindings */
647 | $this->log(new QueryInfo($start, $end, $sql, $bindings));
648 |
649 | $inserted += $stmt->affected_rows;
650 | }
651 |
652 | return $inserted;
653 | } catch (mysqli_sql_exception $e) {
654 | throw QueryException::fromMysqliE($sql ?? '', [], $e);
655 | }
656 | });
657 | }
658 |
659 | public function restoreErrorHandling(): void
660 | {
661 | if (! $this->is_handling_errors) {
662 | return;
663 | }
664 |
665 | if (! isset($this->original_sql_mode)) {
666 | $this->original_sql_mode = $this->queryOriginalSqlMode();
667 | }
668 |
669 | // Turn back to previous error reporting so that shitty wpdb doesn't break.
670 | $this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 0);
671 | mysqli_report(MYSQLI_REPORT_OFF);
672 | $this->mysqli->query(sprintf("SET SESSION sql_mode='%s'", $this->original_sql_mode), );
673 |
674 | if ($this->mysqli->error) {
675 | trigger_error(
676 | "Could not restore error handling. This probably happened because you used preparedQuery() with a select statement.\nError: "
677 | . $this->mysqli->error,
678 | );
679 | }
680 |
681 | $this->is_handling_errors = false;
682 | }
683 |
684 | private function internalPaginate(
685 | Query $query,
686 | ?LeftOff $left_off = null,
687 | ?Lock $lock = null
688 | ): ResultSet {
689 | [$sql, $bindings] = $query->buildPlaceholderSQLAndBindings($left_off);
690 |
691 | // If a lock type is defined we need to
692 | // append it to the SQL query.
693 | // Transaction control is the responsibility of the caller.
694 | if ($lock) {
695 | $sql .= $lock->type;
696 | }
697 |
698 | $batch = $this->selectAll($sql, $bindings);
699 |
700 | return $query->createResult($batch);
701 | }
702 |
703 | /**
704 | * @template T
705 | *
706 | * @param Closure():T $run_query
707 | *
708 | * @psalm-return T
709 | */
710 | private function runWithErrorHandling(Closure $run_query, bool $auto_restore_error_handling = true)
711 | {
712 | if ($this->is_handling_errors) {
713 | return $run_query();
714 | }
715 |
716 | if (! isset($this->original_sql_mode)) {
717 | $this->original_sql_mode = $this->queryOriginalSqlMode();
718 | }
719 |
720 | // Turn on error reporting
721 | $this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 1);
722 | mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
723 | if (! $this->mysqli->query("SET SESSION sql_mode='TRADITIONAL'")) {
724 | // @codeCoverageIgnoreStart
725 | throw new RuntimeException('Could not set mysql error reporting to traditional.');
726 | // @codeCoverageIgnoreEnd
727 | }
728 |
729 | $this->is_handling_errors = true;
730 |
731 | try {
732 | return $run_query();
733 | } finally {
734 | if ($auto_restore_error_handling) {
735 | $this->restoreErrorHandling();
736 | }
737 | }
738 | }
739 |
740 | private function queryOriginalSqlMode(): string
741 | {
742 | $stmt = $this->mysqli->query('SELECT @@SESSION.sql_mode');
743 | if (! $stmt instanceof mysqli_result) {
744 | // @codeCoverageIgnoreStart
745 | throw new RuntimeException('Could not determine current mysqli mode.');
746 | // @codeCoverageIgnoreEnd
747 | }
748 |
749 | $res = $stmt->fetch_row();
750 | if (! is_array($res) || ! isset($res[0]) || ! is_string($res[0])) {
751 | // @codeCoverageIgnoreStart
752 | throw new RuntimeException('Could not determine current mysqli mode.');
753 | // @codeCoverageIgnoreEnd
754 | }
755 |
756 | return $res[0];
757 | }
758 |
759 | /**
760 | * @param non-empty-string $table
761 | * @param non-empty-string[] $column_names
762 | *
763 | * @psalm-return non-empty-string
764 | * @psalm-suppress MoreSpecificReturnType
765 | */
766 | private function buildInsertSql(string $table, array $column_names): string
767 | {
768 | $column_names = array_map(fn ($column_name): string => $this->escIdentifier($column_name), $column_names);
769 | $columns = implode(',', $column_names);
770 | $table = $this->escIdentifier($table);
771 | $placeholders = str_repeat('?,', count($column_names) - 1) . '?';
772 |
773 | /** @psalm-suppress LessSpecificReturnStatement */
774 | return sprintf('insert into %s (%s) values (%s)', $table, $columns, $placeholders);
775 | }
776 |
777 | /**
778 | * @param non-empty-string $sql
779 | * @param list $bindings
780 | */
781 | private function createPreparedStatement(string $sql, array $bindings): mysqli_stmt
782 | {
783 | /** @var mysqli_stmt $stmt */
784 | $stmt = $this->mysqli->prepare($sql);
785 |
786 | $types = $this->paramTypes($bindings);
787 |
788 | if ($types) {
789 | $stmt->bind_param($types, ...$bindings);
790 | }
791 |
792 | return $stmt;
793 | }
794 |
795 | /**
796 | * @param non-empty-string $identifier
797 | */
798 | private function escIdentifier(string $identifier): string
799 | {
800 | return '`' . str_replace('`', '``', $identifier) . '`';
801 | }
802 |
803 | /**
804 | * @param array $bindings
805 | */
806 | private function paramTypes(array $bindings): ?string
807 | {
808 | $types = '';
809 | foreach ($bindings as $binding) {
810 | if (is_float($binding)) {
811 | $types .= 'd';
812 | } elseif (is_int($binding)) {
813 | $types .= 'i';
814 | } else {
815 | $types .= 's';
816 | }
817 | }
818 |
819 | return empty($types) ? null : $types;
820 | }
821 |
822 | private function log(QueryInfo $query_info): void
823 | {
824 | $this->logger->log($query_info);
825 | }
826 |
827 | /**
828 | * @return list
829 | */
830 | private function convertBindings(array $bindings): array
831 | {
832 | $b = [];
833 |
834 | foreach ($bindings as $binding) {
835 | if (! is_scalar($binding) && null !== $binding) {
836 | throw new InvalidArgumentException('All bindings have to be of type scalar or null.');
837 | }
838 |
839 | if (is_bool($binding)) {
840 | $binding = $binding ? 1 : 0;
841 | }
842 |
843 | $b[] = $binding;
844 | }
845 |
846 | return $b;
847 | }
848 |
849 | /**
850 | * @param array $conditions
851 | *
852 | * @return array{0: non-empty-list, 1: list}
853 | */
854 | private function buildWhereArray(array $conditions): array
855 | {
856 | if (empty($conditions)) {
857 | throw new InvalidArgumentException('Column names can not be an empty array.');
858 | }
859 |
860 | $wheres = [];
861 | $bindings = [];
862 | foreach ($conditions as $col_name => $value) {
863 | if (! is_string($col_name) || '' === $col_name) {
864 | throw new InvalidArgumentException('A column name must be a non-empty-string.');
865 | }
866 |
867 | $col_name = $this->escIdentifier($col_name);
868 | if (null === $value) {
869 | $wheres[] = sprintf('%s is null', $col_name);
870 | } else {
871 | $wheres[] = sprintf('%s = ?', $col_name);
872 | $bindings[] = $value;
873 | }
874 | }
875 |
876 | return [$wheres, $bindings];
877 | }
878 |
879 | /**
880 | * @psalm-assert non-empty-string[] $data
881 | */
882 | private function validateProvidedColumnNames(array $data): void
883 | {
884 | if (empty($data)) {
885 | throw new InvalidArgumentException('Column names can not be an empty array.');
886 | }
887 |
888 | foreach ($data as $name) {
889 | if (! is_string($name) || '' === $name) {
890 | throw new InvalidArgumentException('All column names must be a non-empty-strings.');
891 | }
892 | }
893 | }
894 |
895 | /**
896 | * @psalm-assert non-empty-string $string
897 | */
898 | private function assertStringNotEmpty(string $string): void
899 | {
900 | if ('' === $string) {
901 | throw new InvalidArgumentException('Expected a non-empty-string.');
902 | }
903 | }
904 | }
905 |
--------------------------------------------------------------------------------
/src/Exception/NoMatchingRowFound.php:
--------------------------------------------------------------------------------
1 | $bindings
19 | */
20 | public function __construct(string $message, string $sql, array $bindings, ?Throwable $prev = null)
21 | {
22 | $message .= "\nQuery: [{$sql}]";
23 |
24 | $bindings = array_map(function ($binding): string {
25 | if (null === $binding) {
26 | return 'null';
27 | }
28 |
29 | if (! is_string($binding)) {
30 | return (string) $binding;
31 | }
32 |
33 | return sprintf("'%s'", $binding);
34 | }, $bindings);
35 |
36 | $message .= "\nBindings: [" . implode(', ', $bindings) . ']';
37 |
38 | parent::__construct($message, (null !== $prev) ? (int) $prev->getCode() : 0, $prev);
39 | }
40 |
41 | /**
42 | * @param array $bindings
43 | *
44 | * @interal
45 | */
46 | public static function fromMysqliE(string $sql, array $bindings, mysqli_sql_exception $e): self
47 | {
48 | return new self($e->getMessage(), $sql, $bindings, $e);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/KeysetPagination/LeftOff.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | public array $last_included_record_sorting_values;
16 |
17 | /**
18 | * @param array $last_included_record_sorting_values
19 | */
20 | public function __construct(array $last_included_record_sorting_values)
21 | {
22 | $this->last_included_record_sorting_values = $last_included_record_sorting_values;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/KeysetPagination/Lock.php:
--------------------------------------------------------------------------------
1 | type = $type;
27 | }
28 |
29 | public static function forRead(): self
30 | {
31 | // "for share" is not compatible with MariaDB while for "lock in share mode" is compatible with both.
32 | return new self('lock in share mode');
33 | }
34 |
35 | public static function forReadWrite(): self
36 | {
37 | return new self('for update');
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/KeysetPagination/Query.php:
--------------------------------------------------------------------------------
1 |
37 | */
38 | private array $sorting_column_names;
39 |
40 | /**
41 | * @var positive-int
42 | *
43 | * @readonly
44 | */
45 | private int $batch_size;
46 |
47 | /**
48 | * @var array
49 | */
50 | private array $static_column_bindings;
51 |
52 | /**
53 | * @var array
54 | */
55 | private array $binding_count_per_column = [];
56 |
57 | private string $where;
58 |
59 | private string $order_by;
60 |
61 | /**
62 | * @var non-empty-string
63 | */
64 | private string $sql_first_batch;
65 |
66 | /**
67 | * @var non-empty-string
68 | */
69 | private string $sql_nth_batch;
70 |
71 | /**
72 | * @param string $sql A select SQL query with optional where clauses at the end. The where clause should only use columns that have an index.
73 | * @param non-empty-array $deterministic_sorting_columns column names for sorting that ensure a deterministic sorting order
74 | * @param positive-int $batch_size
75 | * @param scalar[] $static_column_bindings the values for "static" column values if the "$sql" query contains conditions
76 | */
77 | public function __construct(
78 | string $sql,
79 | array $deterministic_sorting_columns,
80 | int $batch_size = 500,
81 | array $static_column_bindings = []
82 | ) {
83 | if (substr_count($sql, '?') !== count($static_column_bindings)) {
84 | throw new InvalidArgumentException(
85 | 'The placeholder count does not match the count of static column values.'
86 | );
87 | }
88 |
89 | $this->sorting_column_names = array_keys($deterministic_sorting_columns);
90 | $this->static_column_bindings = $static_column_bindings;
91 | $this->batch_size = $batch_size;
92 |
93 | $this->where = (false !== strpos($sql, 'where'))
94 | ? ' and '
95 | : ' where ';
96 |
97 | $this->order_by = ' order by ';
98 |
99 | $this->applyCursorQuery($deterministic_sorting_columns);
100 |
101 | $this->sql_first_batch = $sql . $this->order_by . ' limit ? ';
102 | $this->sql_nth_batch = $sql . $this->where . $this->order_by . ' limit ? ';
103 | }
104 |
105 | /**
106 | * @interal
107 | *
108 | * @param list> $batch
109 | */
110 | public function createResult(array $batch): ResultSet
111 | {
112 | if (empty($batch)) {
113 | return ResultSet::empty();
114 | }
115 |
116 | $has_more = (count($batch) === ($this->batch_size + 1));
117 |
118 | if ($has_more) {
119 | // We need to remove the last record because
120 | // we are fetching $batch_size + 1 records.
121 | // Otherwise, we will end up with duplicates.
122 | array_pop($batch);
123 | }
124 |
125 | $last_record = $batch[(int) array_key_last($batch)];
126 | $last_record_sorting_values = array_intersect_key(
127 | $last_record,
128 | array_flip($this->sorting_column_names)
129 | );
130 |
131 | return ResultSet::fromRecords(
132 | $batch,
133 | new LeftOff($last_record_sorting_values),
134 | ! $has_more
135 | );
136 | }
137 |
138 | /**
139 | * @interal
140 | *
141 | * @return array{0: non-empty-string, 1: array}
142 | */
143 | public function buildPlaceholderSQLAndBindings(?LeftOff $left_off): array
144 | {
145 | if (null === $left_off) {
146 | return [
147 | $this->sql_first_batch,
148 | array_merge($this->static_column_bindings, [$this->batch_size + 1]),
149 | ];
150 | }
151 |
152 | $last_record = $left_off->last_included_record_sorting_values;
153 | $bindings = $this->static_column_bindings;
154 |
155 | /*
156 | * If the pagination query uses compound sorting columns all but the last "left off value"
157 | * need to be present two times in the bindings array. The last value
158 | * needs to be present once in the bindings array for mysqli.
159 | */
160 | foreach ($this->sorting_column_names as $column) {
161 | if (! isset($last_record[$column])) {
162 | throw new InvalidArgumentException(
163 | "Sorting column [{$column}] is missing. Please check that your select statement includes the column [{$column}]."
164 | );
165 | }
166 |
167 | $bindings_per_column = array_fill(
168 | 0,
169 | $this->binding_count_per_column[$column],
170 | $last_record[$column]
171 | );
172 |
173 | $bindings = array_merge($bindings, $bindings_per_column);
174 | }
175 |
176 | $bindings[] = ($this->batch_size + 1);
177 |
178 | return [$this->sql_nth_batch, $bindings];
179 | }
180 |
181 | /**
182 | * A helper method that will recursively build out the necessary where
183 | * clauses and order by clauses.
184 | *
185 | * A given input for sorting columns ['a' => 'asc', 'b' => 'asc'] will must
186 | * produce the following SQL:
187 | *
188 | * where `a` > ? or ( `a` = ? and `b` > ? ) order by `a` asc, `b` asc
189 | *
190 | * For `a` sorting order ['a' => 'desc', 'b' => 'asc'] it will produce:
191 | *
192 | * where `a` < ? or ( `a` = ? or `b` > ? ) order by `a` desc, `b` asc
193 | *
194 | * This allows MySQL to fully utilize the index on the primary sorting
195 | * column.
196 | *
197 | * @param non-empty-array $sorting_columns
198 | *
199 | * @see https://stackoverflow.com/questions/38017054/mysql-cursor-based-pagination-with-multiple-columns
200 | * @see http://mysql.rjweb.org/doc.php/deletebig#iterating_through_a_compound_key
201 | * @see http://mysql.rjweb.org/doc.php/pagination
202 | */
203 | private function applyCursorQuery(array $sorting_columns): void
204 | {
205 | $order_direction_to_sql_sign = static fn (string $order): string => 'desc' === $order ? '<' : '>';
206 |
207 | $column_name = array_key_first($sorting_columns);
208 |
209 | $escaped_column_name = $this->escIdentifier($column_name);
210 |
211 | $sorting_direction = $sorting_columns[$column_name];
212 |
213 | $direction_sign = $order_direction_to_sql_sign($sorting_direction);
214 |
215 | array_shift($sorting_columns);
216 |
217 | $is_last = empty($sorting_columns);
218 |
219 | if ($is_last) {
220 | $this->binding_count_per_column[$column_name] = 1;
221 | $this->order_by .= "{$escaped_column_name} {$sorting_direction}";
222 | $this->where .= " {$escaped_column_name} {$direction_sign} ?";
223 |
224 | return;
225 | }
226 |
227 | /*
228 | * We have multiple sorting columns.
229 | * This means that the current column that is being processed
230 | * will appear twice in the final SQL statement which
231 | * means we will need the according cursor value also twice
232 | * in the prepared statement bindings.
233 | *
234 | * The order by part needs a ", " appended because the next
235 | * iteration will have another order by value.
236 | *
237 | * To finish the where clause we need to call this function
238 | * recursively as long as we have more than one column left.
239 | *
240 | */
241 | $this->binding_count_per_column[$column_name] = 2;
242 | $this->order_by .= "{$escaped_column_name} {$sorting_direction}, ";
243 | $this->where .= " {$escaped_column_name} {$direction_sign} ? or ( {$escaped_column_name} = ? and ";
244 |
245 | /** @var non-empty-array $sorting_columns */
246 | $this->applyCursorQuery(
247 | $sorting_columns,
248 | );
249 |
250 | // We need to close out the or statement here.
251 | $this->where .= ' )';
252 | }
253 |
254 | /**
255 | * @param non-empty-string $identifier
256 | */
257 | private function escIdentifier(string $identifier): string
258 | {
259 | return '`' . str_replace('`', '``', $identifier) . '`';
260 | }
261 | }
262 |
--------------------------------------------------------------------------------
/src/KeysetPagination/ResultSet.php:
--------------------------------------------------------------------------------
1 | >
18 | */
19 | public array $records;
20 |
21 | public LeftOff $left_off;
22 |
23 | public bool $is_last;
24 |
25 | /**
26 | * @param list> $records
27 | */
28 | private function __construct(array $records, LeftOff $left_off, bool $is_last)
29 | {
30 | $this->records = $records;
31 | $this->left_off = $left_off;
32 | $this->is_last = $is_last;
33 | }
34 |
35 | public static function empty(): self
36 | {
37 | return new self(
38 | [],
39 | new LeftOff([]),
40 | true
41 | );
42 | }
43 |
44 | /**
45 | * @param list> $records
46 | */
47 | public static function fromRecords(array $records, LeftOff $left_off, bool $is_last): self
48 | {
49 | return new self($records, $left_off, $is_last);
50 | }
51 |
52 | public function count(): int
53 | {
54 | return count($this->records);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/MysqliFactory.php:
--------------------------------------------------------------------------------
1 | setAccessible(true);
22 |
23 | /** @var mysqli $mysqli */
24 | $mysqli = $dbh->getValue($wpdb);
25 |
26 | $dbh->setAccessible(false);
27 |
28 | return $mysqli;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/QueryInfo.php:
--------------------------------------------------------------------------------
1 |
38 | */
39 | public array $bindings = [];
40 |
41 | /**
42 | * @param non-empty-string $sql_with_placeholders
43 | * @param array $bindings
44 | */
45 | public function __construct(float $start, float $end, string $sql_with_placeholders, array $bindings)
46 | {
47 | $this->start = $start;
48 | $this->end = $end;
49 | $this->sql_with_placeholders = $sql_with_placeholders;
50 | $this->bindings = $bindings;
51 |
52 | $this->duration_in_ms = ($end - $start) * 1000.00;
53 |
54 | $this->sql = $this->replacePlaceholders($sql_with_placeholders, $bindings);
55 | }
56 |
57 | /**
58 | * @param non-empty-string $sql_with_placeholders
59 | *
60 | * @psalm-suppress LessSpecificReturnStatement
61 | * @psalm-suppress MoreSpecificReturnType
62 | *
63 | * @psalm-return non-empty-string
64 | */
65 | private function replacePlaceholders(string $sql_with_placeholders, array $bindings): string
66 | {
67 | $bindings = array_map(function ($binding): string {
68 | if (is_int($binding)) {
69 | return (string) $binding;
70 | }
71 |
72 | if (is_float($binding)) {
73 | return (string) $binding;
74 | }
75 |
76 | if (null === $binding) {
77 | return 'null';
78 | }
79 |
80 | $binding = (string) $binding;
81 |
82 | return sprintf("'%s'", $binding);
83 | }, $bindings);
84 |
85 | return (string) preg_replace_callback('#\?#', function () use (&$bindings): string {
86 | /**
87 | * @var string[] $bindings
88 | */
89 | return (string) (array_shift($bindings));
90 | }, $sql_with_placeholders);
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/QueryLogger.php:
--------------------------------------------------------------------------------
1 |