PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / class-wpdb-driver-pdo.php

class-wpdb-driver-pdo.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at vendor/wp-php-toolkit/reprint-server/src/class-wpdb-driver-pdo.php

287 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * wpdb-backed PDO adapter for MySQL exports on PDO-less hosts.
4 *
5 * MySQLDumpProducer expects a PDO connection — prepare(), query(), and the
6 * statement methods fetch(), fetchAll(), fetchColumn(), execute().
7 * On hosts without ext-pdo / ext-pdo_mysql, this adapter wraps WordPress's
8 * global $wpdb so the dump producer can run unchanged.
9 *
10 * Surface area mirrors SqliteDriverPDO 1:1 — only the methods MySQLDumpProducer
11 * and the export endpoints actually call. Behavior parity with real PDO is the
12 * bar; behavioral divergence (wpdb's HTML error rendering, sticky last_error,
13 * get_results null ambiguity) is normalized inside the adapter.
14 *
15 * Charset: create_db_connection() gives PDO and wpdb the same utf8mb4_bin
16 * session context. The dump producer casts non-numeric result columns to
17 * binary, while text primary key comparisons retain their stored collation.
18 */
19
20 namespace WordPress\Reprint\Server;
21
22 /**
23 * Wraps a WordPress wpdb instance to look like a PDO connection.
24 */
25 class WpdbDriverPDO
26 {
27 /** @var object */
28 private $wpdb;
29
30 /**
31 * @param object $wpdb The global WordPress $wpdb. Type hint omitted because
32 * wpdb subclasses (HyperDB, LudicrousDB, SQLite drop-in) are not
33 * guaranteed to extend the canonical class in all environments,
34 * and the test double is not a wpdb subclass.
35 */
36 public function __construct($wpdb)
37 {
38 $this->wpdb = $wpdb;
39
40 // Prevent wpdb from echoing HTML error blocks into the response stream.
41 // The export endpoints emit gzip multipart, so even one HTML chunk
42 // would corrupt the output stream irrecoverably. No restore on
43 // shutdown: export endpoints terminate the request after streaming.
44 $wpdb->suppress_errors(true);
45 $wpdb->hide_errors();
46 }
47
48 public function prepare(string $sql): WpdbDriverPDOStatement
49 {
50 return new WpdbDriverPDOStatement($this->wpdb, $sql);
51 }
52
53 public function query(string $sql): WpdbDriverPDOStatement
54 {
55 $stmt = new WpdbDriverPDOStatement($this->wpdb, $sql);
56 $stmt->execute();
57 return $stmt;
58 }
59
60 /**
61 * Quotes a string for safe inclusion in a query.
62 *
63 * Calls $wpdb->_real_escape() — a public method on wpdb (and its
64 * subclasses); the leading underscore is naming convention only, not
65 * visibility. Calling it directly keeps the adapter free of any global
66 * function dependency and HyperDB / LudicrousDB / SQLite-drop-in safe.
67 */
68 public function quote(string $value, $type = null): string
69 {
70 return "'" . $this->wpdb->_real_escape($value) . "'";
71 }
72 }
73
74 /**
75 * PDOStatement-shaped wrapper around a wpdb query result set.
76 *
77 * Substitutes positional (?) and named (:name) placeholders manually rather
78 * than calling $wpdb->prepare() — wpdb's prepare uses %s/%d and adds quoting
79 * itself, which would conflict with how MySQLDumpProducer builds queries.
80 */
81 class WpdbDriverPDOStatement
82 {
83 /** @var object */
84 private $wpdb;
85
86 /** @var string */
87 private $sql;
88
89 /** @var array Stored result rows after execution. */
90 private $rows = [];
91
92 /** @var int Current position for fetch(). */
93 private $position = 0;
94
95 /** @var array Parameters bound via bindValue(). */
96 private $bound_params = [];
97
98 /**
99 * @param object $wpdb
100 * @param string $sql
101 */
102 public function __construct($wpdb, string $sql)
103 {
104 $this->wpdb = $wpdb;
105 $this->sql = $sql;
106 }
107
108 /**
109 * Executes the prepared statement.
110 *
111 * Substitutes parameters into $this->sql, clears any sticky
112 * $wpdb->last_error (which can survive from queries that ran before the
113 * exporter took over the request), runs $wpdb->get_results($sql,
114 * ARRAY_A), and disambiguates the null return: null + non-empty
115 * last_error -> RuntimeException; null + empty last_error -> empty result
116 * set.
117 *
118 * @param array|null $params Positional or named parameters.
119 */
120 public function execute($params = null): bool
121 {
122 $sql = $this->substitute_placeholders($this->sql, $params ?? $this->bound_params);
123
124 // Clear sticky last_error so the post-dispatch check below can't
125 // throw a phantom query error from a prior query.
126 $this->clear_last_error();
127
128 $rows = $this->wpdb->get_results($sql, 'ARRAY_A');
129 $this->discard_query_log();
130 $last_error = $this->get_last_error();
131
132 if ($last_error !== '') {
133 throw new \RuntimeException($last_error);
134 }
135
136 $this->rows = is_array($rows) ? $rows : [];
137 $this->position = 0;
138 return true;
139 }
140
141 /**
142 * $mode is accepted for PDO compatibility but only FETCH_ASSOC is honored.
143 * MySQLDumpProducer never asks for any other mode.
144 */
145 public function fetch($mode = null)
146 {
147 if ($this->position >= count($this->rows)) {
148 return false;
149 }
150 return $this->rows[$this->position++];
151 }
152
153 /**
154 * Honors FETCH_ASSOC (default) and FETCH_COLUMN. Other PDO modes are
155 * unsupported — MySQLDumpProducer never asks for any other mode.
156 */
157 public function fetchAll($mode = null): array
158 {
159 $mode = $mode ?? PdoConstants::fetch_assoc();
160 $remaining = array_slice($this->rows, $this->position);
161 $this->position = count($this->rows);
162
163 if ($mode === PdoConstants::fetch_column()) {
164 return array_map(static function ($row) {
165 return reset($row);
166 }, $remaining);
167 }
168
169 return $remaining;
170 }
171
172 public function fetchColumn(int $column_number = 0)
173 {
174 $row = $this->fetch();
175 if ($row === false) {
176 return false;
177 }
178 $values = array_values($row);
179 return $values[$column_number] ?? false;
180 }
181
182 public function bindValue($parameter, $value, $type = null): bool
183 {
184 $this->bound_params[$parameter] = $value;
185 return true;
186 }
187
188 /**
189 * Substitutes ? and :name placeholders with quoted parameter values.
190 *
191 * Walks $sql byte-by-byte tracking single/double-quoted string literals so
192 * a literal '?' or ':name' inside a string is not treated as a placeholder.
193 * Inside a literal, a backslash escapes the next byte (MySQL extension)
194 * so `\'` does not close the string. Positional substitution runs right
195 * to left so earlier offsets stay valid. Named substitution uses a regex
196 * with a word-boundary tail (/:name(?![A-Za-z0-9_])/) so :foo does not
197 * corrupt :foobar.
198 */
199 private function clear_last_error(): void
200 {
201 $this->wpdb->last_error = '';
202 }
203
204 /**
205 * Drop the wpdb query log in case it's active.
206 * We don't want these stacking up and consuming memory.
207 */
208 private function discard_query_log(): void
209 {
210 if (isset($this->wpdb->queries) && is_array($this->wpdb->queries)) {
211 $this->wpdb->queries = [];
212 }
213 }
214
215 private function get_last_error(): string
216 {
217 return (string) ($this->wpdb->last_error ?? '');
218 }
219
220 private function substitute_placeholders(string $sql, $params): string
221 {
222 if ($params === null || count($params) === 0) {
223 return $sql;
224 }
225
226 $positions = [];
227 $len = strlen($sql);
228 $in_single = false;
229 $in_double = false;
230 $escape_next = false;
231 for ($i = 0; $i < $len; $i++) {
232 if ($escape_next) {
233 $escape_next = false;
234 continue;
235 }
236 $ch = $sql[$i];
237 if (($in_single || $in_double) && $ch === '\\') {
238 $escape_next = true;
239 continue;
240 }
241 if ($ch === "'" && !$in_double) {
242 $in_single = !$in_single;
243 } elseif ($ch === '"' && !$in_single) {
244 $in_double = !$in_double;
245 } elseif ($ch === '?' && !$in_single && !$in_double) {
246 $positions[] = $i;
247 }
248 }
249
250 for ($i = count($positions) - 1; $i >= 0; $i--) {
251 if (!array_key_exists($i, $params)) {
252 continue;
253 }
254 $quoted = $this->quote_value($params[$i]);
255 $sql = substr_replace($sql, $quoted, $positions[$i], 1);
256 }
257
258 foreach ($params as $key => $value) {
259 if (!is_string($key)) {
260 continue;
261 }
262 $name = ltrim($key, ':');
263 if ($name === '') {
264 continue;
265 }
266 $pattern = '/:' . preg_quote($name, '/') . '(?![A-Za-z0-9_])/';
267 $sql = preg_replace_callback(
268 $pattern,
269 function () use ($value) {
270 return $this->quote_value($value);
271 },
272 $sql
273 );
274 }
275
276 return $sql;
277 }
278
279 private function quote_value($value): string
280 {
281 if ($value === null) {
282 return 'NULL';
283 }
284 return "'" . $this->wpdb->_real_escape((string) $value) . "'";
285 }
286 }
287