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 / stats / StatusCountBuckets.php

StatusCountBuckets.php in 404 Solution trunk, at includes/stats/StatusCountBuckets.php

189 lines 6.9 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 * The single definition of what each admin status-count bucket means.
9 *
10 * Two consumers need the same answer and used to hold their own copy of it:
11 *
12 * 1. ABJ_404_Solution_StatusCountsRepository, which aggregates the buckets
13 * with SUM(CASE WHEN ...) over the redirects table;
14 * 2. the mutation paths, which adjust the cached buckets by a delta when a
15 * row changes status or is trashed / restored / deleted, so the tab a
16 * user just clicked reflects their own action without re-running the
17 * aggregate (the aggregate is deferred to cron for performance).
18 *
19 * Two copies of a bucket definition drift, and the drift is invisible: the
20 * incremental path and the recompute path would simply disagree, with the
21 * recompute silently correcting the delta minutes later. So both are derived
22 * from the maps below and nothing else defines a bucket.
23 *
24 * Bucket semantics, matching the aggregate exactly:
25 * - `all` counts rows with disabled = 0 whose status is in the scope;
26 * - a named per-status bucket counts rows with disabled = 0 and that status;
27 * - `trash` counts rows with disabled = 1 whose status is in the scope.
28 *
29 * A `disabled` value that is neither 0 nor 1 contributes to no bucket, which
30 * is what the SQL does too (it tests equality, not truthiness).
31 *
32 * Pure domain logic: no database access, no WordPress calls, no formatting.
33 */
34 class ABJ_404_Solution_StatusCountBuckets {
35
36 /** Redirect-tab scope: manual / automatic / regex redirects. */
37 const SCOPE_REDIRECTS = 'redirects';
38
39 /** Captured-tab scope: captured / ignored / later 404s. */
40 const SCOPE_CAPTURED = 'captured';
41
42 /** Bucket holding every non-disabled row in a scope. */
43 const BUCKET_ALL = 'all';
44
45 /** Bucket holding every disabled row in a scope. */
46 const BUCKET_TRASH = 'trash';
47
48 /**
49 * Every scope the cached status counts are kept for.
50 *
51 * @return array<int, string>
52 */
53 public static function scopes(): array {
54 return array(self::SCOPE_REDIRECTS, self::SCOPE_CAPTURED);
55 }
56
57 /**
58 * The statuses a scope covers, mapped to the bucket each one feeds.
59 *
60 * @param string $scope One of the SCOPE_* constants.
61 * @return array<int, string> status constant => bucket name. Empty for an unknown scope.
62 */
63 public static function bucketsByStatus(string $scope): array {
64 if ($scope === self::SCOPE_REDIRECTS) {
65 return array(
66 ABJ404_STATUS_MANUAL => 'manual',
67 ABJ404_STATUS_AUTO => 'auto',
68 ABJ404_STATUS_REGEX => 'regex',
69 );
70 }
71 if ($scope === self::SCOPE_CAPTURED) {
72 return array(
73 ABJ404_STATUS_CAPTURED => 'captured',
74 ABJ404_STATUS_IGNORED => 'ignored',
75 ABJ404_STATUS_LATER => 'later',
76 );
77 }
78 return array();
79 }
80
81 /**
82 * Every bucket name a scope's cached count array carries, in cache order.
83 *
84 * @param string $scope One of the SCOPE_* constants.
85 * @return array<int, string>
86 */
87 public static function bucketNames(string $scope): array {
88 $names = array(self::BUCKET_ALL);
89 foreach (self::bucketsByStatus($scope) as $bucket) {
90 $names[] = $bucket;
91 }
92 $names[] = self::BUCKET_TRASH;
93 return $names;
94 }
95
96 /**
97 * A scope's count array with every bucket at zero. Used as the shape a
98 * failed or empty aggregate falls back to, and as the delta accumulator.
99 *
100 * @param string $scope One of the SCOPE_* constants.
101 * @return array<string, int>
102 */
103 public static function zeroCounts(string $scope): array {
104 return array_fill_keys(self::bucketNames($scope), 0);
105 }
106
107 /**
108 * The count deltas implied by a set of rows moving from one (status,
109 * disabled) distribution to another.
110 *
111 * Histograms rather than individual rows, so one bulk mutation costs the
112 * same two GROUP BY reads as a single-row one. A row that only exists in
113 * `$before` was deleted; one that only exists in `$after` was inserted;
114 * one that appears in both under different keys changed status or was
115 * trashed / restored.
116 *
117 * @param array<int, array{status:int,disabled:int,count:int}> $before
118 * @param array<int, array{status:int,disabled:int,count:int}> $after
119 * @return array<string, array<string, int>> scope => bucket => signed delta.
120 * Scopes whose buckets all cancel out are omitted.
121 */
122 public static function delta(array $before, array $after): array {
123 $deltas = array();
124 foreach (self::scopes() as $scope) {
125 $deltas[$scope] = self::zeroCounts($scope);
126 }
127
128 foreach ($before as $row) {
129 self::accumulate($deltas, $row, -1);
130 }
131 foreach ($after as $row) {
132 self::accumulate($deltas, $row, 1);
133 }
134
135 foreach ($deltas as $scope => $buckets) {
136 if (count(array_filter($buckets)) === 0) {
137 unset($deltas[$scope]);
138 }
139 }
140 return $deltas;
141 }
142
143 /**
144 * The delta for a known number of rows entering (sign 1) or leaving
145 * (sign -1) one exact (status, disabled) cell. Callers that already know
146 * what they changed use this instead of reading a histogram.
147 *
148 * @param int $status
149 * @param int $disabled
150 * @param int $count Number of rows, always positive.
151 * @param int $sign 1 for rows added, -1 for rows removed.
152 * @return array<string, array<string, int>>
153 */
154 public static function deltaForRows(int $status, int $disabled, int $count, int $sign): array {
155 $row = array('status' => $status, 'disabled' => $disabled, 'count' => abs($count));
156 return $sign < 0 ? self::delta(array($row), array()) : self::delta(array(), array($row));
157 }
158
159 /**
160 * Fold one histogram cell into the accumulating deltas.
161 *
162 * @param array<string, array<string, int>> $deltas Modified in place.
163 * @param array{status:int,disabled:int,count:int} $row
164 * @param int $sign
165 */
166 private static function accumulate(array &$deltas, array $row, int $sign): void {
167 $status = isset($row['status']) ? (int)$row['status'] : -1;
168 $disabled = isset($row['disabled']) ? (int)$row['disabled'] : -1;
169 $count = isset($row['count']) ? (int)$row['count'] : 0;
170 if ($count <= 0) {
171 return;
172 }
173 $signed = $sign * $count;
174
175 foreach (self::scopes() as $scope) {
176 $statusBuckets = self::bucketsByStatus($scope);
177 if (!isset($statusBuckets[$status])) {
178 continue;
179 }
180 if ($disabled === 0) {
181 $deltas[$scope][self::BUCKET_ALL] += $signed;
182 $deltas[$scope][$statusBuckets[$status]] += $signed;
183 } else if ($disabled === 1) {
184 $deltas[$scope][self::BUCKET_TRASH] += $signed;
185 }
186 }
187 }
188 }
189