• 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

87.5
/src/Handlers/FileHandler.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace CodeIgniter\Settings\Handlers;
6

7
use CodeIgniter\Settings\Config\Settings;
8
use RuntimeException;
9

10
/**
11
 * Provides file-based persistence for Settings.
12
 * Uses ArrayHandler for storage to minimize file I/O operations.
13
 */
14
class FileHandler extends ArrayHandler
15
{
16
    /**
17
     * Array of class+context combinations that have been loaded from disk.
18
     * Format: ['ClassName::context', 'ClassName::null', ...]
19
     *
20
     * @var list<string>
21
     */
22
    private array $hydrated = [];
23

24
    /**
25
     * Base path where settings files are stored.
26
     */
27
    private readonly string $path;
28

29
    private readonly Settings $config;
30

31
    /**
32
     * Stores the configured file path and ensures it exists.
33
     */
34
    public function __construct()
35
    {
36
        $this->config = config('Settings');
43✔
37
        $this->path   = rtrim($this->config->file['path'] ?? WRITEPATH . 'settings', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
43✔
38

39
        if (! is_dir($this->path) && (! mkdir($this->path, 0755, true) && ! is_dir($this->path))) {
43✔
40
            throw new RuntimeException('Unable to create settings directory: ' . $this->path);
×
41
        }
42

43
        if (! is_writable($this->path)) {
43✔
44
            throw new RuntimeException('Settings directory is not writable: ' . $this->path);
×
45
        }
46

47
        $this->setupDeferredWrites($this->config->file['deferWrites'] ?? false);
43✔
48
    }
49

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

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

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

71
        return $this->getStored($class, $property, $context);
26✔
72
    }
73

74
    /**
75
     * Stores values into a file for later retrieval.
76
     *
77
     * @param mixed $value
78
     *
79
     * @throws RuntimeException For file write failures
80
     */
81
    public function set(string $class, string $property, $value = null, ?string $context = null): void
82
    {
83
        $this->hydrate($class, $context);
30✔
84

85
        // Update in-memory storage first
86
        $this->setStored($class, $property, $value, $context);
30✔
87

88
        if ($this->deferWrites) {
30✔
89
            $this->markPending($class, $property, $value, $context);
2✔
90
        } else {
91
            // For immediate writes, persist only this specific property change
92
            $this->persist($class, $context, [[
29✔
93
                'property' => $property,
29✔
94
                'value'    => $value,
29✔
95
                'delete'   => false,
29✔
96
            ]]);
29✔
97
        }
98
    }
99

100
    /**
101
     * Stores multiple values into files for later retrieval.
102
     *
103
     * @param list<array{class: string, property: string, value: mixed}> $settings
104
     *
105
     * @throws RuntimeException For file write failures
106
     */
107
    public function setMany(array $settings, ?string $context = null): void
108
    {
109
        if ($settings === []) {
9✔
NEW
110
            return;
×
111
        }
112

113
        $changesByClass = [];
9✔
114

115
        foreach ($settings as $setting) {
9✔
116
            $this->hydrate($setting['class'], $context);
9✔
117
            $this->setStored($setting['class'], $setting['property'], $setting['value'], $context);
9✔
118

119
            if ($this->deferWrites) {
9✔
120
                $this->markPending($setting['class'], $setting['property'], $setting['value'], $context);
2✔
121
            } else {
122
                $changesByClass[$setting['class']][] = [
7✔
123
                    'property' => $setting['property'],
7✔
124
                    'value'    => $setting['value'],
7✔
125
                    'delete'   => false,
7✔
126
                ];
7✔
127
            }
128
        }
129

130
        if ($this->deferWrites) {
9✔
131
            return;
2✔
132
        }
133

134
        foreach ($changesByClass as $class => $changes) {
7✔
135
            $this->persist($class, $context, $changes);
7✔
136
        }
137
    }
138

139
    /**
140
     * Deletes the record from persistent storage, if found,
141
     * and from the local cache.
142
     *
143
     * @throws RuntimeException For file write failures
144
     */
145
    public function forget(string $class, string $property, ?string $context = null): void
146
    {
147
        $this->hydrate($class, $context);
4✔
148

149
        // Delete from local storage
150
        $this->forgetStored($class, $property, $context);
4✔
151

152
        if ($this->deferWrites) {
4✔
153
            $this->markPending($class, $property, null, $context, true);
2✔
154
        } else {
155
            // For immediate writes, persist only this specific property deletion
156
            $this->persist($class, $context, [[
2✔
157
                'property' => $property,
2✔
158
                'value'    => null,
2✔
159
                'delete'   => true,
2✔
160
            ]]);
2✔
161
        }
162
    }
163

164
    /**
165
     * Deletes multiple records from persistent storage, if found,
166
     * and from the local cache.
167
     *
168
     * @param list<array{class: string, property: string}> $settings
169
     *
170
     * @throws RuntimeException For file write failures
171
     */
172
    public function forgetMany(array $settings, ?string $context = null): void
173
    {
174
        if ($settings === []) {
4✔
NEW
175
            return;
×
176
        }
177

178
        $changesByClass = [];
4✔
179

180
        foreach ($settings as $setting) {
4✔
181
            $this->hydrate($setting['class'], $context);
4✔
182
            $this->forgetStored($setting['class'], $setting['property'], $context);
4✔
183

184
            if ($this->deferWrites) {
4✔
185
                $this->markPending($setting['class'], $setting['property'], null, $context, true);
2✔
186
            } else {
187
                $changesByClass[$setting['class']][] = [
2✔
188
                    'property' => $setting['property'],
2✔
189
                    'value'    => null,
2✔
190
                    'delete'   => true,
2✔
191
                ];
2✔
192
            }
193
        }
194

195
        if ($this->deferWrites) {
4✔
196
            return;
2✔
197
        }
198

199
        foreach ($changesByClass as $class => $changes) {
2✔
200
            $this->persist($class, $context, $changes);
2✔
201
        }
202
    }
203

204
    /**
205
     * Deletes all settings files from persistent storage
206
     * and clears the local cache.
207
     *
208
     * @throws RuntimeException For file deletion failures
209
     */
210
    public function flush(): void
211
    {
212
        // Delete all .php files in main directory (null context files)
213
        $files = glob($this->path . '*.php', GLOB_NOSORT);
2✔
214

215
        if ($files === false) {
2✔
216
            throw new RuntimeException('Unable to read settings directory: ' . $this->path);
×
217
        }
218

219
        foreach ($files as $file) {
2✔
220
            if (! unlink($file)) {
2✔
221
                throw new RuntimeException('Unable to delete settings file: ' . $file);
×
222
            }
223
        }
224

225
        // Delete all context subdirectories and their contents
226
        $directories = glob($this->path . '*', GLOB_ONLYDIR | GLOB_NOSORT);
2✔
227

228
        if ($directories !== false) {
2✔
229
            foreach ($directories as $directory) {
2✔
230
                // Delete all files inside the directory
231
                $contextFiles = glob($directory . '/*.php', GLOB_NOSORT);
1✔
232

233
                if ($contextFiles !== false) {
1✔
234
                    foreach ($contextFiles as $file) {
1✔
235
                        if (! unlink($file)) {
1✔
236
                            throw new RuntimeException('Unable to delete settings file: ' . $file);
×
237
                        }
238
                    }
239
                }
240

241
                // Remove the empty directory
242
                if (! rmdir($directory)) {
1✔
243
                    throw new RuntimeException('Unable to delete directory: ' . $directory);
×
244
                }
245
            }
246
        }
247

248
        // Clear local storage and hydration tracking
249
        parent::flush();
2✔
250
        $this->hydrated = [];
2✔
251
    }
252

253
    /**
254
     * Fetches values from files in bulk to minimize I/O operations.
255
     * Loads all properties for a specific class+context combination.
256
     *
257
     * @throws RuntimeException For file read failures
258
     */
259
    private function hydrate(string $class, ?string $context): void
260
    {
261
        $key = $this->getHydrationKey($class, $context);
42✔
262

263
        // Check if already loaded
264
        if (in_array($key, $this->hydrated, true)) {
42✔
265
            return;
35✔
266
        }
267

268
        // Load the specific class+context file
269
        $this->loadFromFile($class, $context);
42✔
270
        $this->hydrated[] = $key;
42✔
271

272
        // Also load general context for this class if not already loaded
273
        if ($context !== null) {
42✔
274
            $generalKey = $this->getHydrationKey($class, null);
7✔
275

276
            if (! in_array($generalKey, $this->hydrated, true)) {
7✔
277
                $this->loadFromFile($class, null);
3✔
278
                $this->hydrated[] = $generalKey;
3✔
279
            }
280
        }
281
    }
282

283
    /**
284
     * Loads settings from a file for a given class+context.
285
     *
286
     * @throws RuntimeException For file read failures
287
     */
288
    private function loadFromFile(string $class, ?string $context): void
289
    {
290
        $filePath = $this->getFilePath($class, $context);
42✔
291

292
        // If file doesn't exist, that's fine - no settings stored yet
293
        if (! file_exists($filePath)) {
42✔
294
            return;
42✔
295
        }
296

297
        // Use include to get the data array
298
        $data = include $filePath;
7✔
299

300
        if (! is_array($data)) {
7✔
301
            throw new RuntimeException('Settings file does not return an array: ' . $filePath);
×
302
        }
303

304
        // Load data into in-memory storage
305
        foreach ($data as $property => $valueData) {
7✔
306
            if (! is_array($valueData) || ! isset($valueData['value'], $valueData['type'])) {
7✔
307
                continue;
×
308
            }
309

310
            $this->setStored($class, $property, $this->parseValue($valueData['value'], $valueData['type']), $context);
7✔
311
        }
312
    }
313

314
    /**
315
     * Persists specific property changes to disk.
316
     * Used for both immediate and deferred writes.
317
     *
318
     * @param list<array{property: string, value: mixed, delete: bool}> $changes Array of property changes to apply
319
     *
320
     * @throws RuntimeException For file write failures
321
     */
322
    private function persist(string $class, ?string $context, array $changes): void
323
    {
324
        $filePath = $this->getFilePath($class, $context);
40✔
325

326
        // Ensure directory exists (especially for context subdirectories)
327
        $directory = dirname($filePath);
40✔
328

329
        if (! is_dir($directory) && (! mkdir($directory, 0755, true) && ! is_dir($directory))) {
40✔
330
            throw new RuntimeException('Unable to create directory: ' . $directory);
×
331
        }
332

333
        // Open/create file for locking
334
        $lockHandle = fopen($filePath, 'c+b');
40✔
335

336
        if ($lockHandle === false) {
40✔
337
            throw new RuntimeException('Unable to open file for locking: ' . $filePath);
×
338
        }
339

340
        try {
341
            // Acquire exclusive lock
342
            if (! flock($lockHandle, LOCK_EX)) {
40✔
343
                throw new RuntimeException('Unable to acquire lock on file: ' . $filePath);
×
344
            }
345

346
            // Clear file stat cache to get current file size
347
            clearstatcache(true, $filePath);
40✔
348

349
            $currentData = [];
40✔
350

351
            if (filesize($filePath) > 0) {
40✔
352
                $currentData = include $filePath;
14✔
353

354
                if (! is_array($currentData)) {
14✔
355
                    $currentData = [];
×
356
                }
357
            }
358

359
            // Apply all pending changes
360
            foreach ($changes as $change) {
40✔
361
                if ($change['delete']) {
40✔
362
                    // Explicitly delete this property
363
                    unset($currentData[$change['property']]);
7✔
364
                } else {
365
                    // Set or update this property
366
                    $currentData[$change['property']] = [
39✔
367
                        'value' => $change['value'],
39✔
368
                        'type'  => gettype($change['value']),
39✔
369
                    ];
39✔
370
                }
371
            }
372

373
            // Generate PHP file content
374
            $content = '<?php' . PHP_EOL . PHP_EOL;
40✔
375
            $content .= 'return ' . var_export($currentData, true) . ';' . PHP_EOL;
40✔
376

377
            // Write file
378
            if (file_put_contents($filePath, $content) === false) {
40✔
379
                throw new RuntimeException('Unable to write settings file: ' . $filePath);
×
380
            }
381

382
            @chmod($filePath, 0644);
40✔
383
        } finally {
384
            flock($lockHandle, LOCK_UN);
40✔
385
            fclose($lockHandle);
40✔
386
        }
387
    }
388

389
    /**
390
     * Persists all pending properties to disk.
391
     * Called automatically at the end of request via post_system
392
     * event when deferWrites is enabled.
393
     */
394
    public function persistPendingProperties(): void
395
    {
396
        if ($this->pendingProperties === []) {
7✔
397
            return;
×
398
        }
399

400
        // Group pending properties by class+context using parent helper
401
        $grouped = $this->getPendingPropertiesGrouped();
7✔
402

403
        // Persist each class+context group
404
        foreach ($grouped as $group) {
7✔
405
            try {
406
                $this->persist($group['class'], $group['context'], $group['changes']);
7✔
407
            } catch (RuntimeException $e) {
×
408
                log_message('error', 'Failed to persist pending properties for ' . $group['class'] . ': ' . $e->getMessage());
×
409
            }
410
        }
411

412
        $this->pendingProperties = [];
7✔
413
    }
414

415
    /**
416
     * Generates a file path for a given class+context combination.
417
     *
418
     * Structure:
419
     * - Null context: writable/settings/Class_Name.php
420
     * - With context: writable/settings/{hash(context)}/Class_Name.php
421
     */
422
    private function getFilePath(string $class, ?string $context): string
423
    {
424
        $className = str_replace('\\', '_', $class);
42✔
425

426
        if ($context === null) {
42✔
427
            return $this->path . $className . '.php';
42✔
428
        }
429

430
        $contextHash = hash('xxh128', $context);
7✔
431

432
        return $this->path . $contextHash . DIRECTORY_SEPARATOR . $className . '.php';
7✔
433
    }
434

435
    /**
436
     * Generates a hydration key for a class+context combination.
437
     * Format: $class when context is null, $class::$context otherwise.
438
     */
439
    private function getHydrationKey(string $class, ?string $context): string
440
    {
441
        return $context === null ? $class : $class . '::' . $context;
42✔
442
    }
443
}
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