PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / ContentRepository.php

ContentRepository.php in 404 Solution 4.2.0, at includes/ContentRepository.php

510 lines 21.6 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 require_once __DIR__ . '/ContentRepositoryInterface.php';
8
9 /**
10 * Published-content lookups, permalink cache, and spelling cache operations.
11 *
12 * Extracted from the DataAccess monolith (Phase 1 of the DataAccess refactor).
13 * Methods originate from three sources:
14 * - DataAccessTrait_PublishedContent (entirely absorbed)
15 * - DataAccessTrait_Maintenance (permalink/spelling cache methods relocated)
16 * - DataAccessTrait_Stats (permalink cache update methods relocated)
17 *
18 * Receives a DatabaseCore instance for all query execution.
19 */
20 class ABJ_404_Solution_ContentRepository implements ABJ_404_Solution_ContentRepositoryInterface {
21
22 /** @var ABJ_404_Solution_DatabaseCore */
23 private $dbCore;
24
25 /** @var ABJ_404_Solution_Functions */
26 private $f;
27
28 /** @var ABJ_404_Solution_Logging */
29 private $logger;
30
31 /**
32 * @param ABJ_404_Solution_DatabaseCore $dbCore
33 * @param ABJ_404_Solution_Functions|null $functions
34 * @param ABJ_404_Solution_Logging|null $logging
35 */
36 public function __construct(
37 ABJ_404_Solution_DatabaseCore $dbCore,
38 $functions = null,
39 $logging = null
40 ) {
41 $this->dbCore = $dbCore;
42 $this->f = $functions !== null ? $functions : abj_service('functions');
43 $this->logger = $logging !== null ? $logging : abj_service('logging');
44 }
45
46 // =========================================================================
47 // Published content lookups (from DataAccessTrait_PublishedContent)
48 // =========================================================================
49
50 /** @return string */
51 private function getPostsTableName(): string {
52 global $wpdb;
53 if (isset($wpdb->posts) && is_string($wpdb->posts) && $wpdb->posts !== '') {
54 return $wpdb->posts;
55 }
56 $prefix = isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '' ? $wpdb->prefix : 'wp_';
57 return $prefix . 'posts';
58 }
59
60 /** @inheritDoc */
61 function getPublishedPagesAndPostsIDs($slug = '', $searchTerm = '',
62 $limitResults = '', $orderResults = '', $extraWhereClause = '') {
63 global $wpdb;
64 $abj404logic = abj_service('plugin_logic');
65 $postsTableName = $this->getPostsTableName();
66
67 $options = $abj404logic->getOptions();
68 $recognizedPostTypes = $this->dbCore->buildPostTypeSqlList($options);
69 if ($recognizedPostTypes === '') {
70 return array();
71 }
72
73 if (!$this->dbCore->tableExists($postsTableName)) {
74 $this->logger->errorMessage("WordPress posts table not found: " . $postsTableName .
75 ". This may indicate an incorrect table prefix or database configuration issue.");
76 return array();
77 }
78
79 if ($slug != "") {
80 $slug = $this->f->sanitizeInvalidUTF8($slug);
81
82 $collationResult = $this->dbCore->queryAndGetResults(
83 "SELECT COLLATION_NAME FROM INFORMATION_SCHEMA.COLUMNS
84 WHERE TABLE_SCHEMA = DATABASE()
85 AND TABLE_NAME = %s
86 AND COLUMN_NAME = 'post_name'",
87 array('query_params' => array($postsTableName), 'log_errors' => false)
88 );
89 $collationRows = isset($collationResult['rows']) && is_array($collationResult['rows']) ? $collationResult['rows'] : array();
90 $columnCollation = null;
91 if (!empty($collationRows) && is_array($collationRows[0])) {
92 $first = reset($collationRows[0]);
93 $columnCollation = is_scalar($first) ? (string)$first : null;
94 }
95 if ($columnCollation !== null && strpos(strtolower($columnCollation), 'utf8mb4') !== false) {
96 $resolvedCollation = $this->dbCore->sanitizeCollationIdentifier($columnCollation);
97 if ($resolvedCollation === '') {
98 $resolvedCollation = $this->dbCore->getPreferredUtf8mb4Collation();
99 }
100 $specifiedSlug = " */\n and CAST(wp_posts.post_name AS CHAR CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci = "
101 . "'" . esc_sql($slug) . "' \n ";
102 $specifiedSlug = str_replace('utf8mb4_unicode_ci', $resolvedCollation, $specifiedSlug);
103 } else {
104 // latin1 databases cannot safely compare utf8mb4 casts; use the native column comparison unless the slug contains 4-byte characters.
105 if ($this->f->containsUtf8mb4Characters($slug)) {
106 $specifiedSlug = '';
107 } else {
108 $specifiedSlug = " */\n and wp_posts.post_name = "
109 . "'" . esc_sql($slug) . "' \n ";
110 }
111 }
112 } else {
113 $specifiedSlug = '';
114 }
115
116 if ($searchTerm != "") {
117 $searchTerm = " */\n and lower(wp_posts.post_title) like "
118 . "'%" . esc_sql($this->f->strtolower($searchTerm)) . "%' \n ";
119 } else {
120 $searchTerm = '';
121 }
122
123 if ($extraWhereClause != "") {
124 $extraWhereClause = " */\n " . $extraWhereClause;
125 }
126
127 if (!empty($limitResults)) {
128 $limitResults = " */\n limit " . $limitResults;
129 }
130 if (!empty($orderResults)) {
131 $orderResults = " */\n order by " . $orderResults;
132 }
133
134 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedPagesAndPostsIDs.sql");
135 $query = $this->dbCore->doTableNameReplacements($query);
136 $query = $this->f->str_replace('{recognizedPostTypes}', $recognizedPostTypes, $query);
137 $query = $this->f->str_replace('{specifiedSlug}', $specifiedSlug, $query);
138 $query = $this->f->str_replace('{searchTerm}', $searchTerm, $query);
139 $query = $this->f->str_replace('{extraWhereClause}', $extraWhereClause, $query);
140 $query = $this->f->str_replace('{limit-results}', $limitResults, $query);
141 $query = $this->f->str_replace('{order-results}', $orderResults, $query);
142
143 $result = $this->dbCore->queryAndGetResults($query, array('result_type' => OBJECT));
144 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
145 $rows = is_array($result['rows']) ? $result['rows'] : array();
146
147 if (!empty($queryError) && $this->dbCore->isCollationError($queryError)) {
148 $fpreg = ABJ_404_Solution_FunctionsPreg::getInstance();
149 $fallbackQuery = $fpreg->regexReplace(
150 'CONVERT\(wpt\.name USING utf8mb4\) COLLATE [A-Za-z0-9_]+',
151 'wpt.name', $query);
152 $fallbackQuery = $fpreg->regexReplace(
153 'CONVERT\(usefulterms\.grouped_terms USING utf8mb4\) COLLATE [A-Za-z0-9_]+',
154 'usefulterms.grouped_terms', is_string($fallbackQuery) ? $fallbackQuery : $query);
155 $fallbackResult = $this->dbCore->queryAndGetResults(
156 is_string($fallbackQuery) ? $fallbackQuery : $query,
157 array('result_type' => OBJECT, 'log_errors' => false));
158 $queryError = is_string($fallbackResult['last_error'] ?? '') ? ($fallbackResult['last_error'] ?? '') : '';
159 if (empty($queryError)) {
160 $rows = is_array($fallbackResult['rows']) ? $fallbackResult['rows'] : array();
161 }
162 }
163
164 if (!empty($queryError) && $this->dbCore->isInvalidDataError($queryError) &&
165 $slug != "" && strpos($query, 'CAST(wp_posts.post_name AS CHAR CHARACTER SET utf8mb4)') !== false) {
166 $fallbackSpecifiedSlug = " */\n and wp_posts.post_name = '" . esc_sql($slug) . "' \n ";
167 $fallbackQuery = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedPagesAndPostsIDs.sql");
168 $fallbackQuery = $this->dbCore->doTableNameReplacements($fallbackQuery);
169 $fallbackQuery = $this->f->str_replace('{recognizedPostTypes}', $recognizedPostTypes, $fallbackQuery);
170 $fallbackQuery = $this->f->str_replace('{specifiedSlug}', $fallbackSpecifiedSlug, $fallbackQuery);
171 $fallbackQuery = $this->f->str_replace('{searchTerm}', $searchTerm, $fallbackQuery);
172 $fallbackQuery = $this->f->str_replace('{extraWhereClause}', $extraWhereClause, $fallbackQuery);
173 $fallbackQuery = $this->f->str_replace('{limit-results}', $limitResults, $fallbackQuery);
174 $fallbackQuery = $this->f->str_replace('{order-results}', $orderResults, $fallbackQuery);
175 $fallbackResult = $this->dbCore->queryAndGetResults($fallbackQuery, array('result_type' => OBJECT, 'log_errors' => false));
176 $fallbackError = is_string($fallbackResult['last_error'] ?? '') ? ($fallbackResult['last_error'] ?? '') : '';
177 if (empty($fallbackError)) {
178 $queryError = '';
179 $rows = is_array($fallbackResult['rows']) ? $fallbackResult['rows'] : array();
180 }
181 }
182
183 if ($queryError) {
184 if (stripos($queryError, 'unknown column') !== false &&
185 stripos($queryError, 'content_keywords') !== false) {
186 $this->logger->warn("content_keywords column not yet available (DB migration pending): " . $queryError);
187 } else if (!$this->dbCore->classifyAndHandleInfrastructureError($queryError)) {
188 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
189 }
190 }
191
192 return $rows;
193 }
194
195 /** @inheritDoc */
196 function getPublishedImagesIDs() {
197 global $wpdb;
198 $abj404logic = abj_service('plugin_logic');
199
200 $options = $abj404logic->getOptions();
201 $recognizedPostTypes = $this->dbCore->buildPostTypeSqlList($options);
202 if ($recognizedPostTypes === '') {
203 return array();
204 }
205
206 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedImageIDs.sql");
207 $query = $this->dbCore->doTableNameReplacements($query);
208 $query = $this->f->str_replace('{recognizedPostTypes}', $recognizedPostTypes, $query);
209
210 $result = $this->dbCore->queryAndGetResults($query, array('result_type' => OBJECT));
211 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
212 if ($queryError && !$this->dbCore->classifyAndHandleInfrastructureError($queryError)) {
213 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
214 }
215
216 return is_array($result['rows']) ? $result['rows'] : array();
217 }
218
219 /** @inheritDoc */
220 function getPublishedTags($slug = null, $limit = null) {
221 global $wpdb;
222 $abj404logic = abj_service('plugin_logic');
223
224 $options = $abj404logic->getOptions();
225 $recognizedCategories = $this->dbCore->buildCategorySqlList($options);
226
227 if ($slug != null) {
228 $slug = $this->f->sanitizeInvalidUTF8($slug);
229 $slug = "*/ and wp_terms.slug = '" . esc_sql($slug) . "'\n";
230 }
231
232 $limitClause = '';
233 if ($limit !== null && is_numeric($limit) && $limit > 0) {
234 $limitClause = "LIMIT " . intval($limit);
235 }
236
237 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedTags.sql");
238 $query = $this->f->str_replace('{slug}', $slug, $query);
239 $query = $this->f->str_replace('{limit}', $limitClause, $query);
240 $query = $this->dbCore->doTableNameReplacements($query);
241 $query = $this->f->str_replace('{recognizedCategories}', $recognizedCategories, $query);
242
243 $result = $this->dbCore->queryAndGetResults($query, array('result_type' => OBJECT));
244 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
245 if ($queryError && !$this->dbCore->classifyAndHandleInfrastructureError($queryError)) {
246 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
247 }
248 $rows = is_array($result['rows']) ? $result['rows'] : array();
249
250 $rows = $this->addURLToTermsRows($rows);
251
252 return $rows;
253 }
254
255 /** @inheritDoc */
256 function addURLToTermsRows($rows) {
257 global $wp_rewrite;
258 $extraPermaStructureCache = array();
259 foreach ($rows as $row) {
260 $taxonomy = isset($row->taxonomy) ? (string)$row->taxonomy : '';
261 if (!array_key_exists($taxonomy, $extraPermaStructureCache)) {
262 $extraPermaStructureCache[$taxonomy] = $wp_rewrite->get_extra_permastruct($taxonomy);
263 }
264 $struct = $extraPermaStructureCache[$taxonomy];
265
266 $slug = isset($row->slug) ? (string)$row->slug : '';
267 $url = str_replace('%' . $taxonomy . '%', $slug, $struct);
268
269 /** @var \stdClass $row */
270 $row->url = $url;
271 }
272
273 return $rows;
274 }
275
276 /** @inheritDoc */
277 function getPublishedCategories($term_id = null, $slug = null, $limit = null) {
278 global $wpdb;
279 $abj404logic = abj_service('plugin_logic');
280
281 $options = $abj404logic->getOptions();
282 $recognizedCategories = $this->dbCore->buildCategorySqlList($options);
283 if ($recognizedCategories === '') {
284 $recognizedCategories = "''";
285 }
286
287 if ($term_id != null) {
288 $term_id = "*/ and {wp_terms}.term_id = " . intval($term_id) . "\n";
289 }
290
291 if ($slug != null) {
292 $slug = $this->f->sanitizeInvalidUTF8($slug);
293 $slug = "*/ and {wp_terms}.slug = '" . esc_sql($slug) . "'\n";
294 }
295
296 $limitClause = '';
297 if ($limit !== null && is_numeric($limit) && $limit > 0) {
298 $limitClause = "LIMIT " . intval($limit);
299 }
300
301 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPublishedCategories.sql");
302 $query = $this->f->str_replace('{recognizedCategories}', $recognizedCategories, $query);
303 $query = $this->f->str_replace('{term_id}', $term_id !== null ? (string)$term_id : '', $query);
304 $query = $this->f->str_replace('{slug}', $slug, $query);
305 $query = $this->f->str_replace('{limit}', $limitClause, $query);
306 $query = $this->dbCore->doTableNameReplacements($query);
307
308 $result = $this->dbCore->queryAndGetResults($query, array('result_type' => OBJECT));
309 $queryError = is_string($result['last_error'] ?? '') ? ($result['last_error'] ?? '') : '';
310 if ($queryError && !$this->dbCore->classifyAndHandleInfrastructureError($queryError)) {
311 $this->logger->errorMessage("Error executing query. Err: " . $queryError . ", Query: " . $query);
312 }
313 $rows = is_array($result['rows']) ? $result['rows'] : array();
314
315 $rows = $this->addURLToTermsRows($rows);
316
317 return $rows;
318 }
319
320 // =========================================================================
321 // Permalink cache (from DataAccessTrait_Maintenance + DataAccessTrait_Stats)
322 // =========================================================================
323
324 /** @inheritDoc */
325 function truncatePermalinkCacheTable(): void {
326 $query = "truncate table {wp_abj404_permalink_cache}";
327 $this->dbCore->queryAndGetResults($query);
328
329 abj_service('ngram_filter')->invalidateCoverageCaches();
330 }
331
332 /** @inheritDoc */
333 function removeFromPermalinkCache(int $post_id): void {
334 $query = "delete from {wp_abj404_permalink_cache} where id = %d";
335 $this->dbCore->queryAndGetResults($query, array('query_params' => array($post_id)));
336
337 abj_service('ngram_filter')->invalidateCoverageCaches();
338 }
339
340 /** @inheritDoc */
341 function getPermalinkFromCache($id) {
342 $id = absint($id);
343 $query = "select url from {wp_abj404_permalink_cache} where id = " . $id;
344 $results = $this->dbCore->queryAndGetResults($query);
345
346 $rows = is_array($results['rows']) ? $results['rows'] : array();
347 if (empty($rows)) {
348 return null;
349 }
350
351 $row1 = is_array($rows[0] ?? null) ? $rows[0] : array();
352 return isset($row1['url']) && is_string($row1['url']) ? $row1['url'] : null;
353 }
354
355 /** @inheritDoc */
356 function getPermalinksByIds(array $ids) {
357 if (empty($ids)) {
358 return array();
359 }
360 $sanitized = array_map('absint', $ids);
361 $placeholders = implode(',', $sanitized);
362 $query = "select id, url from {wp_abj404_permalink_cache} where id in (" . $placeholders . ")";
363 $query = $this->dbCore->doTableNameReplacements($query);
364 $results = $this->dbCore->queryAndGetResults($query);
365 return is_array($results['rows']) ? $results['rows'] : array();
366 }
367
368 /** @inheritDoc */
369 function getPermalinkEtcFromCache($id) {
370 $id = absint($id);
371 $query = "select id, url, meta, url_length, post_parent from {wp_abj404_permalink_cache} where id = " . $id;
372 $results = $this->dbCore->queryAndGetResults($query);
373
374 $rows = is_array($results['rows']) ? $results['rows'] : array();
375 if (empty($rows)) {
376 return null;
377 }
378
379 return is_array($rows[0] ?? null) ? $rows[0] : null;
380 }
381
382 /** @inheritDoc */
383 function getIDsNeededForPermalinkCache() {
384 $abj404logic = abj_service('plugin_logic');
385
386 $options = $abj404logic->getOptions();
387 $recognizedPostTypes = $this->dbCore->buildPostTypeSqlList($options);
388 if ($recognizedPostTypes === '') {
389 return null;
390 }
391
392 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getIDsNeededForPermalinkCache.sql");
393 $query = $this->f->str_replace('{recognizedPostTypes}', $recognizedPostTypes, $query);
394
395 $results = $this->dbCore->queryAndGetResults($query);
396
397 /** @var array<int, array<string, mixed>>|null $rows */
398 $rows = $results['rows'];
399 return $rows;
400 }
401
402 /** @inheritDoc */
403 function updatePermalinkCache() {
404 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ .
405 "/sql/updatePermalinkCache.sql");
406
407 $this->dbCore->setSqlBigSelects();
408
409 $results = $this->dbCore->queryAndGetResults($query);
410
411 return $results;
412 }
413
414 /** @inheritDoc */
415 function updatePermalinkCacheParentPages() {
416 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ .
417 "/sql/updatePermalinkCacheParentPages.sql");
418
419 $depthSoFar = 0;
420 $results = array();
421 do {
422 $results = $this->dbCore->queryAndGetResults($query);
423 $depthSoFar++;
424 } while ($results['rows_affected'] != 0 && $depthSoFar < 15);
425
426 return $results;
427 }
428
429 /** @inheritDoc */
430 function getPermalinkCacheCount(): int {
431 $table = $this->dbCore->doTableNameReplacements('{wp_abj404_permalink_cache}');
432 return $this->dbCore->queryScalarInt("SELECT COUNT(*) FROM `{$table}`");
433 }
434
435 // =========================================================================
436 // Spelling cache (from DataAccessTrait_Maintenance)
437 // =========================================================================
438
439 /** @inheritDoc */
440 function storeSpellingPermalinksToCache(string $requestedURLRaw, $returnValue): void {
441 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/insertSpellingCache.sql");
442
443 $cleanURL = $this->f->sanitizeInvalidUTF8($requestedURLRaw);
444
445 $query = $this->f->str_replace('{url}', esc_sql($cleanURL), $query);
446 $jsonEncoded = json_encode($returnValue);
447 $query = $this->f->str_replace('{matchdata}', esc_sql(is_string($jsonEncoded) ? $jsonEncoded : ''), $query);
448
449 $this->dbCore->queryAndGetResults($query);
450 }
451
452 /**
453 * @cache-write-audit: opt-out -- spelling_cache is itself the cache;
454 * SpellChecker recomputes lookups on demand from {wp_abj404_redirects}
455 * and {wp_abj404_permalink_cache}, neither of which derives a transient
456 * from spelling_cache rows.
457 *
458 * @inheritDoc
459 */
460 function getSpellingPermalinksFromCache(string $requestedURLRaw) {
461 $requestedURLRaw = $this->f->sanitizeInvalidUTF8($requestedURLRaw);
462 $query = "select id, url, matchdata from {wp_abj404_spelling_cache} where url = '" . esc_sql($requestedURLRaw) . "'";
463 $results = $this->dbCore->queryAndGetResults($query);
464
465 $rows = is_array($results['rows']) ? $results['rows'] : array();
466
467 if (empty($rows)) {
468 return array();
469 }
470
471 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
472 $json = isset($row['matchdata']) && is_string($row['matchdata']) ? $row['matchdata'] : '';
473 $returnValue = json_decode($json, true);
474
475 return $returnValue;
476 }
477
478 /** @inheritDoc */
479 function deleteSpellingCache(): void {
480 // @cache-write-audit: opt-out - spelling cache table is itself the cache being invalidated.
481 $query = "truncate table {wp_abj404_spelling_cache}";
482 $this->dbCore->queryAndGetResults($query);
483 }
484
485 // =========================================================================
486 // Old slug lookup (from DataAccessTrait_Maintenance)
487 // =========================================================================
488
489 /** @inheritDoc */
490 function getOldSlug($post_id) {
491 $post_id = absint($post_id);
492
493 $query = "select meta_value from {wp_postmeta} \nwhere post_id = {post_id} " .
494 " and meta_key = '_wp_old_slug' \n" .
495 " order by meta_id desc";
496 $query = $this->f->str_replace('{post_id}', (string)$post_id, $query);
497
498 $results = $this->dbCore->queryAndGetResults($query);
499
500 $rows = $results['rows'];
501 if ($rows == null || empty($rows)) {
502 return null;
503 }
504
505 $rows = is_array($rows) ? $rows : array();
506 $row = is_array($rows[0] ?? null) ? $rows[0] : array();
507 return isset($row['meta_value']) && is_string($row['meta_value']) ? $row['meta_value'] : null;
508 }
509 }
510