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 / database / DataAccess.php

DataAccess.php in 404 Solution trunk, at includes/database/DataAccess.php

326 lines 13.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Composition root and singleton lifecycle for the database/repository/view
10 * collaborator graph.
11 *
12 * Exposes typed accessors so callers obtain a concrete collaborator
13 * (DatabaseCore, ContentRepository, RedirectsRepository,
14 * RedirectsRetentionService, LogsRepository, StatsRepository,
15 * ViewReadService) without depending on this class
16 * to dispatch unrelated work for them. Collaborators are resolved on demand
17 * by the plugin classmap autoloader registered in 404-solution.php
18 * (production) and tests/bootstrap.php (tests); manual require_once wiring
19 * at parse time is intentionally absent (see
20 * tests/DataAccessRequireTimeWiringTest.php for the structural guard).
21 *
22 * Three classes of legacy facade surface were removed by the 2026-06-06
23 * audit (M200) narrowing. Each was a test-only compatibility shim that
24 * recreated god-object coupling:
25 * 1. 33 constant re-exports from {LogsRepository, StatsRepository,
26 * RedirectsRepository, ViewReadRuntimeState, DatabaseRuntimeState}.
27 * Tests now reach the owning class directly.
28 * 2. getPostOrGetSanitize / getPostOrGetSanitizeUrl request-sanitization
29 * fallbacks. The authoritative implementation lives on
30 * RequestInputNormalizer; no production caller routed through
31 * DataAccess.
32 * 3. stageFailurePolicy() vestigial classifier marker. The staged
33 * view-build pipeline it classified for was itself removed (denorm
34 * Step 3e-A); the classifier and its policy config were deleted
35 * as dead code rather than re-exported here.
36 * The __call() rejector below is the structural guard preventing ad-hoc
37 * pass-throughs from being re-added.
38 */
39 class ABJ_404_Solution_DataAccess {
40
41 /** @var self|null */
42 private static $instance = null;
43
44 /** @var ABJ_404_Solution_DatabaseCore The extracted database infrastructure layer. */
45 private $dbCore;
46
47 /** @var ABJ_404_Solution_ContentRepository The extracted content/cache repository. */
48 private $contentRepo;
49
50 /** @var ABJ_404_Solution_RedirectsRepository The extracted redirects repository. */
51 private $redirectsRepo;
52
53 /** @var ABJ_404_Solution_RedirectsRetentionService|null Lazy-initialized retention workflow service. */
54 private $retentionService = null;
55
56 /** @var ABJ_404_Solution_LogsRepository The extracted logs repository. */
57 private $logsRepo;
58
59 /**
60 * @var ABJ_404_Solution_StatsRepository
61 *
62 * Test-only composition surface. Production code MUST resolve the stats
63 * repository through StatsRepositoryInterface (constructor-injected) or via
64 * ABJ_404_Solution_StatsRepositoryResolver::resolve(). Held on DataAccess
65 * exclusively so tests that construct a real (or stubbed-deps) DataAccess
66 * can drive the real StatsRepository against a custom DbCore/LogsRepo.
67 *
68 * Production prohibition is enforced by
69 * StatsRepositoryExtractionTest::testProductionCallersDoNotResolveStatsRepositoryThroughDataAccess.
70 * The 12 prior pass-through methods (getStatsCount, getPeriodicStatsSummary,
71 * getStatsDashboardSnapshot, refreshStatsDashboardSnapshot,
72 * getEarliestLogTimestamp, getTopCapturedForDigest,
73 * buildTopCapturedForDigestQuery, getDigestSummaryStats,
74 * getCapturedCountForNotification, getPostsNeedingContentKeywords,
75 * bulkUpdateContentKeywords, getPeriodicStatsSummariesCached) have been
76 * removed; see StatsRepositoryExtractionTest::testDataAccessNoLongerExposesStatsRepoPassThroughs.
77 */
78 private $statsRepo;
79
80 /** @var ABJ_404_Solution_ViewReadService The extracted view read service (Phase 6). */
81 private $viewReadService;
82
83 /** @var ABJ_404_Solution_Functions */
84 private $f;
85
86 /** @var ABJ_404_Solution_Logging */
87 private $logger;
88
89 /**
90 * @param ABJ_404_Solution_DataAccessDependencies|null $dependencies
91 * @throws InvalidArgumentException when legacy positional arguments are supplied.
92 */
93 public function __construct(?ABJ_404_Solution_DataAccessDependencies $dependencies = null) {
94 if (func_num_args() > 1) {
95 throw new InvalidArgumentException(
96 'DataAccess constructor accepts a DataAccessDependencies bundle; positional collaborator arguments were removed.'
97 );
98 }
99
100 $dependencies = $dependencies !== null ? $dependencies : new ABJ_404_Solution_DataAccessDependencies();
101 $this->f = self::resolveFunctions($dependencies->functions());
102 $this->logger = $this->resolveLogger($dependencies->logging());
103 $dbCore = $dependencies->dbCore();
104 $this->dbCore = $dbCore !== null ? $dbCore : $this->createDbCore();
105 $contentRepo = $dependencies->contentRepo();
106 if ($contentRepo !== null) {
107 $this->contentRepo = $contentRepo;
108 } else {
109 $this->contentRepo = new ABJ_404_Solution_ContentRepository($this->dbCore, $this->f, $this->logger);
110 }
111
112 $redirectsRepo = $dependencies->redirectsRepo();
113 if ($redirectsRepo !== null) {
114 $this->redirectsRepo = $redirectsRepo;
115 } else {
116 $this->redirectsRepo = new ABJ_404_Solution_RedirectsRepository($this->dbCore, $this->f, $this->logger);
117 }
118
119 $logsRepo = $dependencies->logsRepo();
120 $this->logsRepo = $logsRepo !== null
121 ? $logsRepo
122 : new ABJ_404_Solution_LogsRepository($this->dbCore, $this->f, $this->logger);
123
124 $statsRepo = $dependencies->statsRepo();
125 if ($statsRepo !== null) {
126 $this->statsRepo = $statsRepo;
127 } else {
128 $this->statsRepo = new ABJ_404_Solution_StatsRepository($this->dbCore, $this->logsRepo, $this->f, $this->logger);
129 }
130
131 $this->retentionService = $dependencies->retentionService();
132 $viewReadService = $dependencies->viewReadService();
133 if ($viewReadService !== null) {
134 $this->viewReadService = $viewReadService;
135 } else {
136 $this->viewReadService = new ABJ_404_Solution_ViewReadService(
137 $this->dbCore, $this->logsRepo, $this->redirectsRepo, $this->f, $this->logger
138 );
139 }
140 }
141
142 /**
143 * @param mixed $functions
144 * @return ABJ_404_Solution_Functions
145 */
146 private static function resolveFunctions($functions) {
147 if ($functions instanceof ABJ_404_Solution_Functions) {
148 return $functions;
149 }
150 return abj_service('functions');
151 }
152
153 /**
154 * @param mixed $logging
155 * @return ABJ_404_Solution_Logging
156 */
157 private function resolveLogger($logging) {
158 if ($logging instanceof ABJ_404_Solution_Logging) {
159 return $logging;
160 }
161 // Accept duck-typed test spy loggers (warn/errorMessage/debugMessage)
162 // so per-test log-level assertions can observe what production code
163 // dispatched. Without this, the strict instanceof check above silently
164 // drops the spy and DAO sub-services capture the production singleton
165 // instead, making warn() / errorMessage() invisible to the test.
166 // The chain below ($contentRepo, $redirectsRepo, $logsRepo,
167 // $statsRepo, $viewReadService, and DbCore
168 // including its recovery sub-services) accept untyped $logger
169 // parameters, so the spy reaches all of them.
170 if (is_object($logging) && method_exists($logging, 'warn') && method_exists($logging, 'errorMessage')) {
171 /** @var ABJ_404_Solution_Logging $logging */
172 return $logging;
173 }
174 return abj_service('logging');
175 }
176
177 /** @return ABJ_404_Solution_DatabaseCore */
178 private function createDbCore() {
179 return new ABJ_404_Solution_DatabaseCore($this->f, $this->logger);
180 }
181
182 /** @return ABJ_404_Solution_DatabaseCore */
183 public function getDbCore(): ABJ_404_Solution_DatabaseCore {
184 if ($this->dbCore === null) {
185 $this->dbCore = new ABJ_404_Solution_DatabaseCore($this->f, $this->logger);
186 }
187 return $this->dbCore;
188 }
189
190 /** @return ABJ_404_Solution_ContentRepository */
191 public function getContentRepo(): ABJ_404_Solution_ContentRepository {
192 if ($this->contentRepo === null) {
193 $this->contentRepo = new ABJ_404_Solution_ContentRepository($this->getDbCore(), $this->f, $this->logger);
194 }
195 return $this->contentRepo;
196 }
197
198 /** @return ABJ_404_Solution_RedirectsRepository */
199 public function getRedirectsRepo(): ABJ_404_Solution_RedirectsRepository {
200 if ($this->redirectsRepo === null) {
201 $this->redirectsRepo = new ABJ_404_Solution_RedirectsRepository($this->getDbCore(), $this->f, $this->logger);
202 }
203 return $this->redirectsRepo;
204 }
205
206 /** @return ABJ_404_Solution_RedirectsRetentionService */
207 public function getRetentionService(): ABJ_404_Solution_RedirectsRetentionService {
208 if ($this->retentionService === null) {
209 $this->retentionService = new ABJ_404_Solution_RedirectsRetentionService(
210 $this->dbCore !== null ? $this->dbCore : $this->getDbCore(),
211 $this->redirectsRepo !== null ? $this->redirectsRepo : $this->getRedirectsRepo(),
212 $this->f,
213 $this->logger
214 );
215 }
216 return $this->retentionService;
217 }
218
219 /** @return ABJ_404_Solution_LogsRepository */
220 public function getLogsRepo(): ABJ_404_Solution_LogsRepository {
221 if ($this->logsRepo === null) {
222 $this->logsRepo = new ABJ_404_Solution_LogsRepository($this->getDbCore(), $this->f, $this->logger);
223 }
224 return $this->logsRepo;
225 }
226
227 /**
228 * Test-only composition surface. Production callers must inject
229 * StatsRepositoryInterface via constructor or call
230 * ABJ_404_Solution_StatsRepositoryResolver::resolve(); using
231 * $dao->getStatsRepo() in includes/ is forbidden and is enforced by
232 * StatsRepositoryExtractionTest::testProductionCallersDoNotResolveStatsRepositoryThroughDataAccess
233 * (also catches the obfuscated 'get'.'StatsRepo' and quoted-string variants).
234 *
235 * Retained only as a composition exposure for tests that subclass
236 * DataAccess (e.g. StatsCacheInv_TestDAO, the makeRecordingDao() pattern in
237 * DataAccessQueryTimeoutAuditTest) and need the StatsRepository instance
238 * composed from the same private deps the test wired into DataAccess.
239 *
240 * @return ABJ_404_Solution_StatsRepositoryInterface
241 */
242 public function getStatsRepo(): ABJ_404_Solution_StatsRepositoryInterface {
243 if ($this->statsRepo === null) {
244 $this->statsRepo = new ABJ_404_Solution_StatsRepository($this->getDbCore(), $this->getLogsRepo(), $this->f, $this->logger);
245 }
246 return $this->statsRepo;
247 }
248
249 /** @return ABJ_404_Solution_ViewReadService */
250 public function getViewReadService(): ABJ_404_Solution_ViewReadService {
251 if ($this->viewReadService === null) {
252 $this->viewReadService = new ABJ_404_Solution_ViewReadService(
253 $this->getDbCore(), $this->getLogsRepo(), $this->getRedirectsRepo(), $this->f, $this->logger
254 );
255 }
256 return $this->viewReadService;
257 }
258
259 /**
260 * Rejects calls to removed facade pass-through methods.
261 *
262 * Extracted repositories and services are intentionally absent from a
263 * delegate chain: callers must use the typed accessor/injected interface
264 * for the owning collaborator instead of relying on this compatibility
265 * facade to redispatch arbitrary public methods.
266 *
267 * @param string $name
268 * @param array<int, mixed> $arguments
269 * @return mixed
270 * @throws \BadMethodCallException
271 */
272 public function __call(string $name, array $arguments) {
273 throw new \BadMethodCallException(
274 'Method ' . $name . '() not found on ' . static::class . ' or its sub-services.'
275 );
276 }
277
278 /**
279 * Return the current singleton instance without consulting the container
280 * or building a new one. Used by `abj_service()` to honor a test-installed
281 * singleton override without forcing the container to cache a stale
282 * binding. Mirrors the pattern on PluginLogic / Logging.
283 *
284 * @return self|null
285 */
286 public static function peekInstance() {
287 return self::$instance;
288 }
289
290 /**
291 * Install a singleton instance directly. Symmetric with `peekInstance()`;
292 * the canonical seam for tests that need to swap in a test double, and
293 * for callers that have already constructed a fully configured instance.
294 * Pass `null` to clear the cached singleton.
295 *
296 * @param self|null $instance
297 * @return void
298 */
299 public static function setInstance($instance) {
300 self::$instance = $instance;
301 }
302
303 /** @return self */
304 public static function getInstance() {
305 if (self::$instance !== null) {
306 return self::$instance;
307 }
308
309 // If the DI container is initialized, prefer it.
310 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
311 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('data_access');
312 if ($resolved instanceof self) {
313 self::$instance = $resolved;
314 return self::$instance;
315 }
316 }
317
318 // For backward compatibility, create with no arguments
319 // The constructor will use getInstance() for dependencies
320 self::$instance = new ABJ_404_Solution_DataAccess();
321
322 return self::$instance;
323 }
324
325 }
326