├── 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 |