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

codeigniter4 / settings / 25083323851

28 Apr 2026 11:39PM UTC coverage: 88.616%. First build
25083323851

Pull #161

github

web-flow
Merge d8e66a1c5 into f0cd3e245
Pull Request #161: feat: add batch setting operations

133 of 142 new or added lines in 4 files covered. (93.66%)

506 of 571 relevant lines covered (88.62%)

17.15 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace CodeIgniter\Settings\Handlers;
6

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

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

25
    /**
26
     * The Query Builder for the Settings table.
27
     */
28
    private readonly BaseBuilder $builder;
29

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

37
    private readonly Settings $config;
38

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

48
        $this->setupDeferredWrites($this->config->database['deferWrites'] ?? false);
30✔
49
    }
50

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

58
        return $this->hasStored($class, $property, $context);
17✔
59
    }
60

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

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

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

93
    /**
94
     * Stores multiple values into the database for later retrieval.
95
     *
96
     * @param list<array{class: string, property: string, value: mixed}> $settings
97
     *
98
     * @throws RuntimeException For database failures
99
     */
100
    public function setMany(array $settings, ?string $context = null): void
101
    {
102
        if ($settings === []) {
10✔
NEW
103
            return;
×
104
        }
105

106
        if ($this->deferWrites) {
10✔
107
            foreach ($settings as $setting) {
2✔
108
                $this->markPending($setting['class'], $setting['property'], $setting['value'], $context);
2✔
109
                $this->setStored($setting['class'], $setting['property'], $setting['value'], $context);
2✔
110
            }
111

112
            return;
2✔
113
        }
114

115
        $this->persistRows($this->prepareUpsertRows($settings, $context), []);
8✔
116

117
        foreach ($settings as $setting) {
8✔
118
            $this->setStored($setting['class'], $setting['property'], $setting['value'], $context);
8✔
119
        }
120
    }
121

122
    /**
123
     * Persists a single property to the database.
124
     *
125
     * @param mixed $value
126
     *
127
     * @throws RuntimeException For database failures
128
     */
129
    private function persist(string $class, string $property, $value, ?string $context): void
130
    {
131
        $time     = Time::now()->format('Y-m-d H:i:s');
16✔
132
        $type     = gettype($value);
16✔
133
        $prepared = $this->prepareValue($value);
16✔
134

135
        // If it was stored then we need to update
136
        if ($this->has($class, $property, $context)) {
16✔
137
            $result = $this->builder
2✔
138
                ->where('class', $class)
2✔
139
                ->where('key', $property)
2✔
140
                ->where('context', $context)
2✔
141
                ->update([
2✔
142
                    'value'      => $prepared,
2✔
143
                    'type'       => $type,
2✔
144
                    'context'    => $context,
2✔
145
                    'updated_at' => $time,
2✔
146
                ]);
2✔
147
            // ...otherwise insert it
148
        } else {
149
            $result = $this->builder
15✔
150
                ->insert([
15✔
151
                    'class'      => $class,
15✔
152
                    'key'        => $property,
15✔
153
                    'value'      => $prepared,
15✔
154
                    'type'       => $type,
15✔
155
                    'context'    => $context,
15✔
156
                    'created_at' => $time,
15✔
157
                    'updated_at' => $time,
15✔
158
                ]);
15✔
159
        }
160

161
        if ($result !== true) {
16✔
162
            throw new RuntimeException($this->db->error()['message'] ?? 'Error writing to the database.');
×
163
        }
164
    }
165

166
    /**
167
     * Deletes the record from persistent storage, if found,
168
     * and from the local cache.
169
     */
170
    public function forget(string $class, string $property, ?string $context = null): void
171
    {
172
        $this->hydrate($context);
4✔
173

174
        if ($this->deferWrites) {
4✔
175
            $this->markPending($class, $property, null, $context, true);
2✔
176
        } else {
177
            $this->persistForget($class, $property, $context);
2✔
178
        }
179

180
        // Delete from local storage
181
        $this->forgetStored($class, $property, $context);
4✔
182
    }
183

184
    /**
185
     * Deletes multiple records from persistent storage, if found,
186
     * and from the local cache.
187
     *
188
     * @param list<array{class: string, property: string}> $settings
189
     */
190
    public function forgetMany(array $settings, ?string $context = null): void
191
    {
192
        if ($settings === []) {
4✔
NEW
193
            return;
×
194
        }
195

196
        $this->hydrate($context);
4✔
197

198
        if ($this->deferWrites) {
4✔
199
            foreach ($settings as $setting) {
2✔
200
                $this->markPending($setting['class'], $setting['property'], null, $context, true);
2✔
201
                $this->forgetStored($setting['class'], $setting['property'], $context);
2✔
202
            }
203

204
            return;
2✔
205
        }
206

207
        $this->persistRows([], $this->prepareDeleteRows($settings, $context));
2✔
208

209
        foreach ($settings as $setting) {
2✔
210
            $this->forgetStored($setting['class'], $setting['property'], $context);
2✔
211
        }
212
    }
213

214
    /**
215
     * Deletes a single property from the database.
216
     *
217
     * @throws RuntimeException For database failures
218
     */
219
    private function persistForget(string $class, string $property, ?string $context): void
220
    {
221
        $result = $this->builder
2✔
222
            ->where('class', $class)
2✔
223
            ->where('key', $property)
2✔
224
            ->where('context', $context)
2✔
225
            ->delete();
2✔
226

227
        if (! $result) {
2✔
228
            throw new RuntimeException($this->db->error()['message'] ?? 'Error writing to the database.');
×
229
        }
230
    }
231

232
    /**
233
     * Deletes all records from persistent storage, if found,
234
     * and from the local cache.
235
     */
236
    public function flush(): void
237
    {
238
        $this->builder->truncate();
1✔
239

240
        parent::flush();
1✔
241
    }
242

243
    /**
244
     * Fetches values from the database in bulk to minimize calls.
245
     * General (null) is always fetched once, contexts are fetched
246
     * in their entirety for each new request.
247
     *
248
     * @throws RuntimeException For database failures
249
     */
250
    private function hydrate(?string $context): void
251
    {
252
        // Check for completion
253
        if (in_array($context, $this->hydrated, true)) {
23✔
254
            return;
12✔
255
        }
256

257
        if ($context === null) {
23✔
258
            $this->hydrated[] = null;
21✔
259

260
            $query = $this->builder->where('context', null);
21✔
261
        } else {
262
            $query = $this->builder->where('context', $context);
3✔
263

264
            // If general has not been hydrated we will do that at the same time
265
            if (! in_array(null, $this->hydrated, true)) {
3✔
266
                $this->hydrated[] = null;
2✔
267
                $query->orWhere('context', null);
2✔
268
            }
269

270
            $this->hydrated[] = $context;
3✔
271
        }
272

273
        if (is_bool($result = $query->get())) {
23✔
274
            throw new RuntimeException($this->db->error()['message'] ?? 'Error reading from database.');
×
275
        }
276

277
        foreach ($result->getResultObject() as $row) {
23✔
278
            $this->setStored($row->class, $row->key, $this->parseValue($row->value, $row->type), $row->context);
9✔
279
        }
280
    }
281

282
    /**
283
     * Persists all pending properties to the database.
284
     * Called automatically at the end of request via post_system
285
     * event when deferWrites is enabled.
286
     */
287
    public function persistPendingProperties(): void
288
    {
289
        if ($this->pendingProperties === []) {
9✔
290
            return;
×
291
        }
292

293
        $time = Time::now()->format('Y-m-d H:i:s');
9✔
294

295
        // Separate deletes from upserts and prepare for database operations
296
        $deletes = [];
9✔
297
        $upserts = [];
9✔
298

299
        foreach ($this->pendingProperties as $info) {
9✔
300
            if ($info['delete']) {
9✔
301
                // Prepare delete row with correct database column names
302
                $deletes[] = [
3✔
303
                    'class'   => $info['class'],
3✔
304
                    'key'     => $info['property'],
3✔
305
                    'context' => $info['context'],
3✔
306
                ];
3✔
307
            } else {
308
                // Prepare upsert row with correct database column names
309
                $upserts[] = [
6✔
310
                    'class'      => $info['class'],
6✔
311
                    'key'        => $info['property'],
6✔
312
                    'value'      => $this->prepareValue($info['value']),
6✔
313
                    'type'       => gettype($info['value']),
6✔
314
                    'context'    => $info['context'],
6✔
315
                    'created_at' => $time,
6✔
316
                    'updated_at' => $time,
6✔
317
                ];
6✔
318
            }
319
        }
320

321
        try {
322
            $this->persistRows($upserts, $deletes);
9✔
323

324
            $this->pendingProperties = [];
9✔
NEW
325
        } catch (DatabaseException|RuntimeException $e) {
×
NEW
326
            log_message('error', 'Failed to persist pending properties: ' . $e->getMessage());
×
327

NEW
328
            $this->pendingProperties = [];
×
329
        }
330
    }
331

332
    /**
333
     * Prepares database rows for setting persistence.
334
     *
335
     * @param list<array{class: string, property: string, value: mixed}> $settings
336
     *
337
     * @return list<array{class: string, key: string, value: mixed, type: string, context: string|null, created_at: string, updated_at: string}>
338
     */
339
    private function prepareUpsertRows(array $settings, ?string $context): array
340
    {
341
        $time = Time::now()->format('Y-m-d H:i:s');
8✔
342
        $rows = [];
8✔
343

344
        foreach ($settings as $setting) {
8✔
345
            $rows[] = [
8✔
346
                'class'      => $setting['class'],
8✔
347
                'key'        => $setting['property'],
8✔
348
                'value'      => $this->prepareValue($setting['value']),
8✔
349
                'type'       => gettype($setting['value']),
8✔
350
                'context'    => $context,
8✔
351
                'created_at' => $time,
8✔
352
                'updated_at' => $time,
8✔
353
            ];
8✔
354
        }
355

356
        return $rows;
8✔
357
    }
358

359
    /**
360
     * Prepares database rows for delete persistence.
361
     *
362
     * @param list<array{class: string, property: string}> $settings
363
     *
364
     * @return list<array{class: string, key: string, context: string|null}>
365
     */
366
    private function prepareDeleteRows(array $settings, ?string $context): array
367
    {
368
        $rows = [];
2✔
369

370
        foreach ($settings as $setting) {
2✔
371
            $rows[] = [
2✔
372
                'class'   => $setting['class'],
2✔
373
                'key'     => $setting['property'],
2✔
374
                'context' => $context,
2✔
375
            ];
2✔
376
        }
377

378
        return $rows;
2✔
379
    }
380

381
    /**
382
     * Persists prepared rows to the database.
383
     *
384
     * @param list<array{class: string, key: string, value: mixed, type: string, context: string|null, created_at: string, updated_at: string}> $upserts
385
     * @param list<array{class: string, key: string, context: string|null}>                                                                     $deletes
386
     */
387
    private function persistRows(array $upserts, array $deletes): void
388
    {
389
        if ($upserts === [] && $deletes === []) {
15✔
NEW
390
            return;
×
391
        }
392

393
        $this->db->transStart();
15✔
394

395
        // Handle upserts: fetch existing records matching our pending data
396
        if ($upserts !== []) {
15✔
397
            // Build query to fetch only the specific records we need
398
            $this->buildOrWhereConditions($upserts, 'class', 'key', 'context');
14✔
399

400
            $existing = $this->builder->get()->getResultArray();
14✔
401

402
            // Build a map of existing records for quick lookup
403
            $existingMap = [];
14✔
404

405
            foreach ($existing as $row) {
14✔
406
                $key               = $this->buildCompositeKey($row['class'], $row['key'], $row['context']);
4✔
407
                $existingMap[$key] = $row['id'];
4✔
408
            }
409

410
            // Separate into inserts and updates
411
            $inserts = [];
14✔
412
            $updates = [];
14✔
413

414
            foreach ($upserts as $row) {
14✔
415
                $key = $this->buildCompositeKey($row['class'], $row['key'], $row['context']);
14✔
416

417
                if (isset($existingMap[$key])) {
14✔
418
                    // Record exists - prepare for update
419
                    $updates[] = [
4✔
420
                        'id'         => $existingMap[$key],
4✔
421
                        'value'      => $row['value'],
4✔
422
                        'type'       => $row['type'],
4✔
423
                        'updated_at' => $row['updated_at'],
4✔
424
                    ];
4✔
425
                } else {
426
                    // New record - prepare for insert
427
                    $inserts[] = $row;
12✔
428
                }
429
            }
430

431
            // Batch insert new records
432
            if ($inserts !== []) {
14✔
433
                $this->builder->insertBatch($inserts);
12✔
434
            }
435

436
            // Batch update existing records
437
            if ($updates !== []) {
14✔
438
                $this->builder->updateBatch($updates, 'id');
4✔
439
            }
440
        }
441

442
        // Batch delete all delete operations
443
        if ($deletes !== []) {
15✔
444
            $this->buildOrWhereConditions($deletes, 'class', 'key', 'context');
5✔
445

446
            $this->builder->delete();
5✔
447
        }
448

449
        $this->db->transComplete();
15✔
450

451
        if ($this->db->transStatus() === false) {
15✔
NEW
452
            throw new RuntimeException('Failed to persist settings to database.');
×
453
        }
454
    }
455

456
    /**
457
     * Builds a composite key for lookup purposes.
458
     */
459
    private function buildCompositeKey(string $class, string $key, ?string $context): string
460
    {
461
        return $class . '::' . $key . ($context === null ? '' : '::' . $context);
14✔
462
    }
463

464
    /**
465
     * Builds OR WHERE conditions for multiple rows.
466
     */
467
    private function buildOrWhereConditions(array $rows, string $classKey, string $keyKey, string $contextKey): void
468
    {
469
        foreach ($rows as $row) {
15✔
470
            $this->builder->orGroupStart();
15✔
471

472
            $this->builder
15✔
473
                ->where($classKey, $row[$classKey])
15✔
474
                ->where($keyKey, $row[$keyKey])
15✔
475
                ->where($contextKey, $row[$contextKey]);
15✔
476

477
            $this->builder->groupEnd();
15✔
478
        }
479
    }
480
}
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