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

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

268 lines 7.4 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 * Typed value object for a row returned by `getRedirectsByIDs()` (and other
9 * `SELECT ... FROM {prefix}_abj404_redirects` queries that project the same
10 * columns).
11 *
12 * Boundary normalizer (task: type-pressure at module boundaries).
13 *
14 * The redirect row is produced by the DAO (`DataAccessTrait_Stats::getRedirectsByIDs`
15 * and DAO siblings) and consumed by the admin form renderer
16 * (`ViewTrait_Redirects::echoEditRedirectFields` etc.) and the runtime
17 * pipeline (`FrontendRequestPipeline::dispatchRedirect`). Without this VO
18 * each consumer re-implemented its own shape probing:
19 *
20 * - `is_scalar($redirect['id'] ?? '') ? (string)... : ''`
21 * - `is_string($redirect['url'] ?? '') ? (string)... : ''`
22 * - `isset($redirect['start_ts']) && is_numeric(...) ? (int)... : 0`
23 * - `is_scalar($redirect['type']) ? (int)... : 0`
24 *
25 * Drift between the DAO's projection and a consumer's expected shape is the
26 * same class of defect that produced the URL-cache-key bug chain. The VO
27 * pulls every shape probe into one place. Producers pass raw rows through
28 * `fromRaw()`; consumers read fields via accessors that already enforce the
29 * contract.
30 *
31 * Schema (after normalization):
32 *
33 * - id : int >= 0
34 * - url : string
35 * - type : int (one of ABJ404_TYPE_*; 0 if absent / unparseable)
36 * - status : int (one of ABJ404_STATUS_*; 0 if absent / unparseable)
37 * - finalDest : string ('0' if absent)
38 * - code : string (HTTP code; '' if absent)
39 * - engine : string (engine name or '' if absent)
40 * - startTs : int >= 0 (0 = no schedule)
41 * - endTs : int >= 0 (0 = no schedule)
42 *
43 * Construct via `fromRaw()` on the consumer side; the DAO continues to
44 * return raw arrays for backwards compatibility (typed accessors layer on
45 * top, no producer rewrite required).
46 */
47 final class ABJ_404_Solution_RedirectRow {
48
49 /** @var int */
50 private $id;
51
52 /** @var string */
53 private $url;
54
55 /** @var int */
56 private $type;
57
58 /** @var int */
59 private $status;
60
61 /** @var string */
62 private $finalDest;
63
64 /** @var string */
65 private $code;
66
67 /** @var string */
68 private $engine;
69
70 /** @var int */
71 private $startTs;
72
73 /** @var int */
74 private $endTs;
75
76 private function __construct(
77 int $id,
78 string $url,
79 int $type,
80 int $status,
81 string $finalDest,
82 string $code,
83 string $engine,
84 int $startTs,
85 int $endTs
86 ) {
87 $this->id = $id;
88 $this->url = $url;
89 $this->type = $type;
90 $this->status = $status;
91 $this->finalDest = $finalDest;
92 $this->code = $code;
93 $this->engine = $engine;
94 $this->startTs = $startTs;
95 $this->endTs = $endTs;
96 }
97
98 /**
99 * Normalize a raw DB row into a typed VO, or null when the payload is
100 * unrecoverably malformed (not an array, or missing both id and url so
101 * the row cannot be acted on).
102 *
103 * Callers MUST NOT shape-probe the raw payload themselves.
104 *
105 * @param mixed $raw
106 */
107 public static function fromRaw($raw): ?self {
108 if (!is_array($raw)) {
109 return null;
110 }
111 $hasId = array_key_exists('id', $raw);
112 $hasUrl = array_key_exists('url', $raw);
113 if (!$hasId && !$hasUrl) {
114 return null;
115 }
116
117 $id = self::coerceNonNegativeInt($raw, 'id');
118 $url = self::coerceString($raw, 'url');
119 $type = self::coerceInt($raw, 'type');
120 $status = self::coerceInt($raw, 'status');
121 $finalDest = self::coerceFinalDest($raw);
122 $code = self::coerceString($raw, 'code');
123 $engine = trim(self::coerceString($raw, 'engine'));
124 $startTs = self::coerceNonNegativeInt($raw, 'start_ts');
125 $endTs = self::coerceNonNegativeInt($raw, 'end_ts');
126
127 return new self($id, $url, $type, $status, $finalDest, $code, $engine, $startTs, $endTs);
128 }
129
130 /**
131 * @param array<int, array<string, mixed>> $rows Raw rows from the DAO.
132 * @return array<int, self> Normalized rows; malformed rows are skipped.
133 */
134 public static function fromRawList(array $rows): array {
135 $result = array();
136 foreach ($rows as $row) {
137 $vo = self::fromRaw($row);
138 if ($vo !== null) {
139 $result[] = $vo;
140 }
141 }
142 return $result;
143 }
144
145 public function getId(): int {
146 return $this->id;
147 }
148
149 public function getUrl(): string {
150 return $this->url;
151 }
152
153 public function getType(): int {
154 return $this->type;
155 }
156
157 public function getStatus(): int {
158 return $this->status;
159 }
160
161 public function isRegex(): bool {
162 return defined('ABJ404_STATUS_REGEX') && $this->status === (int)ABJ404_STATUS_REGEX;
163 }
164
165 public function isExternal(): bool {
166 return defined('ABJ404_TYPE_EXTERNAL') && $this->type === (int)ABJ404_TYPE_EXTERNAL;
167 }
168
169 public function isFourOhFourDisplayed(): bool {
170 return defined('ABJ404_TYPE_404_DISPLAYED') && $this->type === (int)ABJ404_TYPE_404_DISPLAYED;
171 }
172
173 /** Stored verbatim. May be a numeric post ID or a URL (external redirect). */
174 public function getFinalDest(): string {
175 return $this->finalDest;
176 }
177
178 public function hasFinalDest(): bool {
179 $t = trim($this->finalDest);
180 return $t !== '' && $t !== '0';
181 }
182
183 public function getCode(): string {
184 return $this->code;
185 }
186
187 public function getEngine(): string {
188 return $this->engine;
189 }
190
191 public function getStartTs(): int {
192 return $this->startTs;
193 }
194
195 public function getEndTs(): int {
196 return $this->endTs;
197 }
198
199 /**
200 * @param array<mixed, mixed> $raw
201 */
202 private static function coerceString(array $raw, string $key): string {
203 if (!isset($raw[$key])) {
204 return '';
205 }
206 $v = $raw[$key];
207 if (is_string($v)) {
208 return $v;
209 }
210 if (is_scalar($v)) {
211 return (string)$v;
212 }
213 return '';
214 }
215
216 /**
217 * @param array<mixed, mixed> $raw
218 */
219 private static function coerceInt(array $raw, string $key): int {
220 if (!isset($raw[$key])) {
221 return 0;
222 }
223 $v = $raw[$key];
224 if (is_int($v)) {
225 return $v;
226 }
227 if (is_float($v)) {
228 return (int)$v;
229 }
230 if (is_string($v) && is_numeric($v)) {
231 return (int)$v;
232 }
233 if (is_bool($v)) {
234 return $v ? 1 : 0;
235 }
236 return 0;
237 }
238
239 /**
240 * @param array<mixed, mixed> $raw
241 */
242 private static function coerceNonNegativeInt(array $raw, string $key): int {
243 $i = self::coerceInt($raw, $key);
244 return $i < 0 ? 0 : $i;
245 }
246
247 /**
248 * `final_dest` is stored as a string column but historically populated
249 * with both integer post-IDs and external URLs. Old rows can have an
250 * absent/null/0 value.
251 *
252 * @param array<mixed, mixed> $raw
253 */
254 private static function coerceFinalDest(array $raw): string {
255 if (!isset($raw['final_dest'])) {
256 return '0';
257 }
258 $v = $raw['final_dest'];
259 if (is_string($v)) {
260 return $v;
261 }
262 if (is_scalar($v)) {
263 return (string)$v;
264 }
265 return '0';
266 }
267 }
268