PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 4.3.0, at includes/view-build/ViewQueryBuilder.php

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