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 / logs / LogsHitsDataPopulator.php

LogsHitsDataPopulator.php in 404 Solution trunk, at includes/logs/LogsHitsDataPopulator.php

223 lines 8.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 * Joins pre-aggregated wp_abj404_logs_hits rollup data (logsid, logshits,
9 * last_used) onto a list of result rows by canonical URL.
10 *
11 * Real URLs arrive in many shapes (leading slash / no slash, trailing slash,
12 * raw / esc_url_raw'd, with or without query/fragment) so a direct equality
13 * lookup misses too often. This populator builds a small set of URL variants
14 * per row, queries the hits table in chunked IN() batches, then merges
15 * by canonical form.
16 *
17 * Extracted from LogsRepository under M201. Consumed by the LogsRepository
18 * facade, which also passes itself (as LogsRepositoryInterface) so that any
19 * test-time override of logsHitsTableExists / scheduleHitsTableRebuild on
20 * the facade (or a DataAccess fake DAO bridge) propagates into this
21 * populator's existence-check and missing-table-rebuild-scheduling paths.
22 */
23 class ABJ_404_Solution_LogsHitsDataPopulator {
24
25 /** @var ABJ_404_Solution_DatabaseCore */
26 private $dbCore;
27
28 public function __construct(ABJ_404_Solution_DatabaseCore $dbCore) {
29 $this->dbCore = $dbCore;
30 }
31
32 /**
33 * Populate logshits / logsid / last_used on each row from the
34 * wp_abj404_logs_hits rollup.
35 *
36 * @param array<int, array<string, mixed>> $rows
37 * @param ABJ_404_Solution_LogsRepositoryInterface $repo Source for logsHitsTableExists / scheduleHitsTableRebuild; passing the facade (rather than the raw rollup) ensures subclass overrides propagate.
38 * @return array<int, array<string, mixed>>
39 */
40 public function populate($rows, ABJ_404_Solution_LogsRepositoryInterface $repo) {
41 if (empty($rows)) {
42 return $rows;
43 }
44
45 $urls = array();
46 foreach ($rows as $row) {
47 if ($row['url'] != null && !empty($row['url'])) {
48 $variants = $this->buildHitsLookupUrlVariants($row['url']);
49 foreach ($variants as $variant) {
50 $urls[] = $variant;
51 }
52 }
53 }
54
55 if (empty($urls)) {
56 return $rows;
57 }
58
59 $urls = array_values(array_unique($urls));
60
61 if (!$repo->logsHitsTableExists()) {
62 $repo->scheduleHitsTableRebuild();
63 return $rows;
64 }
65
66 $logsHitsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
67 $batchSize = 200;
68 $logsResults = array();
69 $urlChunks = array_chunk($urls, $batchSize);
70
71 foreach ($urlChunks as $urlChunk) {
72 $placeholders = implode(',', array_fill(0, count($urlChunk), '%s'));
73 $sql = "SELECT requested_url, logsid, last_used, logshits "
74 . "FROM {$logsHitsTable} "
75 . "WHERE BINARY requested_url IN ($placeholders)";
76
77 $chunkResult = $this->dbCore->queryAndGetResults($sql, array(
78 'query_params' => $urlChunk,
79 'log_too_slow' => false,
80 ));
81
82 if (!empty($chunkResult['timed_out']) ||
83 (isset($chunkResult['last_error']) && $chunkResult['last_error'] != '')) {
84 $errRaw = $chunkResult['last_error'] ?? '';
85 $err = is_string($errRaw) ? $errRaw : '';
86 if ($err !== '' && strpos($err, 'logs_hits') !== false) {
87 $repo->scheduleHitsTableRebuild();
88 }
89 return $rows;
90 }
91
92 $chunkResults = is_array($chunkResult['rows'] ?? null) ? $chunkResult['rows'] : array();
93 if (!empty($chunkResults)) {
94 $logsResults = array_merge($logsResults, $chunkResults);
95 }
96 }
97
98 $logsDataByUrl = array();
99 foreach ($logsResults as $logRow) {
100 $canonicalUrl = $this->canonicalizeUrlForHitsMatch($logRow['requested_url'] ?? '');
101 if ($canonicalUrl === '') {
102 continue;
103 }
104 if (!isset($logsDataByUrl[$canonicalUrl])) {
105 $logsDataByUrl[$canonicalUrl] = array(
106 'logsid' => (int)($logRow['logsid'] ?? 0),
107 'logshits' => (int)($logRow['logshits'] ?? 0),
108 'last_used' => (int)($logRow['last_used'] ?? 0),
109 );
110 continue;
111 }
112 $existing = $logsDataByUrl[$canonicalUrl];
113 $currentLogsid = (int)($logRow['logsid'] ?? 0);
114 $existingLogsid = (int)$existing['logsid'];
115 $logsDataByUrl[$canonicalUrl]['logsid'] = ($existingLogsid > 0 && $currentLogsid > 0)
116 ? min($existingLogsid, $currentLogsid)
117 : max($existingLogsid, $currentLogsid);
118 $logsDataByUrl[$canonicalUrl]['logshits'] = (int)$existing['logshits'] + (int)($logRow['logshits'] ?? 0);
119 $logsDataByUrl[$canonicalUrl]['last_used'] = max((int)$existing['last_used'], (int)($logRow['last_used'] ?? 0));
120 }
121
122 foreach ($rows as &$row) {
123 if ($row['url'] != null && !empty($row['url'])) {
124 $canonicalUrl = $this->canonicalizeUrlForHitsMatch($row['url']);
125 if (isset($logsDataByUrl[$canonicalUrl])) {
126 $logData = $logsDataByUrl[$canonicalUrl];
127 $row['logsid'] = $logData['logsid'];
128 $row['logshits'] = $logData['logshits'];
129 $row['last_used'] = $logData['last_used'];
130 }
131 }
132 }
133
134 return $rows;
135 }
136
137 /** @param mixed $url @return string */
138 private function canonicalizeUrlForHitsMatch($url): string {
139 if (!is_string($url)) {
140 return '';
141 }
142 $url = trim($url);
143 if ($url === '') {
144 return '';
145 }
146 $fragment = '';
147 $fragmentPos = strpos($url, '#');
148 if ($fragmentPos !== false) {
149 $fragment = substr($url, $fragmentPos);
150 $url = substr($url, 0, $fragmentPos);
151 }
152 $query = '';
153 $queryPos = strpos($url, '?');
154 if ($queryPos !== false) {
155 $query = substr($url, $queryPos);
156 $url = substr($url, 0, $queryPos);
157 }
158 $path = trim($url, '/');
159 $normalizedPath = ($path === '') ? '/' : '/' . $path;
160 return $normalizedPath . $query . $fragment;
161 }
162
163 /** @param mixed $url @return array<int, string> */
164 private function buildHitsLookupUrlVariants($url) {
165 $variants = array();
166 if (!is_string($url)) {
167 return $variants;
168 }
169 $raw = trim($url);
170 if ($raw !== '') {
171 $variants[] = $raw;
172 }
173 $canonical = $this->canonicalizeUrlForHitsMatch($url);
174 if ($canonical !== '') {
175 $variants[] = $canonical;
176 $parts = $this->splitCanonicalHitsUrl($canonical);
177 $pathPart = $parts['path'];
178 $suffixPart = $parts['suffix'];
179 $pathVariants = array($pathPart);
180 $noLeadingPath = ltrim($pathPart, '/');
181 if ($noLeadingPath !== '') {
182 $pathVariants[] = $noLeadingPath;
183 }
184 if ($pathPart !== '/') {
185 if (substr($pathPart, -1) === '/') {
186 $toggleTrailingPath = rtrim($pathPart, '/');
187 } else {
188 $toggleTrailingPath = $pathPart . '/';
189 }
190 $pathVariants[] = $toggleTrailingPath;
191 $toggleNoLeadingPath = ltrim($toggleTrailingPath, '/');
192 if ($toggleNoLeadingPath !== '') {
193 $pathVariants[] = $toggleNoLeadingPath;
194 }
195 }
196 foreach (array_unique($pathVariants) as $pathVariant) {
197 $variants[] = $pathVariant . $suffixPart;
198 }
199 }
200 return array_values(array_unique($variants));
201 }
202
203 /** @return array{path: string, suffix: string} */
204 private function splitCanonicalHitsUrl(string $canonicalUrl): array {
205 $firstQueryPos = strpos($canonicalUrl, '?');
206 $firstFragmentPos = strpos($canonicalUrl, '#');
207 if ($firstQueryPos === false && $firstFragmentPos === false) {
208 return array('path' => $canonicalUrl, 'suffix' => '');
209 }
210 if ($firstQueryPos === false) {
211 $splitPos = $firstFragmentPos;
212 } elseif ($firstFragmentPos === false) {
213 $splitPos = $firstQueryPos;
214 } else {
215 $splitPos = min($firstQueryPos, $firstFragmentPos);
216 }
217 return array(
218 'path' => substr($canonicalUrl, 0, $splitPos),
219 'suffix' => substr($canonicalUrl, $splitPos),
220 );
221 }
222 }
223