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 / matching / EngineProfileRepository.php

EngineProfileRepository.php in 404 Solution trunk, at includes/matching/EngineProfileRepository.php

276 lines 11.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 * The engine-profile table: where profile rows live, and how they are read,
9 * written and deleted.
10 *
11 * Split out of ABJ_404_Solution_EngineProfileResolver, which had grown to hold
12 * three separate jobs -- deciding which engines run for a URL, memoizing that
13 * decision's input per blog, and owning every query against
14 * wp_abj404_engine_profiles. The first is business logic and the third is data
15 * access, and this codebase keeps those in different files. The resolver now
16 * asks this class for rows and never builds SQL.
17 *
18 * The resolver keeps two thin published accessors over this
19 * (getAllProfilesForAdmin / adminProfileListWasTruncated) because the admin
20 * AJAX handlers reach the profile subsystem through that singleton, which is
21 * also the seam their tests inject at. They carry no SQL and no policy; the
22 * table is owned here.
23 */
24 final class ABJ_404_Solution_EngineProfileRepository {
25
26 /**
27 * Ceiling on rows one admin profile-list request may read and return.
28 *
29 * Sized to be unreachable by the only thing that creates profiles -- an
30 * administrator saving them one at a time through the admin screen -- while
31 * still keeping a single request's work bounded by a number this code
32 * chooses rather than by whatever the table has grown to. See readAllForAdmin().
33 */
34 const MAX_ADMIN_PROFILES = 2000;
35
36 /** Ceiling on the active-profile read that feeds matching. */
37 const MAX_ACTIVE_PROFILES = 200;
38
39 /** @var ABJ_404_Solution_DatabaseCore|null */
40 private $dbCore = null;
41
42 /** @var bool Whether the last admin read filled MAX_ADMIN_PROFILES. */
43 private $adminReadTruncated = false;
44
45 /**
46 * Lazy dbCore accessor, deferring resolution until first use so unit tests
47 * that exercise pure-logic methods never boot the database layer.
48 *
49 * @return ABJ_404_Solution_DatabaseCore
50 */
51 private function dbCore() {
52 if ($this->dbCore === null) {
53 $this->dbCore = abj_service('db_core');
54 }
55 return $this->dbCore;
56 }
57
58 /**
59 * The fully-prefixed table name.
60 *
61 * Read from $wpdb->prefix on every call rather than memoized, because
62 * switch_to_blog() changes the prefix and this table is genuinely per-blog.
63 */
64 public function tableName(): string {
65 global $wpdb;
66 return strtolower($wpdb->prefix) . 'abj404_engine_profiles';
67 }
68
69 /**
70 * Active profiles for matching, priority first.
71 *
72 * @return array<int, object>
73 */
74 public function readActiveProfiles(): array {
75 $table = $this->tableName();
76 if ($this->tableIsAbsent($table)) {
77 return [];
78 }
79
80 $queryResult = $this->dbCore()->queryAndGetResults(
81 "SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`
82 FROM `{$table}`
83 WHERE `status` = 1
84 ORDER BY `priority` ASC, `id` ASC
85 LIMIT %d",
86 ['query_params' => [self::MAX_ACTIVE_PROFILES], 'result_type' => OBJECT]
87 );
88 $rows = $queryResult['rows'] ?? [];
89 if (!is_array($rows)) {
90 return [];
91 }
92 // Rebuilt as a list of objects so the declared return type is checked
93 // rather than asserted: the DAO answers array<mixed>.
94 $profiles = [];
95 foreach ($rows as $row) {
96 if (is_object($row)) {
97 $profiles[] = $row;
98 }
99 }
100
101 return $profiles;
102 }
103
104 /**
105 * Every profile, active or not, for the admin edit screen, up to a ceiling.
106 *
107 * The query used to have no LIMIT, so the work one authenticated admin
108 * request could cause was whatever the table happened to hold: every row
109 * materialised into PHP and encoded into a single JSON response. Profiles
110 * are only ever created one at a time through the admin screen, so no real
111 * site is near this -- but "nobody would do that" is not a bound, and this
112 * plugin has already shipped an out-of-memory failure from an unbounded
113 * load (4.3.0/4.3.1).
114 *
115 * A CEILING, deliberately not a page. This list feeds the screen that edits
116 * these rows, so quietly returning the first N would leave profiles that are
117 * live in matching but invisible and uneditable -- a worse failure than a
118 * slow query, and exactly the kind of silent partial answer this codebase
119 * treats as a defect elsewhere. Reaching the ceiling is recorded rather than
120 * absorbed: see lastAdminReadWasTruncated(), which the caller reports.
121 *
122 * @return array<int, array<string, mixed>>
123 */
124 public function readAllForAdmin(): array {
125 $this->adminReadTruncated = false;
126 $table = $this->tableName();
127
128 if ($this->tableIsAbsent($table)) {
129 return [];
130 }
131
132 $queryResult = $this->dbCore()->queryAndGetResults(
133 "SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status`
134 FROM `{$table}`
135 ORDER BY `priority` ASC, `id` ASC
136 LIMIT " . self::MAX_ADMIN_PROFILES
137 );
138 $rows = $queryResult['rows'] ?? [];
139 if (!is_array($rows)) {
140 return [];
141 }
142 $this->adminReadTruncated = count($rows) >= self::MAX_ADMIN_PROFILES;
143
144 // Rebuilt as a list of row arrays rather than returned as-is: the DAO
145 // answers array<mixed>, so handing it straight back made the declared
146 // return type a claim nothing checked.
147 $profiles = [];
148 foreach ($rows as $row) {
149 if (is_array($row)) {
150 $profiles[] = $row;
151 }
152 }
153
154 return $profiles;
155 }
156
157 /**
158 * Whether the last readAllForAdmin() call filled its ceiling.
159 *
160 * A truncated list is indistinguishable from a short one, so an admin whose
161 * profiles stopped appearing could not tell that from having deleted them.
162 */
163 public function lastAdminReadWasTruncated(): bool {
164 return $this->adminReadTruncated;
165 }
166
167 /**
168 * Insert or update a profile row.
169 *
170 * @param array<string, mixed> $data
171 * @return int|false Inserted/updated row ID, or false on failure.
172 */
173 public function insertOrUpdate(array $data) {
174 $table = $this->tableName();
175
176 $id = isset($data['id']) && is_numeric($data['id']) ? (int)$data['id'] : 0;
177 [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status]
178 = $this->normalizedColumns($data);
179
180 if ($id > 0) {
181 $queryResult = $this->dbCore()->queryAndGetResults(
182 "UPDATE `{$table}`
183 SET `name` = %s, `url_pattern` = %s, `is_regex` = %d,
184 `enabled_engines` = %s, `priority` = %d, `status` = %d
185 WHERE `id` = %d",
186 ['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status, $id]]
187 );
188 $updateError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
189 return $updateError === '' ? $id : false;
190 }
191
192 $queryResult = $this->dbCore()->queryAndGetResults(
193 "INSERT INTO `{$table}` (`name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status`)
194 VALUES (%s, %s, %d, %s, %d, %d)",
195 ['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status]]
196 );
197 $lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
198 if ($lastError !== '') {
199 return false;
200 }
201 $insertId = isset($queryResult['insert_id']) && is_scalar($queryResult['insert_id']) ? (int)$queryResult['insert_id'] : 0;
202 return $insertId > 0 ? $insertId : false;
203 }
204
205 /**
206 * Coerce one untrusted profile payload into the six column values.
207 *
208 * Lifted out of insertOrUpdate() because it was the whole of that method's
209 * branching: six independent isset/type ternaries plus the JSON check,
210 * which is what pushed one INSERT-or-UPDATE decision past the complexity
211 * ceiling. Returned positionally and immediately destructured at the single
212 * call site rather than as a keyed array, so the column ORDER stays stated
213 * once, next to the SQL that consumes it.
214 *
215 * @param array<string, mixed> $data
216 * @return array{0: string, 1: string, 2: int, 3: string, 4: int, 5: int}
217 */
218 private function normalizedColumns(array $data): array {
219 $name = isset($data['name']) ? sanitize_text_field(is_string($data['name']) ? $data['name'] : '') : '';
220 $urlPattern = isset($data['url_pattern']) ? wp_unslash(is_string($data['url_pattern']) ? $data['url_pattern'] : '') : '';
221 $isRegex = isset($data['is_regex']) ? (int)(bool)$data['is_regex'] : 0;
222 $enabledEngines = isset($data['enabled_engines']) && is_string($data['enabled_engines'])
223 ? $data['enabled_engines'] : '[]';
224 $priority = isset($data['priority']) && is_numeric($data['priority']) ? (int)$data['priority'] : 0;
225 $status = isset($data['status']) ? (int)(bool)$data['status'] : 1;
226
227 // enabled_engines is stored as a JSON array; anything else becomes one.
228 if (!is_array(json_decode($enabledEngines, true))) {
229 $enabledEngines = '[]';
230 }
231
232 return [(string)$name, (string)$urlPattern, $isRegex, $enabledEngines, $priority, $status];
233 }
234
235 /** Delete a profile by ID. */
236 public function delete(int $id): bool {
237 $table = $this->tableName();
238 $queryResult = $this->dbCore()->queryAndGetResults(
239 "DELETE FROM `{$table}` WHERE `id` = %d",
240 ['query_params' => [$id]]
241 );
242 $deleteError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
243 return $deleteError === '';
244 }
245
246 /**
247 * Whether the engine profiles table is known NOT to be there.
248 *
249 * SHOW TABLES LIKE returns no rows both for a table that is not there and
250 * for a probe that never ran, and the two answers must not share a code
251 * path: a false "absent" caches an empty profile set for the rest of the
252 * request, so one failed probe silently drops every custom search-engine
253 * profile out of matching (and out of the admin list) until the next
254 * request. last_error is what separates them, and an unanswerable probe
255 * lets the SELECT run so the centralized handler reports the fault.
256 */
257 private function tableIsAbsent(string $table): bool {
258 $queryResult = $this->dbCore()->queryAndGetResults(
259 'SHOW TABLES LIKE %s',
260 ['query_params' => [$table]]
261 );
262 $lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error'])
263 ? trim($queryResult['last_error']) : '';
264 if ($lastError !== '' || !empty($queryResult['timed_out'])) {
265 return false;
266 }
267 $rows = isset($queryResult['rows']) && is_array($queryResult['rows']) ? $queryResult['rows'] : [];
268 $first = $rows[0] ?? null;
269 if (!is_array($first)) {
270 return true;
271 }
272 $firstValue = reset($first);
273 return $firstValue !== $table;
274 }
275 }
276