Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.34% covered (success)
96.34%
79 / 82
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlPrefixRepository
96.30% covered (success)
96.30%
78 / 81
80.00% covered (warning)
80.00%
4 / 5
19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findActiveByModule
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 findByModuleAndField
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 acquireNextNumber
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
9.05
 findPicklistShortCode
100.00% covered (success)
100.00%
23 / 23
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\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Exception\PrefixNotFoundException;
12use App\Core\Engine\Domain\Model\PrefixMetadata;
13use App\Core\Engine\Domain\Repository\PrefixRepositoryInterface;
14use DateTimeImmutable;
15use PDO;
16
17/**
18 * SQL Implementation of Prefix Repository.
19 *
20 * Handles querying prefix rules, executing concurrency-safe sequence increments
21 * with row-level locks, and resolving picklist short codes.
22 *
23 * @package App\Core\Engine\Infrastructure\Repository
24 */
25final readonly class SqlPrefixRepository implements PrefixRepositoryInterface
26{
27    private const string PREFIX_COLUMNS = 'id, module_id, field_id, name, prefix, postfix, '
28        . 'pattern, leading_zeros, start_number, current_number, step, reset_frequency, '
29        . 'last_reset_date, picklist_field_id, is_active, description';
30
31    private const string SELECT_PREFIX_BASE = 'SELECT ' . self::PREFIX_COLUMNS . ' FROM a_core_prefix_records ';
32
33    /**
34     * SqlPrefixRepository constructor.
35     *
36     * @param PDO $pdo Database connection.
37     */
38    public function __construct(private PDO $pdo)
39    {
40    }
41
42    /** {@inheritdoc} */
43    public function findActiveByModule(int $moduleId): array
44    {
45        $sql = self::SELECT_PREFIX_BASE
46            . 'WHERE module_id = :module_id AND is_active = 1 '
47            . 'ORDER BY id ASC';
48
49        $stmt = $this->pdo->prepare($sql);
50        $stmt->execute([':module_id' => $moduleId]);
51
52        $results = [];
53        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
54            $results[] = PrefixMetadata::fromArray($row);
55        }
56
57        return $results;
58    }
59
60    /** {@inheritdoc} */
61    public function findByModuleAndField(int $moduleId, int $fieldId): ?PrefixMetadata
62    {
63        $sql = self::SELECT_PREFIX_BASE
64            . 'WHERE module_id = :module_id AND field_id = :field_id AND is_active = 1 '
65            . 'LIMIT 1';
66
67        $stmt = $this->pdo->prepare($sql);
68        $stmt->execute([
69            ':module_id' => $moduleId,
70            ':field_id'  => $fieldId,
71        ]);
72
73        $row = $stmt->fetch(PDO::FETCH_ASSOC);
74        if ($row === false) {
75            return null;
76        }
77
78        return PrefixMetadata::fromArray($row);
79    }
80
81    /** {@inheritdoc} */
82    public function acquireNextNumber(int $prefixId, DateTimeImmutable $now): int
83    {
84        $ownsTransaction = !$this->pdo->inTransaction();
85        if ($ownsTransaction) {
86            $this->pdo->beginTransaction();
87        }
88
89        try {
90            $isSqlite = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
91            $forUpdate = $isSqlite ? '' : ' FOR UPDATE';
92
93            // Lock the prefix configuration row for atomic update
94            $selectSql = self::SELECT_PREFIX_BASE
95                . 'WHERE id = :id' . $forUpdate;
96
97            $selectStmt = $this->pdo->prepare($selectSql);
98            $selectStmt->execute([':id' => $prefixId]);
99            $row = $selectStmt->fetch(PDO::FETCH_ASSOC);
100
101            if ($row === false) {
102                throw PrefixNotFoundException::forId($prefixId);
103            }
104
105            $meta = PrefixMetadata::fromArray($row);
106
107            $allocatedNumber = $meta->currentNumber;
108            $step = max(1, $meta->step);
109
110            if ($meta->shouldReset($now)) {
111                $allocatedNumber = $meta->startNumber;
112            }
113
114            $nextNumber = $allocatedNumber + $step;
115            $nowFormatted = $now->format('Y-m-d H:i:s');
116
117            $updateSql = 'UPDATE a_core_prefix_records '
118                . 'SET current_number = :next_number, last_reset_date = :last_reset_date, '
119                . 'updated_at = CURRENT_TIMESTAMP '
120                . 'WHERE id = :id';
121
122            $updateStmt = $this->pdo->prepare($updateSql);
123            $updateStmt->execute([
124                ':next_number'      => $nextNumber,
125                ':last_reset_date'  => $nowFormatted,
126                ':id'               => $prefixId,
127            ]);
128
129            if ($ownsTransaction) {
130                $this->pdo->commit();
131            }
132
133            return $allocatedNumber;
134        } catch (\Throwable $e) {
135            if ($ownsTransaction && $this->pdo->inTransaction()) {
136                $this->pdo->rollBack();
137            }
138            throw $e;
139        }
140    }
141
142    /** {@inheritdoc} */
143    public function findPicklistShortCode(int|string $valueOrId, int $picklistId): ?string
144    {
145        $isNumeric = is_numeric($valueOrId);
146        $where = $isNumeric
147            ? '(id = :val_id OR value = :val_str)'
148            : 'value = :val_str';
149
150        $sql = 'SELECT short_code, value '
151            . 'FROM a_core_picklist_value_records '
152            . "WHERE picklist_id = :picklist_id AND {$where} "
153            . 'LIMIT 1';
154
155        $params = [
156            ':picklist_id' => $picklistId,
157            ':val_str'     => (string) $valueOrId,
158        ];
159        if ($isNumeric) {
160            $params[':val_id'] = (int) $valueOrId;
161        }
162
163        $stmt = $this->pdo->prepare($sql);
164        $stmt->execute($params);
165        $row = $stmt->fetch(PDO::FETCH_ASSOC);
166
167        if ($row === false) {
168            return null;
169        }
170
171        $shortCode = trim((string) ($row['short_code'] ?? ''));
172        if ($shortCode !== '') {
173            return $shortCode;
174        }
175
176        return trim((string) ($row['value'] ?? ''));
177    }
178}