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 / redirects / RedirectsBulkReader.php

RedirectsBulkReader.php in 404 Solution trunk, at includes/redirects/RedirectsBulkReader.php

229 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 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Specialized, non-paginated reads of the redirects table for callers that
9 * don't go through the staged admin-list pipeline.
10 *
11 * Owns:
12 * - doRedirectsExport: stream the redirects table to a CSV temp file
13 * - getRedirectsWithRegEx: regex redirects with a static request-scoped cache
14 * - getManualRedirectsWithRegexMetachars: manual redirects whose URL
15 * contains regex metacharacters (for the matcher's wildcard fallback)
16 * - getExtraDataToPermalinkSuggestions: post metadata for suggestion ids
17 *
18 * Extracted from ViewReadService in the i805 decomposition. The regex
19 * cache is still owned by RedirectsRepository (its static cache holds the
20 * per-request state); this reader just consults it.
21 */
22 class ABJ_404_Solution_RedirectsBulkReader {
23
24 /** Number of rows fetched by each keyset page once caching is unsafe. */
25 const REGEX_READ_BATCH_SIZE = 250;
26
27 /** @var ABJ_404_Solution_DatabaseCore */
28 private $dbCore;
29
30 /** @var ABJ_404_Solution_ViewQueryBuilder */
31 private $queryBuilder;
32
33 /** @var ABJ_404_Solution_Functions */
34 private $f;
35
36 /**
37 * @param ABJ_404_Solution_DatabaseCore $dbCore
38 * @param ABJ_404_Solution_ViewQueryBuilder $queryBuilder
39 * @param ABJ_404_Solution_Functions $f
40 */
41 public function __construct(
42 ABJ_404_Solution_DatabaseCore $dbCore,
43 ABJ_404_Solution_ViewQueryBuilder $queryBuilder,
44 $f
45 ) {
46 $this->dbCore = $dbCore;
47 $this->queryBuilder = $queryBuilder;
48 $this->f = $f;
49 }
50
51 /**
52 * Stream the export query straight to a CSV temp file via mysqli to keep
53 * the row buffer bounded on large redirect tables.
54 *
55 * @param string $tempFile
56 * @return void
57 */
58 public function doRedirectsExport(string $tempFile): void {
59 global $wpdb;
60
61 if (file_exists($tempFile)) {
62 ABJ_404_Solution_FileSystemService::safeUnlink($tempFile);
63 }
64
65 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getRedirectsExport.sql");
66 $query = $this->dbCore->doTableNameReplacements($query);
67
68 $result = mysqli_query($wpdb->dbh, $query);
69 if ($result instanceof \mysqli_result) {
70 $fh = fopen($tempFile, 'w');
71 if ($fh === false) {
72 mysqli_free_result($result);
73 return;
74 }
75 // try/finally: mysqli's default error mode (MYSQLI_REPORT_ERROR |
76 // MYSQLI_REPORT_STRICT since PHP 8.1) throws mysqli_sql_exception
77 // on a dropped connection mid-fetch. Without a guaranteed close
78 // here, that exception would skip fclose($fh) and leak the file
79 // handle. Same resource-lifecycle shape as
80 // includes/import/ImportService.php::doImportFile().
81 try {
82 fputcsv($fh, array('from_url', 'status', 'type', 'to_url', 'wp_type', 'engine', 'code'), ',', '"', '\\');
83
84 while (($row = mysqli_fetch_array($result, MYSQLI_ASSOC))) {
85 fputcsv($fh, array(
86 $row['from_url'],
87 $row['status'],
88 $row['type'],
89 $row['to_url'],
90 $row['type_wp'],
91 isset($row['engine']) ? $row['engine'] : '',
92 isset($row['code']) ? $row['code'] : '301'
93 ), ',', '"', '\\');
94 }
95 } finally {
96 fclose($fh);
97 mysqli_free_result($result);
98 }
99 }
100 }
101
102 /** @return iterable<int, array<string, mixed>> */
103 public function getRedirectsWithRegEx(): iterable {
104 $cached = ABJ_404_Solution_RedirectsRepository::getRegexRedirectsCache();
105 $disabled = ABJ_404_Solution_RedirectsRepository::isRegexCacheDisabled();
106
107 if ($cached !== null && !$disabled) {
108 return $cached;
109 }
110
111 if ($disabled) {
112 return $this->iterateAllRegexRedirectsInBatches();
113 }
114
115 $results = $this->queryBuilder->queryRegexRedirects(ABJ_404_Solution_RedirectsRepository::REGEX_CACHE_MAX_COUNT + 1);
116
117 if (count($results) <= ABJ_404_Solution_RedirectsRepository::REGEX_CACHE_MAX_COUNT) {
118 ABJ_404_Solution_RedirectsRepository::setRegexRedirectsCache($results);
119 } else {
120 ABJ_404_Solution_RedirectsRepository::setRegexCacheDisabled(true);
121 return $this->iterateAllRegexRedirectsInBatches($results);
122 }
123
124 return $results;
125 }
126
127 /**
128 * Stream a complete regex redirect read without allowing either a SQL
129 * result set or the PHP row collection to grow without bound. The optional
130 * leading rows are the cache-threshold probe and are reused so the common
131 * 51+ path does not reread them.
132 *
133 * @param array<int, array<string, mixed>> $leadingRows
134 * @return iterable<int, array<string, mixed>>
135 */
136 private function iterateAllRegexRedirectsInBatches(array $leadingRows = array()): iterable {
137 // DESIGN-AUDIT-OK(2026-08-21, owner): A total-work cap would silently make later valid regex rules unreachable, reproducing Troy's 51-rule defect.
138 // The admin-owned finite table is streamed in 250-row pages, holds bounded memory, and stops as soon as a caller finds a match.
139 $afterId = $this->greatestRedirectId($leadingRows);
140 foreach ($leadingRows as $row) {
141 yield $row;
142 }
143
144 do {
145 $page = $this->queryBuilder->queryRegexRedirectsPage(array(
146 'after_id' => $afterId,
147 'limit' => self::REGEX_READ_BATCH_SIZE,
148 ));
149 if (empty($page)) {
150 break;
151 }
152
153 $nextAfterId = $this->greatestRedirectId($page);
154 if ($nextAfterId <= $afterId) {
155 throw new UnexpectedValueException(
156 'Regex redirect keyset page did not advance past id ' . $afterId . '.'
157 );
158 }
159
160 foreach ($page as $row) {
161 yield $row;
162 }
163 $afterId = $nextAfterId;
164 } while (count($page) === self::REGEX_READ_BATCH_SIZE);
165 }
166
167 /**
168 * @param array<int, array<string, mixed>> $rows
169 */
170 private function greatestRedirectId(array $rows): int {
171 $greatestId = 0;
172 foreach ($rows as $row) {
173 $id = isset($row['id']) && is_scalar($row['id']) ? (int)$row['id'] : 0;
174 $greatestId = max($greatestId, $id);
175 }
176 return $greatestId;
177 }
178
179 /** @return array<int, array<string, mixed>> */
180 // DESIGN-AUDIT-OK(2026-06-19, owner): status=MANUAL AND disabled=0 seeks via
181 // idx_status_disabled to the few hand-made MANUAL rows first, so INSTR() runs only
182 // over that bounded subset, not a full table scan. A LIMIT would drop valid manual
183 // regex redirects (changes results), so no cap. Reviewed + accepted 2026-06-18.
184 public function getManualRedirectsWithRegexMetachars(): array {
185 $query = "select \n {wp_abj404_redirects}.id,\n {wp_abj404_redirects}.url,\n {wp_abj404_redirects}.status,\n"
186 . " {wp_abj404_redirects}.type,\n {wp_abj404_redirects}.final_dest,\n {wp_abj404_redirects}.code,\n"
187 . " {wp_abj404_redirects}.timestamp,\n {wp_posts}.id as wp_post_id\n ";
188 $query .= "from {wp_abj404_redirects}\n " .
189 " LEFT OUTER JOIN {wp_posts} \n " .
190 " on {wp_abj404_redirects}.final_dest = {wp_posts}.id \n ";
191
192 $query .= "where status = " . ABJ404_STATUS_MANUAL . " \n " .
193 " and disabled = 0 \n " .
194 " and (INSTR(`url`, '*') > 0 " .
195 " OR INSTR(`url`, '[') > 0 " .
196 " OR INSTR(`url`, ']') > 0 " .
197 " OR INSTR(`url`, '|') > 0 " .
198 " OR INSTR(`url`, '^') > 0 " .
199 " OR INSTR(`url`, '\\\\') > 0 " .
200 " OR INSTR(`url`, '{') > 0 " .
201 " OR INSTR(`url`, '}') > 0)";
202 $results = $this->dbCore->queryAndGetResults($query);
203
204 /** @var array<int, array<string, mixed>> $rows */
205 $rows = is_array($results['rows']) ? $results['rows'] : array();
206 return $rows;
207 }
208
209 /**
210 * @param array<int, string> $postIDs
211 * @return array<int, mixed>
212 */
213 public function getExtraDataToPermalinkSuggestions(array $postIDs): array {
214 $postIDs = array_map('absint', $postIDs);
215 $postIDJoined = implode(', ', $postIDs);
216
217 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getAdditionalPostData.sql");
218 $query = $this->f->str_replace('{IDS_TO_INCLUDE}', $postIDJoined, $query);
219 $query = $this->dbCore->doTableNameReplacements($query);
220 $query = $this->f->doNormalReplacements($query);
221
222 $results = $this->dbCore->queryAndGetResults($query);
223
224 /** @var array<int, mixed> $rows */
225 $rows = is_array($results['rows']) ? $results['rows'] : array();
226 return $rows;
227 }
228 }
229