Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.06% covered (success)
92.06%
174 / 189
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
UniversalPersistenceManager
92.02% covered (success)
92.02%
173 / 188
72.22% covered (warning)
72.22%
13 / 18
75.71
0.00% covered (danger)
0.00%
0 / 1
 clearColumnCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPdo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insert
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
10.51
 update
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
5
 normalizeUpdatePayload
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 syncToClientIfNeeded
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
10.75
 isValueChanged
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 isSemanticallyEqual
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
7
 toBoolean
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 updateRecordStatus
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
4
 updateSpecialAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 assertNotSystemRecord
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 executeInTransaction
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 fetchSnapshot
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 resolveSnapshotColumns
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 isNonPhysicalField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Persistence;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Exception\PermissionDeniedException;
12use App\Core\Engine\Domain\Exception\RecordNotFoundException;
13use App\Core\Engine\Domain\Model\FieldMetadata;
14use App\Core\Engine\Domain\Model\ModuleMetadata;
15use App\Core\Engine\Domain\Model\PermissionContext;
16use PDO;
17use PDOException;
18use Throwable;
19
20/**
21 * Universal Persistence Manager.
22 *
23 * Handles all write operations (INSERT, UPDATE, DELETE) for any module
24 * using the module metadata to determine the target table. All operations
25 * run inside PDO transactions to ensure atomicity.
26 * Fetches pre-change snapshots for audit logging before mutations.
27 *
28 * @package App\Core\Engine\Application\Persistence
29 */
30final class UniversalPersistenceManager
31{
32    /** @var array<string, string> In-memory cache of column projection expressions per table name. */
33    private static array $tableColumnsCache = [];
34
35    /**
36     * Clears in-memory table columns cache (primarily for tests).
37     */
38    public static function clearColumnCache(): void
39    {
40        self::$tableColumnsCache = [];
41    }
42
43    /**
44     * UniversalPersistenceManager constructor.
45     *
46     * @param PDO $pdo Database connection.
47     */
48    public function __construct(
49        private readonly PDO $pdo,
50        private readonly ?PDO $clientPdo = null,
51        private readonly string $tablePrefix = 'a_'
52    ) {
53    }
54
55    /**
56     * Returns the underlying PDO database connection.
57     *
58     * @return PDO Database connection handle.
59     */
60    public function getPdo(): PDO
61    {
62        return $this->pdo;
63    }
64
65    /**
66     * Inserts a new record into the module table and returns the new record ID.
67     *
68     * Automatically injects created_by and owner from the PermissionContext.
69     * Runs inside a PDO transaction.
70     *
71     * @param ModuleMetadata            $module  Module metadata with table info.
72     * @param array<string, mixed>      $data    Validated and transformed field data.
73     * @param PermissionContext          $context Security context with actor ID.
74     * @return int The primary key of the newly created record.
75     * @throws PDOException On database write failure.
76     */
77    public function insert(ModuleMetadata $module, array $data, PermissionContext $context): int
78    {
79        $data['created_by'] = $context->getAuditActorUserId();
80        if (!isset($data['owner']) || $data['owner'] === null || $data['owner'] === '' || (int)$data['owner'] === 0) {
81            $data['owner'] = $context->actorUserId;
82        } else {
83            $data['owner'] = (int)$data['owner'];
84        }
85        unset(
86            $data['is_favorite'],
87            $data['structures']
88        );
89
90        if (isset($data['hidden_views'])) {
91            if (is_array($data['hidden_views'])) {
92                $views = array_filter(array_map('trim', $data['hidden_views']));
93                $data['hidden_views'] = !empty($views) ? implode(',', $views) : null;
94            } elseif ($data['hidden_views'] === '') {
95                $data['hidden_views'] = null;
96            }
97        }
98
99        $columns = array_keys($data);
100        $placeholders = array_map(static fn(string $col): string => ':' . $col, $columns);
101
102        $sql = sprintf(
103            'INSERT INTO `%s` (%s) VALUES (%s)',
104            $module->tableName,
105            '`' . implode('`, `', $columns) . '`',
106            implode(', ', $placeholders)
107        );
108
109        return $this->executeInTransaction(function () use ($sql, $data): int {
110            $stmt = $this->pdo->prepare($sql);
111            foreach ($data as $key => $value) {
112                $stmt->bindValue(':' . $key, $value);
113            }
114            $stmt->execute();
115            return (int) $this->pdo->lastInsertId();
116        });
117    }
118
119    /**
120     * Updates only the changed fields of an existing record.
121     *
122     * Fetches the current snapshot before updating for diff computation.
123     * Only fields present in $data are updated; other fields remain unchanged.
124     *
125     * @param ModuleMetadata            $module Module metadata with table info.
126     * @param int                       $id     Primary key of the record to update.
127     * @param array<string, mixed>      $data   Validated changed field values only.
128     * @param array<int, FieldMetadata> $fields Optional field metadata definitions.
129     * @return array<string, array{old: mixed, new: mixed}> Field-level diff for audit.
130     * @throws RecordNotFoundException When the record does not exist.
131     * @throws PDOException On database write failure.
132     */
133    public function update(ModuleMetadata $module, int $id, array $data, array $fields = []): array
134    {
135        $data = $this->normalizeUpdatePayload($data);
136
137        $snapshot = $this->fetchSnapshot($module, $id, $fields);
138        $this->assertNotSystemRecord($snapshot, $module, $id, 'modified');
139        $diff     = [];
140
141        foreach ($data as $key => $newValue) {
142            $oldValue = $snapshot[$key] ?? null;
143            if ($this->isValueChanged($oldValue, $newValue)) {
144                $diff[$key] = [
145                    'old' => $oldValue,
146                    'new' => $newValue,
147                ];
148            }
149        }
150
151        if ($diff === []) {
152            return [];
153        }
154
155        $setClauses = [];
156        $params     = [':id' => $id];
157
158        foreach ($diff as $column => $change) {
159            $paramName = ':set_' . str_replace('-', '_', $column);
160            $setClauses[] = sprintf('`%s` = %s', $column, $paramName);
161            $params[$paramName] = $change['new'];
162        }
163
164        $sql = sprintf(
165            'UPDATE `%s` SET %s WHERE `%s` = :id',
166            $module->tableName,
167            implode(', ', $setClauses),
168            $module->primaryKey
169        );
170
171        $this->executeInTransaction(function () use ($sql, $params): void {
172            $stmt = $this->pdo->prepare($sql);
173            $stmt->execute($params);
174        });
175
176        $this->syncToClientIfNeeded($module, $sql, $params);
177
178        return $diff;
179    }
180
181    /**
182     * Normalizes and sanitizes the update payload fields.
183     *
184     * @param array<string, mixed> $data Raw update payload.
185     * @return array<string, mixed> Normalized payload.
186     */
187    private function normalizeUpdatePayload(array $data): array
188    {
189        unset(
190            $data['id'],
191            $data['created_by'],
192            $data['created_at'],
193            $data['is_favorite'],
194            $data['structures']
195        );
196        if (isset($data['hidden_views'])) {
197            if (is_array($data['hidden_views'])) {
198                $views = array_filter(array_map('trim', $data['hidden_views']));
199                $data['hidden_views'] = !empty($views) ? implode(',', $views) : null;
200            } elseif ($data['hidden_views'] === '') {
201                $data['hidden_views'] = null;
202            }
203        }
204        if (isset($data['owner'])) {
205            $data['owner'] = (int)$data['owner'];
206        }
207
208        return $data;
209    }
210
211    /**
212     * Synchronizes core settings updates to client database if applicable.
213     *
214     * @param ModuleMetadata $module Module metadata.
215     * @param string $sql SQL query string.
216     * @param array<string, mixed> $params Query parameters.
217     */
218    private function syncToClientIfNeeded(ModuleMetadata $module, string $sql, array $params): void
219    {
220        if ($this->clientPdo !== null && $module->tableName === $this->tablePrefix . 'core_settings_records') {
221            try {
222                $stmtClient = $this->clientPdo->prepare($sql);
223                $stmtClient->execute($params);
224            } catch (Throwable) {
225                // Non-blocking sync to client context database
226            }
227        }
228    }
229
230    /**
231     * Determines whether two field values represent a genuine semantic change.
232     *
233     * @param mixed $oldValue Current value from database snapshot.
234     * @param mixed $newValue Incoming mutated value after input transformation.
235     * @return bool True if the value genuinely changed, false otherwise.
236     */
237    private function isValueChanged(mixed $oldValue, mixed $newValue): bool
238    {
239        if ($oldValue === $newValue) {
240            return false;
241        }
242
243        $isOldEmpty = ($oldValue === null || $oldValue === '');
244        $isNewEmpty = ($newValue === null || $newValue === '');
245        if ($isOldEmpty && $isNewEmpty) {
246            return false;
247        }
248
249        return !$this->isSemanticallyEqual($oldValue, $newValue);
250    }
251
252    private function isSemanticallyEqual(mixed $oldValue, mixed $newValue): bool
253    {
254        if (is_numeric($oldValue) && is_numeric($newValue)) {
255            return (string) $oldValue === (string) $newValue;
256        }
257        if (is_bool($oldValue) || is_bool($newValue)) {
258            return $this->toBoolean($oldValue) === $this->toBoolean($newValue);
259        }
260        return is_array($oldValue) && is_array($newValue) && $oldValue === $newValue;
261    }
262
263    private function toBoolean(mixed $val): bool
264    {
265        return is_bool($val) ? $val : in_array($val, [1, '1', 'true'], true);
266    }
267
268    /**
269     * Deletes a record by primary key.
270     *
271     * Fetches the snapshot before deletion for audit trail recording.
272     * Runs inside a PDO transaction.
273     *
274     * @param ModuleMetadata            $module Module metadata with table info.
275     * @param int                       $id     Primary key of record to delete.
276     * @param array<int, FieldMetadata> $fields Optional field metadata definitions.
277     * @return array<string, mixed> Snapshot of deleted record for audit trail.
278     * @throws RecordNotFoundException When record does not exist.
279     * @throws PDOException On database write failure.
280     */
281    public function delete(ModuleMetadata $module, int $id, array $fields = []): array
282    {
283        $snapshot = $this->fetchSnapshot($module, $id, $fields);
284        $this->assertNotSystemRecord($snapshot, $module, $id, 'deleted');
285
286        $sql = sprintf(
287            'DELETE FROM `%s` WHERE `%s` = :id',
288            $module->tableName,
289            $module->primaryKey
290        );
291
292        return $this->executeInTransaction(function () use ($sql, $id, $snapshot): array {
293            $stmt = $this->pdo->prepare($sql);
294            $stmt->bindValue(':id', $id, PDO::PARAM_INT);
295            $stmt->execute();
296            return $snapshot;
297        });
298    }
299
300    /**
301     * Changes the lifecycle status of a record (1=Available, 2=Archived, 3=Deleted).
302     *
303     * @param ModuleMetadata            $module    Module metadata.
304     * @param int                       $id        Record primary key.
305     * @param int                       $newStatus Target lifecycle status code.
306     * @param array<int, FieldMetadata> $fields    Optional field metadata definitions.
307     * @return array<string, mixed> Snapshot of record.
308     */
309    public function updateRecordStatus(
310        ModuleMetadata $module,
311        int            $id,
312        int            $newStatus,
313        array          $fields = []
314    ): array {
315        $snapshot = $this->fetchSnapshot($module, $id, $fields);
316        $this->assertNotSystemRecord($snapshot, $module, $id, 'transitioned to another status');
317
318        $col = 'special_access';
319        foreach ($fields as $f) {
320            if ($f->fieldKey === 'special_access') {
321                $col = 'special_access';
322                break;
323            }
324            if ($f->fieldKey === 'record_status') {
325                $col = 'record_status';
326            }
327        }
328
329        $sql = sprintf(
330            'UPDATE `%s` SET `%s` = :status WHERE `%s` = :id',
331            $module->tableName,
332            $col,
333            $module->primaryKey
334        );
335
336        return $this->executeInTransaction(function () use ($sql, $id, $newStatus, $snapshot): array {
337            $stmt = $this->pdo->prepare($sql);
338            $stmt->bindValue(':status', $newStatus, PDO::PARAM_INT);
339            $stmt->bindValue(':id', $id, PDO::PARAM_INT);
340            $stmt->execute();
341            return $snapshot;
342        });
343    }
344
345    /**
346     * Updates special access lifecycle code (alias for updateRecordStatus).
347     *
348     * @param ModuleMetadata            $module    Module metadata.
349     * @param int                       $id        Record primary key.
350     * @param int                       $newStatus Target lifecycle status code.
351     * @param array<int, FieldMetadata> $fields    Optional field metadata definitions.
352     * @return array<string, mixed> Snapshot of record.
353     */
354    public function updateSpecialAccess(
355        ModuleMetadata $module,
356        int            $id,
357        int            $newStatus,
358        array          $fields = []
359    ): array {
360        return $this->updateRecordStatus($module, $id, $newStatus, $fields);
361    }
362
363    /**
364     * Guards system-protected records from being mutated or deleted.
365     *
366     * @param array<string, mixed> $snapshot Record snapshot.
367     * @param ModuleMetadata       $module   Module metadata.
368     * @param int                  $id       Record ID.
369     * @param string               $action   Attempted mutation action description.
370     * @throws PermissionDeniedException When record is system protected.
371     */
372    private function assertNotSystemRecord(array $snapshot, ModuleMetadata $module, int $id, string $action): void
373    {
374        $isSystem = (bool) ($snapshot['is_system'] ?? false);
375
376        if ($isSystem) {
377            throw new PermissionDeniedException(
378                sprintf('System record #%d in module "%s" cannot be %s.', $id, $module->name, $action)
379            );
380        }
381    }
382
383    /**
384     * Executes an operation wrapped in a transaction if one is not already active.
385     *
386     * @template T
387     * @param callable(): T $callback Database operation to execute.
388     * @return T Result of callback.
389     */
390    private function executeInTransaction(callable $callback): mixed
391    {
392        $ownsTransaction = !$this->pdo->inTransaction();
393        if ($ownsTransaction) {
394            $this->pdo->beginTransaction();
395        }
396        try {
397            $result = $callback();
398            if ($ownsTransaction) {
399                $this->pdo->commit();
400            }
401            return $result;
402        } catch (PDOException $e) {
403            if ($ownsTransaction && $this->pdo->inTransaction()) {
404                $this->pdo->rollBack();
405            }
406            throw $e;
407        }
408    }
409
410    /**
411     * Fetches a full record snapshot by primary key.
412     *
413     * @param ModuleMetadata            $module Module metadata.
414     * @param int                       $id     Record primary key.
415     * @param array<int, FieldMetadata> $fields Optional field definitions to avoid DESCRIBE.
416     * @return array<string, mixed> Full row data.
417     * @throws RecordNotFoundException When record does not exist.
418     */
419    public function fetchSnapshot(ModuleMetadata $module, int $id, array $fields = []): array
420    {
421        $tableName = $module->tableName;
422        if (!isset(self::$tableColumnsCache[$tableName])) {
423            self::$tableColumnsCache[$tableName] = $this->resolveSnapshotColumns($module, $fields);
424        }
425
426        $colList = self::$tableColumnsCache[$tableName];
427
428        $sql  = sprintf(
429            'SELECT %s FROM `%s` WHERE `%s` = :id LIMIT 1',
430            $colList,
431            $tableName,
432            $module->primaryKey
433        );
434        $stmt = $this->pdo->prepare($sql);
435        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
436        $stmt->execute();
437
438        /** @var array<string, mixed>|false $row */
439        $row = $stmt->fetch(PDO::FETCH_ASSOC);
440
441        if ($row === false) {
442            throw RecordNotFoundException::forId($module->name, $id);
443        }
444
445        return $row;
446    }
447
448    /**
449     * Resolves and formats comma-separated column list for snapshot query.
450     *
451     * @param array<int, FieldMetadata> $fields
452     */
453    private function resolveSnapshotColumns(ModuleMetadata $module, array $fields): string
454    {
455        $columns = [];
456        try {
457            $colsStmt = $this->pdo->query(sprintf('DESCRIBE `%s`', $module->tableName));
458            if ($colsStmt !== false) {
459                /** @var array<int, string> $columns */
460                $columns = $colsStmt->fetchAll(PDO::FETCH_COLUMN);
461            }
462        } catch (\Throwable) {
463            $columns = [];
464        }
465
466        if ($columns !== []) {
467            return '`' . implode('`, `', $columns) . '`';
468        }
469
470        if ($fields !== []) {
471            $cols = [];
472            foreach ($fields as $field) {
473                if ($this->isNonPhysicalField($field)) {
474                    continue;
475                }
476                $cols[] = '`' . $field->fieldKey . '`';
477            }
478            $pkCol = '`' . $module->primaryKey . '`';
479            if (!in_array($pkCol, $cols, true)) {
480                $cols[] = $pkCol;
481            }
482            return implode(', ', $cols);
483        }
484
485        return '`' . $module->primaryKey . '`';
486    }
487
488    /**
489     * Determines whether a field is virtual, relational, or non-persisted in the main table.
490     */
491    private function isNonPhysicalField(FieldMetadata $field): bool
492    {
493        if ($field->fieldKey === 'is_favorite' || $field->uitypeName === 'favorite') {
494            return true;
495        }
496
497        if ($field->fieldKey === 'structures' || $field->uitypeName === 'relation_1m_picklist') {
498            return true;
499        }
500
501        return in_array($field->fieldKey, ['is_pinned', 'default_sort', 'default_order'], true);
502    }
503}