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 / repositories / PublishedContentRepository.php

PublishedContentRepository.php in 404 Solution trunk, at includes/repositories/PublishedContentRepository.php

481 lines 20.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 // allow-no-test-found: covered by tests/ContentRepositoryDecompositionTest.php through ContentRepository facade entry points.
8
9 require_once __DIR__ . '/../database/DatabaseCollationHelper.php';
10 require_once __DIR__ . '/TermUrlEnricher.php';
11
12 /**
13 * Reads published posts, pages, images, tags, and categories from WordPress tables.
14 */
15 class ABJ_404_Solution_PublishedContentRepository {
16
17 /** @var ABJ_404_Solution_DatabaseCore */
18 private $dbCore;
19
20 /** @var ABJ_404_Solution_Functions */
21 private $f;
22
23 /** @var ABJ_404_Solution_Logging */
24 private $logger;
25
26 /** @var mixed Options provider exposing getOptions(): array. */
27 private $optionsProvider;
28
29 /** @var ABJ_404_Solution_DatabaseErrorClassifier */
30 private $errorClassifier;
31
32 /** @var ABJ_404_Solution_DatabaseCollationHelper */
33 private $collationHelper;
34
35 /** @var ABJ_404_Solution_TermUrlEnricher */
36 private $termUrlEnricher;
37
38 /**
39 * @param ABJ_404_Solution_DatabaseCore $dbCore
40 * @param ABJ_404_Solution_Functions $functions
41 * @param ABJ_404_Solution_Logging $logging
42 * @param mixed $optionsProvider Object exposing getOptions(): array.
43 * @param ABJ_404_Solution_DatabaseErrorClassifier $errorClassifier
44 * @param ABJ_404_Solution_DatabaseCollationHelper $collationHelper
45 * @param ABJ_404_Solution_TermUrlEnricher|null $termUrlEnricher
46 */
47 public function __construct(
48 ABJ_404_Solution_DatabaseCore $dbCore,
49 $functions,
50 $logging,
51 $optionsProvider,
52 $errorClassifier,
53 $collationHelper,
54 $termUrlEnricher = null
55 ) {
56 $this->dbCore = $dbCore;
57 $this->f = $functions;
58 $this->logger = $logging;
59 $this->optionsProvider = $optionsProvider;
60 $this->errorClassifier = $errorClassifier;
61 $this->collationHelper = $collationHelper;
62 $this->termUrlEnricher = $termUrlEnricher !== null ? $termUrlEnricher : new ABJ_404_Solution_TermUrlEnricher();
63 }
64
65 /** @return string */
66 private function getPostsTableName(): string {
67 global $wpdb;
68 if (isset($wpdb->posts) && is_string($wpdb->posts) && $wpdb->posts !== '') {
69 return $wpdb->posts;
70 }
71 $prefix = isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '' ? $wpdb->prefix : 'wp_';
72 return $prefix . 'posts';
73 }
74
75 /** @return array<string, mixed> */
76 private function getRuntimeOptions(): array {
77 $provider = $this->optionsProvider !== null ? $this->optionsProvider : abj_service('options_repository');
78 if (is_object($provider) && method_exists($provider, 'getOptions')) {
79 $options = $provider->getOptions();
80 return is_array($options) ? $options : array();
81 }
82 return array();
83 }
84
85 /**
86 * Find published posts and pages using named query criteria.
87 * Unknown keys and non-scalar values are ignored for forward compatibility.
88 *
89 * @param array{slug?: string, search_term?: string, limit_results?: string, order_results?: string, extra_where_clause?: string} $criteria
90 * @return array<int, object>
91 */
92 public function getPublishedPagesAndPostsIDs(array $criteria = array()) {
93 $postsTableName = $this->getPostsTableName();
94
95 $slug = $this->publishedCriteriaString($criteria, 'slug');
96 $searchTerm = $this->publishedCriteriaString($criteria, 'search_term');
97 $limitResults = $this->publishedCriteriaString($criteria, 'limit_results');
98 $orderResults = $this->publishedCriteriaString($criteria, 'order_results');
99 $extraWhereClause = $this->publishedCriteriaString($criteria, 'extra_where_clause');
100
101 $options = $this->getRuntimeOptions();
102 $recognizedPostTypes = $this->dbCore->tableNameResolver()->buildPostTypeSqlList($options);
103 if ($recognizedPostTypes === '') {
104 return array();
105 }
106
107 $slugClause = $this->buildPostSlugClause($slug, $postsTableName);
108 $queryParts = array(
109 'recognizedPostTypes' => $recognizedPostTypes,
110 'specifiedSlug' => $slugClause['clause'],
111 'searchTerm' => $this->buildPostSearchClause($searchTerm),
112 'extraWhereClause' => $this->buildExtraWhereClause($extraWhereClause),
113 'limitResults' => $this->buildLimitClause($limitResults),
114 'orderResults' => $this->buildOrderClause($orderResults),
115 );
116 $query = $this->buildPublishedPagesQuery($queryParts);
117
118 $result = $this->dbCore->queryAndGetResults($query, array('result_type' => OBJECT));
119 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
120 $rows = $this->objectRows($result['rows'] ?? array());
121
122 $fallback = $this->applyPublishedPagesFallbacks($query, $queryError, $rows, $slugClause, $queryParts);
123 $this->handlePublishedPagesQueryError($fallback['queryError'], $query);
124
125 return $fallback['rows'];
126 }
127
128 /**
129 * @param array<string, mixed> $criteria
130 * @param string $key
131 * @return string
132 */
133 private function publishedCriteriaString(array $criteria, string $key): string {
134 $value = $criteria[$key] ?? '';
135 return is_scalar($value) ? (string)$value : '';
136 }
137
138 /**
139 * @param string $slug
140 * @param string $postsTableName
141 * @return array{slug: string, clause: string}
142 */
143 private function buildPostSlugClause($slug, string $postsTableName): array {
144 if ($slug == "") {
145 return array('slug' => '', 'clause' => '');
146 }
147
148 $cleanSlug = $this->f->sanitizeInvalidUTF8($slug);
149 $columnCollation = $this->getPostNameColumnCollation($postsTableName);
150 // The clause below pins CHARACTER SET utf8mb4, so it may only be emitted
151 // when the column's collation belongs to that family; otherwise the two
152 // halves disagree and the engine rejects the read with errno 1253.
153 if ($columnCollation !== null
154 && ABJ_404_Solution_DatabaseCollationHelper::isUtf8mb4Collation($columnCollation)) {
155 // Interpolated directly rather than substituted into a placeholder
156 // collation afterwards: the slug is already embedded by then, so a
157 // slug containing the placeholder's text would be rewritten too.
158 // isUtf8mb4Collation() has already established this sanitizes to a
159 // non-empty utf8mb4 name, so there is no empty case left to handle.
160 $resolvedCollation = $this->collationHelper->sanitizeCollationIdentifier($columnCollation);
161 $clause = " */\n and CAST(wp_posts.post_name AS CHAR CHARACTER SET utf8mb4) COLLATE " . $resolvedCollation . " = "
162 . "'" . esc_sql($cleanSlug) . "' \n ";
163 return array('slug' => $cleanSlug, 'clause' => $clause);
164 }
165
166 if (abj_service('sanitizer')->containsUtf8mb4Characters($cleanSlug)) {
167 return array('slug' => $cleanSlug, 'clause' => '');
168 }
169
170 return array(
171 'slug' => $cleanSlug,
172 'clause' => " */\n and wp_posts.post_name = '" . esc_sql($cleanSlug) . "' \n ",
173 );
174 }
175
176 /** @return string|null */
177 private function getPostNameColumnCollation(string $postsTableName) {
178 $collationResult = $this->dbCore->queryAndGetResults(
179 "SELECT COLLATION_NAME FROM INFORMATION_SCHEMA.COLUMNS
180 WHERE TABLE_SCHEMA = DATABASE()
181 AND TABLE_NAME = %s
182 AND COLUMN_NAME = 'post_name'",
183 array('query_params' => array($postsTableName), 'log_errors' => false)
184 );
185 $collationRows = isset($collationResult['rows']) && is_array($collationResult['rows']) ? $collationResult['rows'] : array();
186 if (empty($collationRows) || !is_array($collationRows[0])) {
187 return null;
188 }
189
190 $first = reset($collationRows[0]);
191 return is_scalar($first) ? (string)$first : null;
192 }
193
194 /** @param string $searchTerm @return string */
195 private function buildPostSearchClause($searchTerm): string {
196 if ($searchTerm == "") {
197 return '';
198 }
199
200 // Strip control characters and validate UTF-8 before SQL escaping.
201 // Pattern 10: defense-in-depth against invalid-UTF-8 bytes reaching MySQL.
202 $sanitized = sanitize_text_field($searchTerm);
203 return " */\n and lower(wp_posts.post_title) like "
204 . "'%" . esc_sql($this->f->strtolower($sanitized)) . "%' \n ";
205 }
206
207 /** @param string $extraWhereClause @return string */
208 private function buildExtraWhereClause($extraWhereClause): string {
209 return $extraWhereClause != "" ? " */\n " . $extraWhereClause : '';
210 }
211
212 /** @param string $limitResults @return string */
213 private function buildLimitClause($limitResults): string {
214 return !empty($limitResults) ? " */\n limit " . $limitResults : '';
215 }
216
217 /** @param string $orderResults @return string */
218 private function buildOrderClause($orderResults): string {
219 return !empty($orderResults) ? " */\n order by " . $orderResults : '';
220 }
221
222 /**
223 * @param array{recognizedPostTypes: string, specifiedSlug: string, searchTerm: string, extraWhereClause: string, limitResults: string, orderResults: string} $queryParts
224 * @return string
225 */
226 private function buildPublishedPagesQuery(array $queryParts): string {
227 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getPublishedPagesAndPostsIDs.sql");
228 $query = $this->dbCore->doTableNameReplacements($query);
229 $query = $this->f->str_replace('{recognizedPostTypes}', $queryParts['recognizedPostTypes'], $query);
230 $query = $this->f->str_replace('{specifiedSlug}', $queryParts['specifiedSlug'], $query);
231 $query = $this->f->str_replace('{searchTerm}', $queryParts['searchTerm'], $query);
232 $query = $this->f->str_replace('{extraWhereClause}', $queryParts['extraWhereClause'], $query);
233 $query = $this->f->str_replace('{limit-results}', $queryParts['limitResults'], $query);
234 $query = $this->f->str_replace('{order-results}', $queryParts['orderResults'], $query);
235 return $query;
236 }
237
238 /**
239 * @param string $query
240 * @param string $queryError
241 * @param array<int, object> $rows
242 * @param array{slug: string, clause: string} $slugClause
243 * @param array{recognizedPostTypes: string, specifiedSlug: string, searchTerm: string, extraWhereClause: string, limitResults: string, orderResults: string} $queryParts
244 * @return array{queryError: string, rows: array<int, object>}
245 */
246 private function applyPublishedPagesFallbacks(
247 string $query,
248 string $queryError,
249 array $rows,
250 array $slugClause,
251 array $queryParts
252 ): array {
253 $fallback = $this->applyCollationFallback($query, $queryError, $rows);
254 return $this->applyInvalidDataSlugFallback($query, $fallback['queryError'], $fallback['rows'], $slugClause, $queryParts);
255 }
256
257 /**
258 * @param string $query
259 * @param string $queryError
260 * @param array<int, object> $rows
261 * @return array{queryError: string, rows: array<int, object>}
262 */
263 private function applyCollationFallback(string $query, string $queryError, array $rows): array {
264 if (empty($queryError) || !$this->errorClassifier->taxonomy()->schema()->isCollationError($queryError)) {
265 return array('queryError' => $queryError, 'rows' => $rows);
266 }
267
268 $fpreg = ABJ_404_Solution_FunctionsPreg::getInstance();
269 $fallbackQuery = $fpreg->regexReplace(
270 'CONVERT\(wpt\.name USING utf8mb4\) COLLATE [A-Za-z0-9_]+',
271 'wpt.name',
272 $query
273 );
274 $fallbackQuery = $fpreg->regexReplace(
275 'CONVERT\(usefulterms\.grouped_terms USING utf8mb4\) COLLATE [A-Za-z0-9_]+',
276 'usefulterms.grouped_terms',
277 is_string($fallbackQuery) ? $fallbackQuery : $query
278 );
279 $fallbackResult = $this->dbCore->queryAndGetResults(
280 is_string($fallbackQuery) ? $fallbackQuery : $query,
281 array('result_type' => OBJECT, 'log_errors' => false)
282 );
283 $fallbackError = is_string($fallbackResult['last_error'] ?? '') ? ($fallbackResult['last_error'] ?? '') : '';
284 if (!empty($fallbackError)) {
285 return array('queryError' => $fallbackError, 'rows' => $rows);
286 }
287
288 return array('queryError' => '', 'rows' => $this->objectRows($fallbackResult['rows'] ?? array()));
289 }
290
291 /**
292 * @param string $query
293 * @param string $queryError
294 * @param array<int, object> $rows
295 * @param array{slug: string, clause: string} $slugClause
296 * @param array{recognizedPostTypes: string, specifiedSlug: string, searchTerm: string, extraWhereClause: string, limitResults: string, orderResults: string} $queryParts
297 * @return array{queryError: string, rows: array<int, object>}
298 */
299 private function applyInvalidDataSlugFallback(
300 string $query,
301 string $queryError,
302 array $rows,
303 array $slugClause,
304 array $queryParts
305 ): array {
306 if (empty($queryError) || !$this->errorClassifier->taxonomy()->schema()->isInvalidDataError($queryError) ||
307 $slugClause['slug'] === '' ||
308 strpos($query, 'CAST(wp_posts.post_name AS CHAR CHARACTER SET utf8mb4)') === false) {
309 return array('queryError' => $queryError, 'rows' => $rows);
310 }
311
312 $fallbackParts = $queryParts;
313 // @utf8-audit: opt-out - $slugClause['slug'] is an internal post_name string already
314 // selected from wp_posts (the same column we're comparing it against), not user input.
315 $fallbackParts['specifiedSlug'] = " */\n and wp_posts.post_name = '" . esc_sql($slugClause['slug']) . "' \n ";
316 $fallbackResult = $this->dbCore->queryAndGetResults(
317 $this->buildPublishedPagesQuery($fallbackParts),
318 array('result_type' => OBJECT, 'log_errors' => false)
319 );
320 $fallbackError = is_string($fallbackResult['last_error'] ?? '') ? ($fallbackResult['last_error'] ?? '') : '';
321 if (!empty($fallbackError)) {
322 return array('queryError' => $queryError, 'rows' => $rows);
323 }
324
325 return array('queryError' => '', 'rows' => $this->objectRows($fallbackResult['rows'] ?? array()));
326 }
327
328 private function handlePublishedPagesQueryError(string $queryError, string $query): void {
329 if ($queryError === '') {
330 return;
331 }
332
333 if (stripos($queryError, 'unknown column') !== false &&
334 stripos($queryError, 'content_keywords') !== false) {
335 $this->logger->warn("content_keywords column not yet available (DB migration pending): " . $queryError);
336 return;
337 }
338
339 if (!$this->errorClassifier->classifyAndHandleInfrastructureError($queryError)) {
340 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
341 }
342 }
343
344 /**
345 * @param mixed $rows
346 * @return array<int, object>
347 */
348 private function objectRows($rows): array {
349 if (!is_array($rows)) {
350 return array();
351 }
352
353 $objects = array();
354 foreach ($rows as $row) {
355 if (is_object($row)) {
356 $objects[] = $row;
357 }
358 }
359 return $objects;
360 }
361
362 /**
363 * @param string|null $slug
364 * @param int|null $limit
365 * @return array<int, object>
366 */
367 public function getPublishedTags($slug = null, $limit = null) {
368 $options = $this->getRuntimeOptions();
369 $recognizedCategories = $this->dbCore->tableNameResolver()->buildCategorySqlList($options);
370
371 if ($slug != null) {
372 $slug = $this->f->sanitizeInvalidUTF8($slug);
373 $slug = "*/ and wp_terms.slug = '" . esc_sql($slug) . "'\n";
374 }
375
376 $limitClause = '';
377 if ($limit !== null && is_numeric($limit) && $limit > 0) {
378 $limitClause = "LIMIT " . intval($limit);
379 }
380
381 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getPublishedTags.sql");
382 $query = $this->f->str_replace('{slug}', $slug, $query);
383 $query = $this->f->str_replace('{limit}', $limitClause, $query);
384 $query = $this->dbCore->doTableNameReplacements($query);
385 $query = $this->f->str_replace('{recognizedCategories}', $recognizedCategories, $query);
386
387 $result = $this->dbCore->queryAndGetResults($query, array('result_type' => OBJECT));
388 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
389 if ($queryError && !$this->errorClassifier->classifyAndHandleInfrastructureError($queryError)) {
390 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
391 }
392 $rows = $this->objectRows($result['rows'] ?? array());
393
394 return $this->termUrlEnricher->addURLToTermsRows($rows);
395 }
396
397 /**
398 * Cheap published-tag count using the SAME taxonomy filter as
399 * getPublishedTags(), but COUNT(*) only (no rows loaded). Feeds the term
400 * n-gram coverage readiness gate.
401 *
402 * @return int
403 */
404 public function getPublishedTagCount(): int {
405 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getPublishedTagCount.sql");
406 $query = $this->dbCore->doTableNameReplacements($query);
407 return $this->dbCore->queryScalarInt($query, array('log_errors' => false));
408 }
409
410 /**
411 * Cheap published-category count using the SAME taxonomy filter as
412 * getPublishedCategories(), but COUNT(*) only (no rows loaded). Feeds the
413 * term n-gram coverage readiness gate.
414 *
415 * @return int
416 */
417 public function getPublishedCategoryCount(): int {
418 $options = $this->getRuntimeOptions();
419 $recognizedCategories = $this->dbCore->tableNameResolver()->buildCategorySqlList($options);
420 if ($recognizedCategories === '') {
421 $recognizedCategories = "''";
422 }
423 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getPublishedCategoryCount.sql");
424 $query = $this->f->str_replace('{recognizedCategories}', $recognizedCategories, $query);
425 $query = $this->dbCore->doTableNameReplacements($query);
426 return $this->dbCore->queryScalarInt($query, array('log_errors' => false));
427 }
428
429 /**
430 * @param array<int, object> $rows
431 * @return array<int, object>
432 */
433 public function addURLToTermsRows($rows) {
434 return $this->termUrlEnricher->addURLToTermsRows($rows);
435 }
436
437 /**
438 * @param int|null $term_id
439 * @param string|null $slug
440 * @param int|null $limit
441 * @return array<int, object>
442 */
443 public function getPublishedCategories($term_id = null, $slug = null, $limit = null) {
444 $options = $this->getRuntimeOptions();
445 $recognizedCategories = $this->dbCore->tableNameResolver()->buildCategorySqlList($options);
446 if ($recognizedCategories === '') {
447 $recognizedCategories = "''";
448 }
449
450 if ($term_id != null) {
451 $term_id = "*/ and {wp_terms}.term_id = " . intval($term_id) . "\n";
452 }
453
454 if ($slug != null) {
455 $slug = $this->f->sanitizeInvalidUTF8($slug);
456 $slug = "*/ and {wp_terms}.slug = '" . esc_sql($slug) . "'\n";
457 }
458
459 $limitClause = '';
460 if ($limit !== null && is_numeric($limit) && $limit > 0) {
461 $limitClause = "LIMIT " . intval($limit);
462 }
463
464 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getPublishedCategories.sql");
465 $query = $this->f->str_replace('{recognizedCategories}', $recognizedCategories, $query);
466 $query = $this->f->str_replace('{term_id}', $term_id !== null ? (string)$term_id : '', $query);
467 $query = $this->f->str_replace('{slug}', $slug, $query);
468 $query = $this->f->str_replace('{limit}', $limitClause, $query);
469 $query = $this->dbCore->doTableNameReplacements($query);
470
471 $result = $this->dbCore->queryAndGetResults($query, array('result_type' => OBJECT));
472 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
473 if ($queryError && !$this->errorClassifier->classifyAndHandleInfrastructureError($queryError)) {
474 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
475 }
476 $rows = $this->objectRows($result['rows'] ?? array());
477
478 return $this->termUrlEnricher->addURLToTermsRows($rows);
479 }
480 }
481