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 / ViewQueryBuilder.php

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

421 lines 20.6 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 * Admin view query construction for the redirect/captured lists.
9 *
10 * Builds the SQL for the admin redirect/captured lists: high-impact captured
11 * count, regex redirects, optimized count, and the single-table read/count
12 * against wp_abj404_redirects (Denorm Step 3b). The staged view_done read path
13 * that used to live here was removed when the denorm chain dropped the
14 * wp_abj404_view_done table (Step 3e-D / i467); admin reads now serve straight
15 * off the redirects row.
16 */
17 class ABJ_404_Solution_ViewQueryBuilder {
18
19 /** @var ABJ_404_Solution_DatabaseCore */
20 private $dbCore;
21
22 /** @var ABJ_404_Solution_ViewQueryPolicy */
23 private $policy;
24
25 /**
26 * @param ABJ_404_Solution_DatabaseCore $dbCore
27 */
28 public function __construct(ABJ_404_Solution_DatabaseCore $dbCore) {
29 $this->dbCore = $dbCore;
30 $this->policy = new ABJ_404_Solution_ViewQueryPolicy();
31 }
32
33 /** @return string */
34 public function buildHighImpactCapturedCountQuery(): string {
35 // Plain equality gives the optimizer an indexable requested_url probe;
36 // the BINARY predicate keeps exact-match URL semantics.
37 $logsHitsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
38 $canonicalRedirectUrl = "COALESCE(r.canonical_url, CONCAT('/', TRIM(BOTH '/' FROM r.url)))";
39 $comparableRedirectUrl = $this->dbCore->collationHelper()->coerceExpressionToColumnCollation(
40 $canonicalRedirectUrl,
41 array('table' => $logsHitsTable, 'column' => 'requested_url')
42 );
43 // allow-unbounded-select: COUNT(*) aggregate; returns a single row
44 $query = "SELECT COUNT(*) AS cnt
45 FROM {wp_abj404_redirects} r
46 INNER JOIN {wp_abj404_logs_hits} h
47 ON h.requested_url = " . $comparableRedirectUrl . "
48 AND BINARY h.requested_url = BINARY
49 COALESCE(r.canonical_url, CONCAT('/', TRIM(BOTH '/' FROM r.url)))
50 WHERE r.status = " . ABJ404_STATUS_CAPTURED . " AND r.disabled = 0
51 AND h.logshits >= 3";
52 return $this->dbCore->doTableNameReplacements($query);
53 }
54
55 /**
56 * @param int|null $limit
57 * @return array<int, array<string, mixed>>
58 */
59 public function queryRegexRedirects(?int $limit = null) {
60 return $this->queryRegexRedirectsPage(array(
61 'after_id' => 0,
62 'limit' => $limit,
63 ));
64 }
65
66 /**
67 * Read one ascending-id page of active regex redirects.
68 *
69 * @param array{after_id: int, limit: int|null} $page
70 * @return array<int, array<string, mixed>>
71 */
72 public function queryRegexRedirectsPage(array $page): array {
73 $afterId = max(0, (int)($page['after_id'] ?? 0));
74 $limit = $page['limit'] ?? null;
75 $query = "select \n {wp_abj404_redirects}.id,\n {wp_abj404_redirects}.url,\n {wp_abj404_redirects}.status,\n"
76 . " {wp_abj404_redirects}.type,\n {wp_abj404_redirects}.final_dest,\n {wp_abj404_redirects}.code,\n"
77 . " {wp_abj404_redirects}.timestamp,\n {wp_posts}.id as wp_post_id\n "
78 . "from {wp_abj404_redirects}\n "
79 . " LEFT OUTER JOIN {wp_posts} \n "
80 . " on {wp_abj404_redirects}.final_dest = {wp_posts}.id \n "
81 . "where status in (" . ABJ404_STATUS_REGEX . ") \n "
82 . " and disabled = 0\n"
83 . " and {wp_abj404_redirects}.id > " . $afterId . "\n"
84 . "order by {wp_abj404_redirects}.id ASC";
85 if ($limit !== null) {
86 $query .= "\nlimit " . max(1, intval($limit));
87 }
88
89 $results = $this->dbCore->queryAndGetResults($query);
90 $rows = is_array($results['rows'] ?? null) ? $results['rows'] : array();
91 /** @var array<int, array<string, mixed>> $rows */
92 return $rows;
93 }
94
95 /**
96 * @param string $sub
97 * @param array<string, mixed> $tableOptions
98 * @return string
99 */
100 public function getOptimizedRedirectsForViewCountQuery(string $sub, array $tableOptions): string {
101 $statusTypes = $this->policy->resolveStatusTypeList($sub, $tableOptions);
102 $trashValue = $this->policy->resolveTrashValue($tableOptions);
103 $scoreRangeClause = $this->policy->buildScoreRangeClause($tableOptions, 'wp_abj404_redirects.'); // allow-prefix-literal: SQL alias bound by FROM clause
104
105 $query = "SELECT COUNT(*) AS count\n"
106 . "FROM {wp_abj404_redirects} wp_abj404_redirects\n" // allow-prefix-literal: second token is the SQL alias name, not a table reference
107 . "WHERE 1 and status IN (" . $statusTypes . ") AND disabled = " . intval($trashValue) . "\n"
108 . $scoreRangeClause;
109
110 return $this->dbCore->doTableNameReplacements($query);
111 }
112
113 /**
114 * Execute the single-table redirects read for one page (Denorm Step 3b).
115 *
116 * Serves rows straight off wp_abj404_redirects, where every sortable column
117 * (url, status, type, code, timestamp, score, logshits, last_used,
118 * dest_for_view) is a real column -> a single-table filesort over at most one
119 * page of rows, no temp-table materialize, no join. The four derived columns
120 * are refreshed live per visible row by RedirectsViewLiveResolver after this
121 * read; this method only fetches the ordered/filtered page.
122 *
123 * @param string $sub
124 * @param array<string, mixed> $tableOptions
125 * @param bool $derivedPresent Whether the four denorm columns exist on the
126 * redirects table (schema-drift tolerance: false selects base columns only
127 * and the live resolver fills the derived values).
128 * @return array<int, array<string, mixed>>
129 * @throws ABJ_404_Solution_ViewQueryFailureException When the database adapter reports a failed read.
130 */
131 public function readRedirectsSingleTable(string $sub, array $tableOptions, bool $derivedPresent = true): array {
132 $query = $this->buildRedirectsSingleTableReadQuery($sub, $tableOptions, $derivedPresent);
133 $result = $this->dbCore->queryAndGetResults($query, $this->resolveReadTimeoutOptions($tableOptions));
134 $lastErrorRaw = $result['last_error'] ?? '';
135 $lastError = is_scalar($lastErrorRaw) ? trim((string)$lastErrorRaw) : '';
136 if (!empty($result['timed_out']) || $lastError !== '') {
137 $message = !empty($result['timed_out'])
138 ? 'Redirect row query timed out.'
139 : 'Redirect row query failed: ' . $lastError;
140 throw new ABJ_404_Solution_ViewQueryFailureException($message);
141 }
142 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
143 /** @var array<int, array<string, mixed>> $rows */
144 return $rows;
145 }
146
147 /**
148 * Build the queryAndGetResults options for the single-table read/count from
149 * the optional per-request timeout, with a level-9-safe numeric guard.
150 *
151 * @param array<string, mixed> $tableOptions
152 * @return array<string, int>
153 */
154 private function resolveReadTimeoutOptions(array $tableOptions): array {
155 $raw = $tableOptions['_abj404_query_timeout'] ?? null;
156 if (!is_numeric($raw)) {
157 return array();
158 }
159 $timeout = (int)$raw;
160 return $timeout > 0 ? array('timeout' => $timeout) : array();
161 }
162
163 /**
164 * Execute the single-table filtered count against wp_abj404_redirects.
165 *
166 * @param string $sub
167 * @param array<string, mixed> $tableOptions
168 * @param bool $derivedPresent
169 * @return int
170 * @throws ABJ_404_Solution_ViewQueryFailureException When the aggregate is unavailable or malformed.
171 */
172 public function countRedirectsSingleTable(string $sub, array $tableOptions, bool $derivedPresent = true): int {
173 $query = $this->buildRedirectsSingleTableCountQuery($sub, $tableOptions, $derivedPresent);
174 $result = $this->dbCore->queryAndGetResults($query, $this->resolveReadTimeoutOptions($tableOptions));
175 $lastErrorRaw = $result['last_error'] ?? '';
176 $lastError = is_scalar($lastErrorRaw) ? trim((string)$lastErrorRaw) : '';
177 if (!empty($result['timed_out']) || $lastError !== '') {
178 $message = !empty($result['timed_out'])
179 ? 'Filtered redirect count query timed out.'
180 : 'Filtered redirect count query failed: ' . $lastError;
181 throw new ABJ_404_Solution_ViewQueryFailureException($message);
182 }
183 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
184 if (empty($rows) || !is_array($rows[0])) {
185 throw new ABJ_404_Solution_ViewQueryFailureException(
186 'Filtered redirect count query returned no aggregate row.'
187 );
188 }
189 $raw = $rows[0]['cnt'] ?? reset($rows[0]);
190 if (!is_numeric($raw)) {
191 throw new ABJ_404_Solution_ViewQueryFailureException(
192 'Filtered redirect count query returned a nonnumeric aggregate.'
193 );
194 }
195 return intval($raw);
196 }
197
198 /**
199 * @param string $sub
200 * @param array<string, mixed> $tableOptions
201 * @param bool $derivedPresent
202 * @return string
203 */
204 public function buildRedirectsSingleTableReadQuery(string $sub, array $tableOptions, bool $derivedPresent = true): string {
205 $effectiveSort = $this->resolveEffectiveSort($tableOptions, $derivedPresent);
206 $orderBy = $effectiveSort['orderby'];
207 $order = $effectiveSort['order'];
208
209 $rawPaged = $tableOptions['paged'] ?? 1;
210 $paged = max(1, is_scalar($rawPaged) ? intval($rawPaged) : 1);
211 $rawPerpage = $tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE;
212 $perpage = max(1, is_scalar($rawPerpage) ? intval($rawPerpage) : (int)ABJ404_OPTION_DEFAULT_PERPAGE);
213 $limitStart = ($paged - 1) * $perpage;
214
215 // The derived columns are projected only when they exist, purely so the
216 // live resolver can dedupe its write-back against the stored values; the
217 // rendered values come from the live resolution either way. On a
218 // schema-drifted table without them, the base-column projection still
219 // renders a complete page.
220 $derivedProjection = $derivedPresent
221 ? ",\n dest_for_view, published_status, logshits, last_used" : "";
222
223 // Tie-break on the PK id in the sort direction (not on url): url is
224 // varchar(2048) and only prefix-indexable, so an `ORDER BY <col>, url`
225 // can never be index-ordered and always filesorts. With `id <dir>` a
226 // single ascending composite index (disabled, <col>, id) serves both
227 // ASC and DESC scans, so the Hits / Last Used sorts stop after one page
228 // instead of sorting the whole active-redirect set. See
229 // RedirectsDerivedSortExplainPlanTest. id is unique, so the ordering is
230 // still fully deterministic for pagination.
231 return "SELECT id, url, status, type, final_dest, code, timestamp, engine, score"
232 . $derivedProjection . "\n"
233 . "FROM {wp_abj404_redirects}\n"
234 . $this->buildSingleTableWhere($sub, $tableOptions, $derivedPresent)
235 . "ORDER BY " . $orderBy . " " . $order . ", id " . $order . "\n"
236 . "LIMIT " . $limitStart . ", " . $perpage;
237 }
238
239 /**
240 * @param string $sub
241 * @param array<string, mixed> $tableOptions
242 * @param bool $derivedPresent
243 * @return string
244 */
245 public function buildRedirectsSingleTableCountQuery(string $sub, array $tableOptions, bool $derivedPresent = true): string {
246 return "SELECT COUNT(*) AS cnt\n"
247 . "FROM {wp_abj404_redirects}\n"
248 . $this->buildSingleTableWhere($sub, $tableOptions, $derivedPresent);
249 }
250
251 /**
252 * The shared WHERE body for the single-table read and count: status filter,
253 * trash (disabled) filter, score range, and the dest_for_view-aware
254 * filterText search. Identical between read and count so a filtered count
255 * always equals the unpaginated row set (i457 invariant). The filterText
256 * destination-title match drops to url/label matching when the dest_for_view
257 * column is absent (schema-drift tolerance).
258 *
259 * @param string $sub
260 * @param array<string, mixed> $tableOptions
261 * @param bool $derivedPresent
262 * @return string
263 */
264 private function buildSingleTableWhere(string $sub, array $tableOptions, bool $derivedPresent = true): string {
265 $statusTypes = $this->policy->resolveStatusTypeList($sub, $tableOptions);
266 $trashClause = 'AND disabled = ' . intval($this->policy->resolveTrashValue($tableOptions));
267 $scoreRangeClause = $this->policy->buildScoreRangeClause($tableOptions, '');
268 $filterTextClause = $this->policy->buildFilterTextClause($sub, $tableOptions, true, $derivedPresent);
269
270 return "WHERE status IN (" . $statusTypes . ")\n"
271 . " " . $trashClause . "\n"
272 . " " . $scoreRangeClause . "\n"
273 . " " . $filterTextClause . "\n";
274 }
275
276 /**
277 * Effective (orderby column, direction) for the single-table read ORDER BY.
278 *
279 * On EITHER tab, a URL or Destination sort that cannot be served index-ordered
280 * right now (its narrow sort key is not ready: column missing, composite index
281 * missing, or the legacy-row drain not yet converged -- see
282 * RedirectsDenormSchemaReadiness::sortKeyReadyForColumn) would force a filesort over
283 * the wide source column (varchar(2048), prefix-only). On a large table that
284 * scan can exceed a shared host's max_statement_time, the server kills the
285 * query, and the tab is stuck on its loading placeholder every load. Until the
286 * sort key is ready we therefore serve the always-indexed safe default
287 * (timestamp DESC, "newest first") instead. This is display-only: the user's
288 * saved sort preference is left untouched and resumes automatically once the
289 * sort key is ready -- and the column header is rendered non-sortable with a
290 * progress tooltip meanwhile (captured tab: View_CapturedURLsTable; Page
291 * Redirects tab: ABJ_404_Solution_AdminTableColumnHeaders).
292 *
293 * Applied to BOTH tabs (the literal "no wide-column filesort" rule): the
294 * Page Redirects status filter does not make the wide-url filesort safe at
295 * scale, and the readiness predicate self-heals so the real sort resumes the
296 * moment the key is index-ordered.
297 *
298 * @param array<string, mixed> $tableOptions
299 * @param bool $derivedPresent
300 * @return array{orderby: string, order: string}
301 */
302 private function resolveEffectiveSort(array $tableOptions, bool $derivedPresent): array {
303 $rawOrderBy = strtolower(is_string($tableOptions['orderby'] ?? null) ? $tableOptions['orderby'] : '');
304 if ($this->wideColumnSortPendingBackfill($rawOrderBy, $tableOptions)) {
305 return array('orderby' => 'timestamp', 'order' => 'DESC');
306 }
307 return array(
308 'orderby' => $this->resolveSingleTableOrderByColumn($tableOptions, $derivedPresent),
309 'order' => $this->policy->resolveOrderDirection($tableOptions),
310 );
311 }
312
313 /**
314 * Whether the requested sort targets a narrow sort-key-backed column
315 * (url -> url_sort_key, dest -> dest_sort_key) that cannot be served
316 * index-ordered yet. The coordinator sets the matching
317 * _abj404_*_sort_key_present flag true only when the column exists AND its
318 * composite indexes exist AND its drain latch is set
319 * (RedirectsDenormSchemaReadiness::sortKeyReadyForColumn); a falsey flag means the
320 * only available ordering is a wide-column filesort. Sorts on real
321 * always-populated columns (logshits, last_used, score, code, type, status,
322 * timestamp) are never pending and are not substituted.
323 *
324 * @param string $rawOrderBy Lowercased requested orderby.
325 * @param array<string, mixed> $tableOptions
326 * @return bool
327 */
328 private function wideColumnSortPendingBackfill(string $rawOrderBy, array $tableOptions): bool {
329 if ($rawOrderBy === 'url') {
330 return empty($tableOptions['_abj404_url_sort_key_present']);
331 }
332 if ($rawOrderBy === 'dest' || $rawOrderBy === 'final_dest') {
333 return empty($tableOptions['_abj404_dest_sort_key_present']);
334 }
335 return false;
336 }
337
338 /**
339 * Resolve the ORDER BY column for the single-table read.
340 *
341 * A sort on a derived column (logshits / last_used / dest) uses the real
342 * column whenever the four denorm columns exist. The only fallback is
343 * schema-drift tolerance: when the columns are absent entirely (the column-add
344 * ALTER never completed) a derived sort would reference a missing column, so
345 * it falls back to the always-present native url column.
346 *
347 * No backfill-completion flag is consulted. Each derived column degrades
348 * gracefully for not-yet-backfilled rows by construction, so ordering on it is
349 * always meaningful and self-heals as rows are resolved:
350 * - logshits is NOT NULL and defaults to 0, so an un-backfilled row simply
351 * sorts as 0 hits and rises into place once the rollup is written;
352 * - last_used is NULL for no-hit rows, which sort last;
353 * - the dest_for_view ordering groups NULL/empty destinations last via its
354 * CASE expression (see ViewQueryPolicy::resolveOrderByColumn).
355 *
356 * The previous implementation gated these sorts on the obsolete denorm
357 * backfill-complete option and fell back to url order until it flipped. That
358 * option was flipped only when no row had dest_for_view NULL, a condition a
359 * live 404 site never reaches: every newly captured 404 is inserted with
360 * dest_for_view NULL, so the option stayed false forever and the Hits / Last
361 * Used / Destination columns were permanently sorted by url instead of by
362 * their own values (the rendered Hits column looked random).
363 *
364 * @param array<string, mixed> $tableOptions
365 * @param bool $derivedPresent Whether the four denorm columns exist on the
366 * table. False (schema drift) is the only case that forces the url fallback.
367 * @return string
368 */
369 private function resolveSingleTableOrderByColumn(array $tableOptions, bool $derivedPresent = true): string {
370 $rawOrderByValue = $tableOptions['orderby'] ?? '';
371 $rawOrderBy = strtolower(is_string($rawOrderByValue) ? $rawOrderByValue : '');
372 $derivedSorts = array('logshits', 'last_used', 'dest', 'final_dest');
373 if (in_array($rawOrderBy, $derivedSorts, true) && !$derivedPresent) {
374 return 'url';
375 }
376 // Destination sort: ORDER BY the narrow indexable dest_sort_key
377 // (uniform direction -> (disabled, dest_sort_key, id) /
378 // (status, disabled, dest_sort_key, id) serve it without a filesort) when
379 // the column exists. Blank/NULL destinations then sort naturally (first
380 // ascending, last descending) rather than the old always-last CASE, which
381 // was the very thing that forced a filesort (computed expression over a
382 // varchar(2048) prefix-only column). When the column is absent (an install
383 // mid-upgrade), fall back to the CASE-on-dest_for_view filesort so the
384 // sort still works -- just unindexed -- until the column-add completes.
385 if (($rawOrderBy === 'dest' || $rawOrderBy === 'final_dest')
386 && !empty($tableOptions['_abj404_dest_sort_key_present'])) {
387 return 'dest_sort_key';
388 }
389 // URL sort (incl. the Page Redirects default): ORDER BY the narrow
390 // indexable url_sort_key so (disabled, url_sort_key, id) /
391 // (status, disabled, url_sort_key, id) serve it without a filesort. url is
392 // varchar(2048), prefix-only, so ORDER BY url itself always filesorts even
393 // when every value is short (MySQL will not order by a prefix index). When
394 // the column is absent (an install mid-upgrade) fall back to raw url so the
395 // sort still works -- unindexed -- until the column-add completes. URLs
396 // longer than 191 chars sharing a 191-char prefix tie-break by id.
397 if ($rawOrderBy === 'url' && !empty($tableOptions['_abj404_url_sort_key_present'])) {
398 return 'url_sort_key';
399 }
400 return $this->policy->resolveOrderByColumn($tableOptions);
401 }
402
403 /**
404 * @param string $sub
405 * @param array<string, mixed> $tableOptions
406 * @return string
407 */
408 public function resolveStatusTypeList(string $sub, array $tableOptions): string {
409 return $this->policy->resolveStatusTypeList($sub, $tableOptions);
410 }
411
412 /**
413 * @param array<string, mixed> $tableOptions
414 * @return string
415 */
416 public function resolveOrderByColumn(array $tableOptions): string {
417 return $this->policy->resolveOrderByColumn($tableOptions);
418 }
419
420 }
421