Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.63% covered (success)
92.63%
88 / 95
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlUserPreferenceRepository
92.55% covered (success)
92.55%
87 / 94
66.67% covered (warning)
66.67%
4 / 6
20.17
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
 save
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 findValue
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
5.16
 findAllByUser
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
6.02
 deleteByScope
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 buildCacheKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
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\Preference\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Preference\Domain\Model\PreferenceScope;
12use App\Core\Preference\Domain\Model\UserPreference;
13use App\Core\Preference\Domain\Repository\UserPreferenceRepositoryInterface;
14use JsonException;
15use PDO;
16
17/**
18 * SQL Implementation of UserPreferenceRepositoryInterface.
19 *
20 * Persists and fetches user preferences from a_core_user_preference_records with prepared statements.
21 *
22 * @package App\Core\Preference\Infrastructure\Repository
23 */
24final class SqlUserPreferenceRepository implements UserPreferenceRepositoryInterface
25{
26    /** @var array<string, mixed> In-memory cache map for fast per-request resolution. */
27    private array $cache = [];
28
29    /**
30     * SqlUserPreferenceRepository constructor.
31     *
32     * @param PDO    $pdo         Database connection instance.
33     * @param string $tablePrefix Database table prefix.
34     */
35    public function __construct(
36        private readonly PDO $pdo,
37        private readonly string $tablePrefix = 'a_',
38    ) {
39    }
40
41    /**
42     * @inheritDoc
43     */
44    public function save(UserPreference $preference): void
45    {
46        $scope = $preference->scope;
47        $jsonValue = json_encode($preference->value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
48        $tableName = $this->tablePrefix . 'core_user_preference_records';
49
50        $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
51        if ($driver === 'sqlite') {
52            $sql = "INSERT INTO `{$tableName}` (
53                `user_id`, `device_fingerprint`, `module_name`, `entity_type`, `entity_id`,
54                `preference_key`, `preference_value`, `created_at`, `updated_at`
55            ) VALUES (
56                :user_id, :device_fingerprint, :module_name, :entity_type, :entity_id,
57                :preference_key, :preference_value, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
58            ) ON CONFLICT(`user_id`, `device_fingerprint`, `module_name`, `entity_type`, `entity_id`, `preference_key`)
59            DO UPDATE SET
60                `preference_value` = excluded.`preference_value`,
61                `updated_at` = CURRENT_TIMESTAMP";
62        } else {
63            $sql = "INSERT INTO `{$tableName}` (
64                `user_id`, `device_fingerprint`, `module_name`, `entity_type`, `entity_id`,
65                `preference_key`, `preference_value`, `created_at`, `updated_at`
66            ) VALUES (
67                :user_id, :device_fingerprint, :module_name, :entity_type, :entity_id,
68                :preference_key, :preference_value, NOW(6), NOW(6)
69            ) ON DUPLICATE KEY UPDATE
70                `preference_value` = VALUES(`preference_value`),
71                `updated_at` = NOW(6)";
72        }
73
74        $stmt = $this->pdo->prepare($sql);
75        $stmt->bindValue(':user_id', $scope->userId, PDO::PARAM_INT);
76        $stmt->bindValue(':device_fingerprint', $scope->deviceFingerprint, PDO::PARAM_STR);
77        $stmt->bindValue(':module_name', $scope->moduleName, PDO::PARAM_STR);
78        $stmt->bindValue(':entity_type', $scope->entityType, PDO::PARAM_STR);
79        $stmt->bindValue(':entity_id', $scope->entityId, PDO::PARAM_INT);
80        $stmt->bindValue(':preference_key', $preference->key, PDO::PARAM_STR);
81        $stmt->bindValue(':preference_value', $jsonValue, PDO::PARAM_STR);
82        $stmt->execute();
83
84        $cacheKey = $this->buildCacheKey($scope, $preference->key);
85        $this->cache[$cacheKey] = $preference->value;
86    }
87
88    /**
89     * @inheritDoc
90     */
91    public function findValue(PreferenceScope $scope, string $key): mixed
92    {
93        $cacheKey = $this->buildCacheKey($scope, $key);
94        if (array_key_exists($cacheKey, $this->cache)) {
95            return $this->cache[$cacheKey];
96        }
97
98        $tableName = $this->tablePrefix . 'core_user_preference_records';
99        $sql = "SELECT `preference_value` FROM `{$tableName}`
100                WHERE `user_id` = :user_id
101                  AND `preference_key` = :key
102                  AND (`device_fingerprint` = :dev OR (:dev_null IS NULL AND `device_fingerprint` IS NULL))
103                  AND (`module_name` = :mod OR (:mod_null IS NULL AND `module_name` IS NULL))
104                  AND (`entity_type` = :etype OR (:etype_null IS NULL AND `entity_type` IS NULL))
105                  AND (`entity_id` = :eid OR (:eid_null IS NULL AND `entity_id` IS NULL))
106                LIMIT 1";
107
108        $stmt = $this->pdo->prepare($sql);
109        $stmt->bindValue(':user_id', $scope->userId, PDO::PARAM_INT);
110        $stmt->bindValue(':key', $key, PDO::PARAM_STR);
111        $stmt->bindValue(':dev', $scope->deviceFingerprint, PDO::PARAM_STR);
112        $stmt->bindValue(':dev_null', $scope->deviceFingerprint, PDO::PARAM_STR);
113        $stmt->bindValue(':mod', $scope->moduleName, PDO::PARAM_STR);
114        $stmt->bindValue(':mod_null', $scope->moduleName, PDO::PARAM_STR);
115        $stmt->bindValue(':etype', $scope->entityType, PDO::PARAM_STR);
116        $stmt->bindValue(':etype_null', $scope->entityType, PDO::PARAM_STR);
117        $stmt->bindValue(':eid', $scope->entityId, PDO::PARAM_INT);
118        $stmt->bindValue(':eid_null', $scope->entityId, PDO::PARAM_INT);
119        $stmt->execute();
120
121        $raw = $stmt->fetchColumn();
122        if ($raw === false || $raw === null) {
123            $this->cache[$cacheKey] = null;
124            return null;
125        }
126
127        try {
128            $value = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR);
129        } catch (JsonException) {
130            $value = (string) $raw;
131        }
132
133        $this->cache[$cacheKey] = $value;
134        return $value;
135    }
136
137    /**
138     * @inheritDoc
139     */
140    public function findAllByUser(int $userId, ?string $moduleName = null, ?string $deviceFingerprint = null): array
141    {
142        $tableName = $this->tablePrefix . 'core_user_preference_records';
143        $sql = "SELECT `preference_key`, `preference_value`, `module_name`, `device_fingerprint`
144                FROM `{$tableName}`
145                WHERE `user_id` = :user_id";
146
147        $params = [':user_id' => $userId];
148        if ($moduleName !== null) {
149            $sql .= " AND (`module_name` = :mod OR `module_name` IS NULL)";
150            $params[':mod'] = $moduleName;
151        }
152
153        if ($deviceFingerprint !== null) {
154            $sql .= " AND (`device_fingerprint` = :dev OR `device_fingerprint` IS NULL)";
155            $params[':dev'] = $deviceFingerprint;
156        }
157
158        $stmt = $this->pdo->prepare($sql);
159        foreach ($params as $pKey => $pVal) {
160            $stmt->bindValue($pKey, $pVal);
161        }
162        $stmt->execute();
163
164        $result = [];
165        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
166            $key = (string) $row['preference_key'];
167            try {
168                $val = json_decode((string) $row['preference_value'], true, 512, JSON_THROW_ON_ERROR);
169            } catch (JsonException) {
170                $val = (string) $row['preference_value'];
171            }
172            $result[$key] = $val;
173        }
174
175        return $result;
176    }
177
178    /**
179     * @inheritDoc
180     */
181    public function deleteByScope(PreferenceScope $scope, ?string $key = null): int
182    {
183        $tableName = $this->tablePrefix . 'core_user_preference_records';
184        $sql = "DELETE FROM `{$tableName}` WHERE `user_id` = :user_id";
185        $params = [':user_id' => $scope->userId];
186
187        if ($key !== null) {
188            $sql .= " AND `preference_key` = :key";
189            $params[':key'] = $key;
190        }
191
192        if ($scope->moduleName !== null) {
193            $sql .= " AND `module_name` = :mod";
194            $params[':mod'] = $scope->moduleName;
195        }
196
197        if ($scope->deviceFingerprint !== null) {
198            $sql .= " AND `device_fingerprint` = :dev";
199            $params[':dev'] = $scope->deviceFingerprint;
200        }
201
202        $stmt = $this->pdo->prepare($sql);
203        foreach ($params as $pKey => $pVal) {
204            $stmt->bindValue($pKey, $pVal);
205        }
206        $stmt->execute();
207
208        $this->cache = [];
209        return $stmt->rowCount();
210    }
211
212    /**
213     * Builds a composite internal cache key for in-memory lookup.
214     *
215     * @param PreferenceScope $scope Scope coordinates.
216     * @param string          $key   Preference key.
217     * @return string Composite cache key.
218     */
219    private function buildCacheKey(PreferenceScope $scope, string $key): string
220    {
221        $dev = $scope->deviceFingerprint ?? '_';
222        $mod = $scope->moduleName ?? '_';
223        $et = $scope->entityType ?? '_';
224        $eid = (string) ($scope->entityId ?? '_');
225        return "{$scope->userId}:{$dev}:{$mod}:{$et}:{$eid}:{$key}";
226    }
227}