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

codeigniter4 / settings / 18917040695

29 Oct 2025 05:39PM UTC coverage: 87.041% (+1.4%) from 85.634%
18917040695

Pull #154

github

web-flow
Merge f301c9621 into 080b3bf48
Pull Request #154: feat: deferred writes

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

403 of 463 relevant lines covered (87.04%)

141.75 hits per line

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

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

3
namespace CodeIgniter\Settings\Handlers;
4

5
use CodeIgniter\Settings\Config\Settings;
6
use RuntimeException;
7

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

22
    /**
23
     * Base path where settings files are stored.
24
     */
25
    private string $path;
26

27
    private Settings $config;
28

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

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

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

45
        $this->setupDeferredWrites($this->config->file['deferWrites'] ?? false);
340✔
46
    }
47

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

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

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

69
        return $this->getStored($class, $property, $context);
200✔
70
    }
71

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

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

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

100
    /**
101
     * Deletes the record from persistent storage, if found,
102
     * and from the local cache.
103
     *
104
     * @return void
105
     *
106
     * @throws RuntimeException For file write failures
107
     */
108
    public function forget(string $class, string $property, ?string $context = null)
109
    {
110
        $this->hydrate($class, $context);
40✔
111

112
        // Delete from local storage
113
        $this->forgetStored($class, $property, $context);
40✔
114

115
        if ($this->deferWrites) {
40✔
116
            $this->markPending($class, $property, null, $context, true);
20✔
117
        } else {
118
            // For immediate writes, persist only this specific property deletion
119
            $this->persist($class, $context, [[
20✔
120
                'property' => $property,
20✔
121
                'value'    => null,
20✔
122
                'delete'   => true,
20✔
123
            ]]);
20✔
124
        }
125
    }
126

127
    /**
128
     * Deletes all settings files from persistent storage
129
     * and clears the local cache.
130
     *
131
     * @return void
132
     *
133
     * @throws RuntimeException For file deletion failures
134
     */
135
    public function flush()
136
    {
137
        // Delete all .php files in main directory (null context files)
138
        $files = glob($this->path . '*.php', GLOB_NOSORT);
20✔
139

140
        if ($files === false) {
20✔
141
            throw new RuntimeException('Unable to read settings directory: ' . $this->path);
×
142
        }
143

144
        foreach ($files as $file) {
20✔
145
            if (! unlink($file)) {
20✔
146
                throw new RuntimeException('Unable to delete settings file: ' . $file);
×
147
            }
148
        }
149

150
        // Delete all context subdirectories and their contents
151
        $directories = glob($this->path . '*', GLOB_ONLYDIR | GLOB_NOSORT);
20✔
152

153
        if ($directories !== false) {
20✔
154
            foreach ($directories as $directory) {
20✔
155
                // Delete all files inside the directory
156
                $contextFiles = glob($directory . '/*.php', GLOB_NOSORT);
10✔
157

158
                if ($contextFiles !== false) {
10✔
159
                    foreach ($contextFiles as $file) {
10✔
160
                        if (! unlink($file)) {
10✔
161
                            throw new RuntimeException('Unable to delete settings file: ' . $file);
×
162
                        }
163
                    }
164
                }
165

166
                // Remove the empty directory
167
                if (! rmdir($directory)) {
10✔
168
                    throw new RuntimeException('Unable to delete directory: ' . $directory);
×
169
                }
170
            }
171
        }
172

173
        // Clear local storage and hydration tracking
174
        parent::flush();
20✔
175
        $this->hydrated = [];
20✔
176
    }
177

178
    /**
179
     * Fetches values from files in bulk to minimize I/O operations.
180
     * Loads all properties for a specific class+context combination.
181
     *
182
     * @throws RuntimeException For file read failures
183
     */
184
    private function hydrate(string $class, ?string $context): void
185
    {
186
        $key = $this->getHydrationKey($class, $context);
330✔
187

188
        // Check if already loaded
189
        if (in_array($key, $this->hydrated, true)) {
330✔
190
            return;
260✔
191
        }
192

193
        // Load the specific class+context file
194
        $this->loadFromFile($class, $context);
330✔
195
        $this->hydrated[] = $key;
330✔
196

197
        // Also load general context for this class if not already loaded
198
        if ($context !== null) {
330✔
199
            $generalKey = $this->getHydrationKey($class, null);
60✔
200

201
            if (! in_array($generalKey, $this->hydrated, true)) {
60✔
202
                $this->loadFromFile($class, null);
20✔
203
                $this->hydrated[] = $generalKey;
20✔
204
            }
205
        }
206
    }
207

208
    /**
209
     * Loads settings from a file for a given class+context.
210
     *
211
     * @throws RuntimeException For file read failures
212
     */
213
    private function loadFromFile(string $class, ?string $context): void
214
    {
215
        $filePath = $this->getFilePath($class, $context);
330✔
216

217
        // If file doesn't exist, that's fine - no settings stored yet
218
        if (! file_exists($filePath)) {
330✔
219
            return;
330✔
220
        }
221

222
        // Use include to get the data array
223
        $data = include $filePath;
40✔
224

225
        if (! is_array($data)) {
40✔
226
            throw new RuntimeException('Settings file does not return an array: ' . $filePath);
×
227
        }
228

229
        // Load data into in-memory storage
230
        foreach ($data as $property => $valueData) {
40✔
231
            if (! is_array($valueData) || ! isset($valueData['value'], $valueData['type'])) {
40✔
232
                continue;
×
233
            }
234

235
            $this->setStored($class, $property, $this->parseValue($valueData['value'], $valueData['type']), $context);
40✔
236
        }
237
    }
238

239
    /**
240
     * Persists specific property changes to disk.
241
     * Used for both immediate and deferred writes.
242
     *
243
     * @param list<array{property: string, value: mixed, delete: bool}> $changes Array of property changes to apply
244
     *
245
     * @throws RuntimeException For file write failures
246
     */
247
    private function persist(string $class, ?string $context, array $changes): void
248
    {
249
        $filePath = $this->getFilePath($class, $context);
310✔
250

251
        // Ensure directory exists (especially for context subdirectories)
252
        $directory = dirname($filePath);
310✔
253

254
        if (! is_dir($directory) && (! mkdir($directory, 0755, true) && ! is_dir($directory))) {
310✔
255
            throw new RuntimeException('Unable to create directory: ' . $directory);
×
256
        }
257

258
        // Open/create file for locking
259
        $lockHandle = fopen($filePath, 'c+b');
310✔
260

261
        if ($lockHandle === false) {
310✔
262
            throw new RuntimeException('Unable to open file for locking: ' . $filePath);
×
263
        }
264

265
        try {
266
            // Acquire exclusive lock
267
            if (! flock($lockHandle, LOCK_EX)) {
310✔
268
                throw new RuntimeException('Unable to acquire lock on file: ' . $filePath);
×
269
            }
270

271
            // Clear file stat cache to get current file size
272
            clearstatcache(true, $filePath);
310✔
273

274
            $currentData = [];
310✔
275

276
            if (filesize($filePath) > 0) {
310✔
277
                $currentData = include $filePath;
100✔
278

279
                if (! is_array($currentData)) {
100✔
280
                    $currentData = [];
×
281
                }
282
            }
283

284
            // Apply all pending changes
285
            foreach ($changes as $change) {
310✔
286
                if ($change['delete']) {
310✔
287
                    // Explicitly delete this property
288
                    unset($currentData[$change['property']]);
30✔
289
                } else {
290
                    // Set or update this property
291
                    $currentData[$change['property']] = [
300✔
292
                        'value' => $change['value'],
300✔
293
                        'type'  => gettype($change['value']),
300✔
294
                    ];
300✔
295
                }
296
            }
297

298
            // Generate PHP file content
299
            $content = '<?php' . PHP_EOL . PHP_EOL;
310✔
300
            $content .= 'return ' . var_export($currentData, true) . ';' . PHP_EOL;
310✔
301

302
            // Write file
303
            if (file_put_contents($filePath, $content) === false) {
310✔
304
                throw new RuntimeException('Unable to write settings file: ' . $filePath);
×
305
            }
306

307
            @chmod($filePath, 0644);
310✔
308
        } finally {
309
            flock($lockHandle, LOCK_UN);
310✔
310
            fclose($lockHandle);
310✔
311
        }
312
    }
313

314
    /**
315
     * Persists all pending properties to disk.
316
     * Called automatically at the end of request via post_system
317
     * event when deferWrites is enabled.
318
     */
319
    public function persistPendingProperties(): void
320
    {
321
        if ($this->pendingProperties === []) {
30✔
NEW
322
            return;
×
323
        }
324

325
        // Group pending properties by class+context using parent helper
326
        $grouped = $this->getPendingPropertiesGrouped();
30✔
327

328
        // Persist each class+context group
329
        foreach ($grouped as $group) {
30✔
330
            try {
331
                $this->persist($group['class'], $group['context'], $group['changes']);
30✔
NEW
332
            } catch (RuntimeException $e) {
×
NEW
333
                log_message('error', 'Failed to persist pending properties for ' . $group['class'] . ': ' . $e->getMessage());
×
334
            }
335
        }
336

337
        $this->pendingProperties = [];
30✔
338
    }
339

340
    /**
341
     * Generates a file path for a given class+context combination.
342
     *
343
     * Structure:
344
     * - Null context: writable/settings/Class_Name.php
345
     * - With context: writable/settings/{hash(context)}/Class_Name.php
346
     */
347
    private function getFilePath(string $class, ?string $context): string
348
    {
349
        $className = str_replace('\\', '_', $class);
330✔
350

351
        if ($context === null) {
330✔
352
            return $this->path . $className . '.php';
330✔
353
        }
354

355
        $contextHash = hash('xxh128', $context);
60✔
356

357
        return $this->path . $contextHash . DIRECTORY_SEPARATOR . $className . '.php';
60✔
358
    }
359

360
    /**
361
     * Generates a hydration key for a class+context combination.
362
     * Format: $class when context is null, $class::$context otherwise.
363
     */
364
    private function getHydrationKey(string $class, ?string $context): string
365
    {
366
        return $context === null ? $class : $class . '::' . $context;
330✔
367
    }
368
}
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