• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / settings / 19026940269

03 Nov 2025 07:23AM UTC coverage: 87.041% (+1.4%) from 85.634%
19026940269

push

github

web-flow
feat: deferred writes (#154)

117 of 126 new or added lines in 4 files covered. (92.86%)

403 of 463 relevant lines covered (87.04%)

28.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.75
/src/Handlers/DatabaseHandler.php
1
<?php
2

3
namespace CodeIgniter\Settings\Handlers;
4

5
use CodeIgniter\Database\BaseBuilder;
6
use CodeIgniter\Database\BaseConnection;
7
use CodeIgniter\Database\Exceptions\DatabaseException;
8
use CodeIgniter\I18n\Time;
9
use CodeIgniter\Settings\Config\Settings;
10
use RuntimeException;
11

12
/**
13
 * Provides database persistence for Settings.
14
 * Uses ArrayHandler for storage to minimize database calls.
15
 */
16
class DatabaseHandler extends ArrayHandler
17
{
18
    /**
19
     * The DB connection for the Settings.
20
     */
21
    private BaseConnection $db;
22

23
    /**
24
     * The Query Builder for the Settings table.
25
     */
26
    private BaseBuilder $builder;
27

28
    /**
29
     * Array of contexts that have been stored.
30
     *
31
     * @var list<null>|list<string>
32
     */
33
    private array $hydrated = [];
34

35
    private Settings $config;
36

37
    /**
38
     * Stores the configured database table.
39
     */
40
    public function __construct()
41
    {
42
        $this->config  = config('Settings');
40✔
43
        $this->db      = db_connect($this->config->database['group']);
40✔
44
        $this->builder = $this->db->table($this->config->database['table']);
40✔
45

46
        $this->setupDeferredWrites($this->config->database['deferWrites'] ?? false);
40✔
47
    }
48

49
    /**
50
     * Checks whether this handler has a value set.
51
     */
52
    public function has(string $class, string $property, ?string $context = null): bool
53
    {
54
        $this->hydrate($context);
32✔
55

56
        return $this->hasStored($class, $property, $context);
32✔
57
    }
58

59
    /**
60
     * Attempt to retrieve a value from the database.
61
     * To boost performance, all of the values are
62
     * read and stored the first call for each contexts
63
     * and then retrieved from storage.
64
     *
65
     * @return mixed
66
     */
67
    public function get(string $class, string $property, ?string $context = null)
68
    {
69
        return $this->getStored($class, $property, $context);
16✔
70
    }
71

72
    /**
73
     * Stores values into the database for later retrieval.
74
     *
75
     * @param mixed $value
76
     *
77
     * @return void
78
     *
79
     * @throws RuntimeException For database failures
80
     */
81
    public function set(string $class, string $property, $value = null, ?string $context = null)
82
    {
83
        if ($this->deferWrites) {
34✔
84
            $this->markPending($class, $property, $value, $context);
8✔
85
        } else {
86
            $this->persist($class, $property, $value, $context);
32✔
87
        }
88

89
        // Update storage after persistence check
90
        $this->setStored($class, $property, $value, $context);
34✔
91
    }
92

93
    /**
94
     * Persists a single property to the database.
95
     *
96
     * @param mixed $value
97
     *
98
     * @throws RuntimeException For database failures
99
     */
100
    private function persist(string $class, string $property, $value, ?string $context): void
101
    {
102
        $time     = Time::now()->format('Y-m-d H:i:s');
32✔
103
        $type     = gettype($value);
32✔
104
        $prepared = $this->prepareValue($value);
32✔
105

106
        // If it was stored then we need to update
107
        if ($this->has($class, $property, $context)) {
32✔
108
            $result = $this->builder
4✔
109
                ->where('class', $class)
4✔
110
                ->where('key', $property)
4✔
111
                ->where('context', $context)
4✔
112
                ->update([
4✔
113
                    'value'      => $prepared,
4✔
114
                    'type'       => $type,
4✔
115
                    'context'    => $context,
4✔
116
                    'updated_at' => $time,
4✔
117
                ]);
4✔
118
            // ...otherwise insert it
119
        } else {
120
            $result = $this->builder
30✔
121
                ->insert([
30✔
122
                    'class'      => $class,
30✔
123
                    'key'        => $property,
30✔
124
                    'value'      => $prepared,
30✔
125
                    'type'       => $type,
30✔
126
                    'context'    => $context,
30✔
127
                    'created_at' => $time,
30✔
128
                    'updated_at' => $time,
30✔
129
                ]);
30✔
130
        }
131

132
        if ($result !== true) {
32✔
133
            throw new RuntimeException($this->db->error()['message'] ?? 'Error writing to the database.');
×
134
        }
135
    }
136

137
    /**
138
     * Deletes the record from persistent storage, if found,
139
     * and from the local cache.
140
     *
141
     * @return void
142
     */
143
    public function forget(string $class, string $property, ?string $context = null)
144
    {
145
        $this->hydrate($context);
8✔
146

147
        if ($this->deferWrites) {
8✔
148
            $this->markPending($class, $property, null, $context, true);
4✔
149
        } else {
150
            $this->persistForget($class, $property, $context);
4✔
151
        }
152

153
        // Delete from local storage
154
        $this->forgetStored($class, $property, $context);
8✔
155
    }
156

157
    /**
158
     * Deletes a single property from the database.
159
     *
160
     * @throws RuntimeException For database failures
161
     */
162
    private function persistForget(string $class, string $property, ?string $context): void
163
    {
164
        $result = $this->builder
4✔
165
            ->where('class', $class)
4✔
166
            ->where('key', $property)
4✔
167
            ->where('context', $context)
4✔
168
            ->delete();
4✔
169

170
        if (! $result) {
4✔
171
            throw new RuntimeException($this->db->error()['message'] ?? 'Error writing to the database.');
×
172
        }
173
    }
174

175
    /**
176
     * Deletes all records from persistent storage, if found,
177
     * and from the local cache.
178
     *
179
     * @return void
180
     */
181
    public function flush()
182
    {
183
        $this->builder->truncate();
2✔
184

185
        parent::flush();
2✔
186
    }
187

188
    /**
189
     * Fetches values from the database in bulk to minimize calls.
190
     * General (null) is always fetched once, contexts are fetched
191
     * in their entirety for each new request.
192
     *
193
     * @throws RuntimeException For database failures
194
     */
195
    private function hydrate(?string $context): void
196
    {
197
        // Check for completion
198
        if (in_array($context, $this->hydrated, true)) {
36✔
199
            return;
22✔
200
        }
201

202
        if ($context === null) {
36✔
203
            $this->hydrated[] = null;
34✔
204

205
            $query = $this->builder->where('context', null);
34✔
206
        } else {
207
            $query = $this->builder->where('context', $context);
4✔
208

209
            // If general has not been hydrated we will do that at the same time
210
            if (! in_array(null, $this->hydrated, true)) {
4✔
211
                $this->hydrated[] = null;
2✔
212
                $query->orWhere('context', null);
2✔
213
            }
214

215
            $this->hydrated[] = $context;
4✔
216
        }
217

218
        if (is_bool($result = $query->get())) {
36✔
219
            throw new RuntimeException($this->db->error()['message'] ?? 'Error reading from database.');
×
220
        }
221

222
        foreach ($result->getResultObject() as $row) {
36✔
223
            $this->setStored($row->class, $row->key, $this->parseValue($row->value, $row->type), $row->context);
8✔
224
        }
225
    }
226

227
    /**
228
     * Persists all pending properties to the database.
229
     * Called automatically at the end of request via post_system
230
     * event when deferWrites is enabled.
231
     *
232
     * @return void
233
     */
234
    public function persistPendingProperties()
235
    {
236
        if ($this->pendingProperties === []) {
10✔
NEW
237
            return;
×
238
        }
239

240
        $time = Time::now()->format('Y-m-d H:i:s');
10✔
241

242
        // Separate deletes from upserts and prepare for database operations
243
        $deletes = [];
10✔
244
        $upserts = [];
10✔
245

246
        foreach ($this->pendingProperties as $info) {
10✔
247
            if ($info['delete']) {
10✔
248
                // Prepare delete row with correct database column names
249
                $deletes[] = [
2✔
250
                    'class'   => $info['class'],
2✔
251
                    'key'     => $info['property'],
2✔
252
                    'context' => $info['context'],
2✔
253
                ];
2✔
254
            } else {
255
                // Prepare upsert row with correct database column names
256
                $upserts[] = [
8✔
257
                    'class'      => $info['class'],
8✔
258
                    'key'        => $info['property'],
8✔
259
                    'value'      => $this->prepareValue($info['value']),
8✔
260
                    'type'       => gettype($info['value']),
8✔
261
                    'context'    => $info['context'],
8✔
262
                    'created_at' => $time,
8✔
263
                    'updated_at' => $time,
8✔
264
                ];
8✔
265
            }
266
        }
267

268
        try {
269
            $this->db->transStart();
10✔
270

271
            // Handle upserts: fetch existing records matching our pending data
272
            if ($upserts !== []) {
10✔
273
                // Build query to fetch only the specific records we need
274
                $this->buildOrWhereConditions($upserts, 'class', 'key', 'context');
8✔
275

276
                $existing = $this->builder->get()->getResultArray();
8✔
277

278
                // Build a map of existing records for quick lookup
279
                $existingMap = [];
8✔
280

281
                foreach ($existing as $row) {
8✔
282
                    $key               = $this->buildCompositeKey($row['class'], $row['key'], $row['context']);
6✔
283
                    $existingMap[$key] = $row['id'];
6✔
284
                }
285

286
                // Separate into inserts and updates
287
                $inserts = [];
8✔
288
                $updates = [];
8✔
289

290
                foreach ($upserts as $row) {
8✔
291
                    $key = $this->buildCompositeKey($row['class'], $row['key'], $row['context']);
8✔
292

293
                    if (isset($existingMap[$key])) {
8✔
294
                        // Record exists - prepare for update
295
                        $updates[] = [
6✔
296
                            'id'         => $existingMap[$key],
6✔
297
                            'value'      => $row['value'],
6✔
298
                            'type'       => $row['type'],
6✔
299
                            'updated_at' => $row['updated_at'],
6✔
300
                        ];
6✔
301
                    } else {
302
                        // New record - prepare for insert
303
                        $inserts[] = $row;
4✔
304
                    }
305
                }
306

307
                // Batch insert new records
308
                if ($inserts !== []) {
8✔
309
                    $this->builder->insertBatch($inserts);
4✔
310
                }
311

312
                // Batch update existing records
313
                if ($updates !== []) {
8✔
314
                    $this->builder->updateBatch($updates, 'id');
6✔
315
                }
316
            }
317

318
            // Batch delete all delete operations
319
            if ($deletes !== []) {
10✔
320
                $this->buildOrWhereConditions($deletes, 'class', 'key', 'context');
2✔
321

322
                $this->builder->delete();
2✔
323
            }
324

325
            $this->db->transComplete();
10✔
326

327
            if ($this->db->transStatus() === false) {
10✔
NEW
328
                log_message('error', 'Failed to persist pending properties to database.');
×
329
            }
330

331
            $this->pendingProperties = [];
10✔
NEW
332
        } catch (DatabaseException $e) {
×
NEW
333
            log_message('error', 'Failed to persist pending properties: ' . $e->getMessage());
×
334

NEW
335
            $this->pendingProperties = [];
×
336
        }
337
    }
338

339
    /**
340
     * Builds a composite key for lookup purposes.
341
     */
342
    private function buildCompositeKey(string $class, string $key, ?string $context): string
343
    {
344
        return $class . '::' . $key . ($context === null ? '' : '::' . $context);
8✔
345
    }
346

347
    /**
348
     * Builds OR WHERE conditions for multiple rows.
349
     */
350
    private function buildOrWhereConditions(array $rows, string $classKey, string $keyKey, string $contextKey): void
351
    {
352
        foreach ($rows as $row) {
10✔
353
            $this->builder->orGroupStart();
10✔
354

355
            $this->builder
10✔
356
                ->where($classKey, $row[$classKey])
10✔
357
                ->where($keyKey, $row[$keyKey])
10✔
358
                ->where($contextKey, $row[$contextKey]);
10✔
359

360
            $this->builder->groupEnd();
10✔
361
        }
362
    }
363
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc