PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / view-build / RedirectsViewLiveResolver.php

RedirectsViewLiveResolver.php in 404 Solution trunk, at includes/view-build/RedirectsViewLiveResolver.php

448 lines 19.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 // allow-no-test-found: exercised by RedirectsSingleTableLiveResolveTest
8
9 /**
10 * Live resolver for the denormalized derived columns on the visible page of the
11 * admin redirects/captured table (Denorm Step 3b, i460).
12 *
13 * The single-table read serves rows straight off wp_abj404_redirects, where
14 * dest_for_view / published_status / logshits / last_used are real columns that
15 * may be stale or empty (right after upgrade, or after a destination rename / new
16 * 404 hit). This class resolves the derived/display values LIVE for the ~50 rows
17 * on the current page on every read, renders from those live values, and writes
18 * the four persisted columns back so subsequent filter/sort reads see fresh data.
19 * That is the mechanism behind both the always-fresh display and the
20 * instant+complete first load (correct even when the columns are still NULL).
21 *
22 * Resolution mirrors the staged view_done pipeline exactly (stages S4-S9) so the
23 * output is byte-identical to the pre-refactor staged path: dest_for_view /
24 * published_status / wp_post_id / wp_post_type per redirect type (POST, CAT/TAG,
25 * HOME, EXTERNAL, 404-displayed, else empty/broken); logshits / logsid /
26 * last_used rolled up from wp_abj404_logs_hits by the canonical URL key.
27 *
28 * wp_post_id / wp_post_type / logsid are display-only (not columns on the table).
29 * The four persisted columns are written back idempotently (only changed rows),
30 * and the persist DEGRADES GRACEFULLY on a read-only / disk-full host: values are
31 * still resolved and rendered, the write is skipped, nothing throws (defensive
32 * philosophy #2/#8). queryAndGetResults() is the centralized error handler.
33 */
34 class ABJ_404_Solution_RedirectsViewLiveResolver {
35
36 /** @var ABJ_404_Solution_DatabaseCore */
37 private $dbCore;
38
39 /** @var string|null Memoized blogname for HOME-typed rows (per request). */
40 private $blognameCache = null;
41
42 /** @var ABJ_404_Solution_RedirectsDenormSchemaReadiness Live introspection of
43 * the denorm columns / sort-key indexes, composed and shared so the resolver,
44 * the read coordinator, and the header UI see the same per-request memoized
45 * probes. */
46 private $schemaReadiness;
47
48 /** @var ABJ_404_Solution_RedirectsHitsRollupReader Rolls up wp_abj404_logs_hits
49 * for the visible page (S9-equivalent). */
50 private $hitsRollupReader;
51
52 /**
53 * Error logging is intentionally delegated to queryAndGetResults (the
54 * centralized DAO error handler), so no logger dependency is held here.
55 *
56 * @param ABJ_404_Solution_DatabaseCore $dbCore
57 * @param ABJ_404_Solution_Functions|null $f UTF-8 sanitizer source; falls
58 * back to the container's functions service when not injected.
59 */
60 public function __construct(ABJ_404_Solution_DatabaseCore $dbCore, $f = null) {
61 $this->dbCore = $dbCore;
62 $f = $f !== null ? $f : abj_service('functions');
63 $this->schemaReadiness = new ABJ_404_Solution_RedirectsDenormSchemaReadiness($dbCore);
64 $this->hitsRollupReader = new ABJ_404_Solution_RedirectsHitsRollupReader($dbCore, $f);
65 }
66
67 /**
68 * The shared live schema/index readiness introspector for the redirects
69 * table. Returned (not mirrored by per-method delegators) so the read
70 * coordinator and the header UI consult the SAME per-request memoized probes
71 * this resolver uses, keeping the query path and the header from drifting on
72 * which sorts are index-ready.
73 *
74 * @return ABJ_404_Solution_RedirectsDenormSchemaReadiness
75 */
76 public function schemaReadiness(): ABJ_404_Solution_RedirectsDenormSchemaReadiness {
77 return $this->schemaReadiness;
78 }
79
80 /**
81 * Safe string read of a query-result field; absent/non-scalar becomes ''.
82 * @param array<array-key, mixed> $row @param string $key @return string
83 */
84 private function strField(array $row, string $key): string {
85 return isset($row[$key]) && is_scalar($row[$key]) ? (string)$row[$key] : '';
86 }
87
88 /**
89 * Safe int read; NULL/absent/non-numeric stays null, numeric becomes int.
90 * @param array<array-key, mixed> $row @param string $key @return int|null
91 */
92 private function intFieldOrNull(array $row, string $key): ?int {
93 return isset($row[$key]) && is_numeric($row[$key]) ? (int)$row[$key] : null;
94 }
95
96 /**
97 * Safe string read that preserves the NULL/absent distinction (stays null),
98 * which the write-back change detection needs (stored NULL != resolved '').
99 * @param array<array-key, mixed> $row @param string $key @return string|null
100 */
101 private function strFieldOrNull(array $row, string $key): ?string {
102 return isset($row[$key]) && is_scalar($row[$key]) ? (string)$row[$key] : null;
103 }
104
105 /**
106 * Resolve the derived/display columns for the visible rows LIVE, overwrite
107 * the rendered values on each row, and persist the four denorm columns back.
108 *
109 * @param array<int, array<string, mixed>> $rows Rows read off wp_abj404_redirects.
110 * @param bool $persist Whether the four denorm columns exist and may be
111 * written back. False on a schema-drifted table that lacks the columns:
112 * the values are still resolved for display, just not persisted.
113 * @return array<int, array<string, mixed>> The same rows with fresh derived values.
114 */
115 public function resolveAndPersistVisibleRows(array $rows, bool $persist = true): array {
116 if (empty($rows)) {
117 return $rows;
118 }
119
120 $postsMap = $this->resolvePostsMap($rows);
121 $termsMap = $this->resolveTermsMap($rows);
122 $hitsMap = $this->hitsRollupReader->resolveHitsMap($rows);
123
124 $resolved = array();
125 $writeBacks = array();
126 foreach ($rows as $row) {
127 if (!is_array($row)) {
128 $resolved[] = $row;
129 continue;
130 }
131 $out = $this->applyResolution($row, $postsMap, $termsMap, $hitsMap);
132 $resolved[] = $out;
133 if (!$persist) {
134 continue;
135 }
136 $persistValues = $this->persistValuesIfChanged($row, $out);
137 if ($persistValues !== null) {
138 $writeBacks[] = $persistValues;
139 }
140 }
141
142 if (!empty($writeBacks)) {
143 $this->persistResolvedColumns($writeBacks);
144 }
145
146 return $resolved;
147 }
148
149 /**
150 * Look up wp_posts for every POST-typed row's numeric final_dest (S4).
151 * @param array<int, array<string, mixed>> $rows
152 * @return array<int, array<string, mixed>> postId => {ID, post_title, post_status, post_type}
153 */
154 private function resolvePostsMap(array $rows): array {
155 $ids = $this->collectFinalDestIds($rows, ABJ404_TYPE_POST);
156 if (empty($ids)) {
157 return array();
158 }
159 // allow-unbounded-select: caller-supplied ID keyset (where ID IN the current page's rows); page-bounded
160 $query = "SELECT ID, post_title, post_status, post_type FROM {wp_posts} WHERE ID IN ("
161 . implode(',', $ids) . ")";
162 return $this->indexRowsBy($this->dbCore->queryAndGetResults($query), 'ID');
163 }
164
165 /**
166 * Look up wp_terms for every CAT/TAG-typed row's numeric final_dest (S5).
167 * @param array<int, array<string, mixed>> $rows
168 * @return array<int, array<string, mixed>> termId => {term_id, name}
169 */
170 private function resolveTermsMap(array $rows): array {
171 $ids = array_merge(
172 $this->collectFinalDestIds($rows, ABJ404_TYPE_CAT),
173 $this->collectFinalDestIds($rows, ABJ404_TYPE_TAG)
174 );
175 $ids = array_values(array_unique($ids));
176 if (empty($ids)) {
177 return array();
178 }
179 $query = "SELECT term_id, name FROM {wp_terms} WHERE term_id IN (" . implode(',', $ids) . ")";
180 return $this->indexRowsBy($this->dbCore->queryAndGetResults($query), 'term_id');
181 }
182
183 /**
184 * Overwrite the derived/display fields on a single row from the resolved
185 * maps. Per-type destination resolution mirrors staged stages S4-S8 plus the
186 * catch-all; hits come from the S9-equivalent rollup.
187 *
188 * @param array<string, mixed> $row
189 * @param array<int, array<string, mixed>> $postsMap
190 * @param array<int, array<string, mixed>> $termsMap
191 * @param array<string, array{logshits:int, logsid:int|null, last_used:int|null}> $hitsMap
192 * @return array<string, mixed>
193 */
194 private function applyResolution(array $row, array $postsMap, array $termsMap, array $hitsMap): array {
195 $out = $row;
196
197 $destination = $this->resolveDestinationFields($row, $postsMap, $termsMap);
198 $out['dest_for_view'] = $destination['dest_for_view'];
199 $out['published_status'] = $destination['published_status'];
200 $out['wp_post_id'] = $destination['wp_post_id'];
201 $out['wp_post_type'] = $destination['wp_post_type'];
202
203 $hit = $hitsMap[$this->canonicalUrl($this->strField($row, 'url'))] ?? null;
204 $out['logshits'] = $hit !== null ? $hit['logshits'] : null;
205 $out['logsid'] = $hit !== null ? $hit['logsid'] : null;
206 $out['last_used'] = $hit !== null ? $hit['last_used'] : null;
207
208 return $out;
209 }
210
211 /**
212 * Resolve dest_for_view / published_status / wp_post_id / wp_post_type for a
213 * single row per redirect type. Mirrors staged stages S4-S8 plus the
214 * catch-all (any other type renders empty/broken).
215 *
216 * @param array<string, mixed> $row
217 * @param array<int, array<string, mixed>> $postsMap
218 * @param array<int, array<string, mixed>> $termsMap
219 * @return array{dest_for_view:string, published_status:int, wp_post_id:string|null, wp_post_type:string|null}
220 */
221 private function resolveDestinationFields(array $row, array $postsMap, array $termsMap): array {
222 $type = $this->intFieldOrNull($row, 'type');
223 $finalDest = $this->strField($row, 'final_dest');
224
225 if ($type === ABJ404_TYPE_POST) {
226 $post = $this->lookupById($postsMap, $finalDest);
227 if ($post === null) {
228 return $this->destinationFields('', 0);
229 }
230 return $this->destinationFields(
231 $this->strField($post, 'post_title'),
232 strtolower($this->strField($post, 'post_status')) === 'publish' ? 1 : 0,
233 isset($post['ID']) ? $this->strField($post, 'ID') : null,
234 isset($post['post_type']) ? $this->strField($post, 'post_type') : null
235 );
236 }
237 if ($type === ABJ404_TYPE_CAT || $type === ABJ404_TYPE_TAG) {
238 $term = $this->lookupById($termsMap, $finalDest);
239 return $term === null
240 ? $this->destinationFields('', 0)
241 : $this->destinationFields($this->strField($term, 'name'), 1);
242 }
243 if ($type === ABJ404_TYPE_HOME) {
244 return $this->destinationFields($this->blogname(), 1);
245 }
246 if ($type === ABJ404_TYPE_EXTERNAL) {
247 return $this->destinationFields($finalDest, 1);
248 }
249 if ($type === ABJ404_TYPE_404_DISPLAYED) {
250 return $this->destinationFields('', 1);
251 }
252 return $this->destinationFields('', 0);
253 }
254
255 /**
256 * @return array{dest_for_view:string, published_status:int, wp_post_id:string|null, wp_post_type:string|null}
257 */
258 private function destinationFields(string $dest, int $published, ?string $wpPostId = null, ?string $wpPostType = null): array {
259 return array(
260 'dest_for_view' => $dest,
261 'published_status' => $published,
262 'wp_post_id' => $wpPostId,
263 'wp_post_type' => $wpPostType,
264 );
265 }
266
267 /**
268 * Compute the to-be-persisted values for the four denorm columns, returning
269 * them only when they differ from what the row already stored (idempotent
270 * write-back; converged rows produce no write). logshits is NOT NULL on the
271 * table, so an unresolved (no-hits) row persists 0; comparing the persist
272 * value (not the rendered NULL) against the stored 0 avoids rewriting forever.
273 *
274 * @param array<string, mixed> $original The row as read off the table.
275 * @param array<string, mixed> $resolved The row after live resolution.
276 * @return array{id:int, dest_for_view:string, dest_sort_key:string, published_status:int, logshits:int, last_used:int|null}|null
277 */
278 private function persistValuesIfChanged(array $original, array $resolved): ?array {
279 $id = $this->intFieldOrNull($resolved, 'id');
280 if ($id === null) {
281 return null;
282 }
283 $dest = $this->strField($resolved, 'dest_for_view');
284 $published = $this->intFieldOrNull($resolved, 'published_status') ?? 0;
285 $logshits = $this->intFieldOrNull($resolved, 'logshits') ?? 0;
286 $lastUsed = $this->intFieldOrNull($resolved, 'last_used');
287
288 $storedDest = $this->strFieldOrNull($original, 'dest_for_view');
289 $storedPublished = $this->intFieldOrNull($original, 'published_status');
290 $storedLogshits = $this->intFieldOrNull($original, 'logshits');
291 $storedLastUsed = $this->intFieldOrNull($original, 'last_used');
292
293 $unchanged = $storedDest === $dest
294 && $storedPublished === $published
295 && $storedLogshits === $logshits
296 && $storedLastUsed === $lastUsed;
297 if ($unchanged) {
298 return null;
299 }
300
301 // dest_sort_key is a pure function of dest_for_view (the indexable narrow
302 // copy LEFT(dest_for_view, 191)); it rides along on the dest_for_view
303 // change rather than being its own change trigger. The bulk backfill is
304 // the populator for the pre-backfill NULL window. mb_substr counts
305 // characters, matching the SQL LEFT(...,191).
306 $destSortKey = function_exists('mb_substr')
307 ? (string) mb_substr($dest, 0, 191)
308 : (string) substr($dest, 0, 191);
309
310 return array(
311 'id' => $id,
312 'dest_for_view' => $dest,
313 'dest_sort_key' => $destSortKey,
314 'published_status' => $published,
315 'logshits' => $logshits,
316 'last_used' => $lastUsed,
317 );
318 }
319
320 /**
321 * Persist the four denorm columns for the changed rows in one batched UPDATE.
322 * Skipped entirely when a write block (read-only replica / disk full) is
323 * active so a degraded host still renders without an errored write.
324 *
325 * @param array<int, array{id:int, dest_for_view:string, dest_sort_key:string, published_status:int, logshits:int, last_used:int|null}> $writeBacks
326 * @return void
327 */
328 private function persistResolvedColumns(array $writeBacks): void {
329 if ($this->dbCore->noticeState()->isWriteBlockActive()) {
330 return;
331 }
332
333 // dest_sort_key is written only when the column exists (added after the
334 // Step 3a four); on an install still missing it, skip that one assignment
335 // so the write-back of the other columns still succeeds (schema drift).
336 $writeDestSortKey = $this->schemaReadiness->destSortKeyColumnPresent();
337
338 $ids = array();
339 $destCases = '';
340 $destSortCases = '';
341 $publishedCases = '';
342 $logshitsCases = '';
343 $lastUsedCases = '';
344 foreach ($writeBacks as $wb) {
345 $id = (int)$wb['id'];
346 $ids[] = $id;
347 $destCases .= ' WHEN ' . $id . " THEN '" . esc_sql($wb['dest_for_view']) . "'";
348 $destSortCases .= ' WHEN ' . $id . " THEN '" . esc_sql($wb['dest_sort_key']) . "'";
349 $publishedCases .= ' WHEN ' . $id . ' THEN ' . (int)$wb['published_status'];
350 $logshitsCases .= ' WHEN ' . $id . ' THEN ' . (int)$wb['logshits'];
351 $lastUsedCases .= ' WHEN ' . $id . ' THEN '
352 . ($wb['last_used'] === null ? 'NULL' : (int)$wb['last_used']);
353 }
354
355 $idList = implode(',', $ids);
356 $query = "UPDATE {wp_abj404_redirects} SET"
357 . " dest_for_view = CASE id" . $destCases . " END,"
358 . ($writeDestSortKey ? " dest_sort_key = CASE id" . $destSortCases . " END," : "")
359 . " published_status = CASE id" . $publishedCases . " END,"
360 . " logshits = CASE id" . $logshitsCases . " END,"
361 . " last_used = CASE id" . $lastUsedCases . " END"
362 . " WHERE id IN (" . $idList . ")";
363 // queryAndGetResults is the centralized error handler: a write failure on
364 // a read-only/disk-full host is logged there as a warning and never
365 // surfaced. The resolved values were already rendered, so a skipped
366 // persist only costs a re-resolve on the next read.
367 $this->dbCore->queryAndGetResults($query);
368 }
369
370 /**
371 * Collect numeric final_dest values for rows of one type; non-numeric
372 * final_dest is dropped (matches the staged fd_int REGEXP guard).
373 * @param array<int, array<string, mixed>> $rows @param int $type
374 * @return array<int, int>
375 */
376 private function collectFinalDestIds(array $rows, int $type): array {
377 $ids = array();
378 foreach ($rows as $row) {
379 if (!is_array($row)) {
380 continue;
381 }
382 $rowType = $this->intFieldOrNull($row, 'type');
383 if ($rowType !== $type) {
384 continue;
385 }
386 $finalDest = $this->strField($row, 'final_dest');
387 if (preg_match('/^\d+$/', $finalDest)) {
388 $ids[(int)$finalDest] = (int)$finalDest;
389 }
390 }
391 return array_values($ids);
392 }
393
394 /**
395 * @param array<string, mixed> $result queryAndGetResults() return shape.
396 * @return array<int, array<string, mixed>>
397 */
398 private function indexRowsBy(array $result, string $keyColumn): array {
399 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
400 $map = array();
401 foreach ($rows as $row) {
402 if (is_array($row) && isset($row[$keyColumn]) && is_numeric($row[$keyColumn])) {
403 $map[(int)$row[$keyColumn]] = $row;
404 }
405 }
406 return $map;
407 }
408
409 /**
410 * @param array<int, array<string, mixed>> $map @param string $finalDest
411 * @return array<string, mixed>|null
412 */
413 private function lookupById(array $map, string $finalDest): ?array {
414 if (!preg_match('/^\d+$/', $finalDest)) {
415 return null;
416 }
417 return $map[(int)$finalDest] ?? null;
418 }
419
420 /** @param string $url @return string */
421 private function canonicalUrl(string $url): string {
422 return '/' . trim($url, '/');
423 }
424
425 /** @return string */
426 private function blogname(): string {
427 if ($this->blognameCache !== null) {
428 return $this->blognameCache;
429 }
430 $value = '';
431 if (function_exists('get_option')) {
432 $raw = get_option('blogname', '');
433 $value = is_scalar($raw) ? (string)$raw : '';
434 }
435 if ($value === '') {
436 $result = $this->dbCore->queryAndGetResults(
437 "SELECT option_value FROM {wp_options} WHERE option_name = 'blogname' LIMIT 1"
438 );
439 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
440 if (isset($rows[0]) && is_array($rows[0])) {
441 $value = $this->strField($rows[0], 'option_value');
442 }
443 }
444 $this->blognameCache = $value;
445 return $value;
446 }
447 }
448