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

codeigniter4 / settings / 18866587338

28 Oct 2025 06:52AM UTC coverage: 85.634% (+0.5%) from 85.088%
18866587338

Pull #153

github

web-flow
Merge c647cb444 into 1969871d4
Pull Request #153: feat: add `FileHandler` implementation

108 of 129 new or added lines in 4 files covered. (83.72%)

304 of 355 relevant lines covered (85.63%)

14.73 hits per line

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

85.87
/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');
31✔
35
        $this->path   = rtrim($this->config->file['path'] ?? WRITEPATH . 'settings', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
31✔
36

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

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

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

53
        return $this->hasStored($class, $property, $context);
26✔
54
    }
55

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

67
        return $this->getStored($class, $property, $context);
20✔
68
    }
69

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

83
        // Update in-memory storage first
84
        $this->setStored($class, $property, $value, $context);
27✔
85

86
        // Persist to disk with file locking
87
        $this->persist($class, $context);
27✔
88
    }
89

90
    /**
91
     * Deletes the record from persistent storage, if found,
92
     * and from the local cache.
93
     *
94
     * @return void
95
     *
96
     * @throws RuntimeException For file write failures
97
     */
98
    public function forget(string $class, string $property, ?string $context = null)
99
    {
100
        $this->hydrate($class, $context);
2✔
101

102
        // Delete from local storage
103
        $this->forgetStored($class, $property, $context);
2✔
104

105
        // Persist to disk with file locking
106
        $this->persist($class, $context);
2✔
107
    }
108

109
    /**
110
     * Deletes all settings files from persistent storage
111
     * and clears the local cache.
112
     *
113
     * @return void
114
     *
115
     * @throws RuntimeException For file deletion failures
116
     */
117
    public function flush()
118
    {
119
        // Delete all .php files in main directory (null context files)
120
        $files = glob($this->path . '*.php', GLOB_NOSORT);
2✔
121

122
        if ($files === false) {
2✔
NEW
123
            throw new RuntimeException('Unable to read settings directory: ' . $this->path);
×
124
        }
125

126
        foreach ($files as $file) {
2✔
127
            if (! unlink($file)) {
2✔
NEW
128
                throw new RuntimeException('Unable to delete settings file: ' . $file);
×
129
            }
130
        }
131

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

135
        if ($directories !== false) {
2✔
136
            foreach ($directories as $directory) {
2✔
137
                // Delete all files inside the directory
138
                $contextFiles = glob($directory . '/*.php', GLOB_NOSORT);
1✔
139

140
                if ($contextFiles !== false) {
1✔
141
                    foreach ($contextFiles as $file) {
1✔
142
                        if (! unlink($file)) {
1✔
NEW
143
                            throw new RuntimeException('Unable to delete settings file: ' . $file);
×
144
                        }
145
                    }
146
                }
147

148
                // Remove the empty directory
149
                if (! rmdir($directory)) {
1✔
NEW
150
                    throw new RuntimeException('Unable to delete directory: ' . $directory);
×
151
                }
152
            }
153
        }
154

155
        // Clear local storage and hydration tracking
156
        parent::flush();
2✔
157
        $this->hydrated = [];
2✔
158
    }
159

160
    /**
161
     * Fetches values from files in bulk to minimize I/O operations.
162
     * Loads all properties for a specific class+context combination.
163
     *
164
     * @throws RuntimeException For file read failures
165
     */
166
    private function hydrate(string $class, ?string $context): void
167
    {
168
        $key = $this->getHydrationKey($class, $context);
30✔
169

170
        // Check if already loaded
171
        if (in_array($key, $this->hydrated, true)) {
30✔
172
            return;
24✔
173
        }
174

175
        // Load the specific class+context file
176
        $this->loadFromFile($class, $context);
30✔
177
        $this->hydrated[] = $key;
30✔
178

179
        // Also load general context for this class if not already loaded
180
        if ($context !== null) {
30✔
181
            $generalKey = $this->getHydrationKey($class, null);
6✔
182

183
            if (! in_array($generalKey, $this->hydrated, true)) {
6✔
184
                $this->loadFromFile($class, null);
2✔
185
                $this->hydrated[] = $generalKey;
2✔
186
            }
187
        }
188
    }
189

190
    /**
191
     * Loads settings from a file for a given class+context.
192
     *
193
     * @throws RuntimeException For file read failures
194
     */
195
    private function loadFromFile(string $class, ?string $context): void
196
    {
197
        $filePath = $this->getFilePath($class, $context);
30✔
198

199
        // If file doesn't exist, that's fine - no settings stored yet
200
        if (! file_exists($filePath)) {
30✔
201
            return;
30✔
202
        }
203

204
        // Use include to get the data array
205
        $data = include $filePath;
2✔
206

207
        if (! is_array($data)) {
2✔
NEW
208
            throw new RuntimeException('Settings file does not return an array: ' . $filePath);
×
209
        }
210

211
        // Load data into in-memory storage
212
        foreach ($data as $property => $valueData) {
2✔
213
            if (! is_array($valueData) || ! isset($valueData['value'], $valueData['type'])) {
2✔
NEW
214
                continue;
×
215
            }
216

217
            $this->setStored($class, $property, $this->parseValue($valueData['value'], $valueData['type']), $context);
2✔
218
        }
219
    }
220

221
    /**
222
     * Persists current in-memory settings for a class+context to disk.
223
     * Uses file locking to prevent race conditions during concurrent writes.
224
     *
225
     * @throws RuntimeException For file write failures
226
     */
227
    private function persist(string $class, ?string $context): void
228
    {
229
        $filePath = $this->getFilePath($class, $context);
28✔
230

231
        // Ensure directory exists (especially for context subdirectories)
232
        $directory = dirname($filePath);
28✔
233

234
        if (! is_dir($directory) && (! mkdir($directory, 0755, true) && ! is_dir($directory))) {
28✔
NEW
235
            throw new RuntimeException('Unable to create directory: ' . $directory);
×
236
        }
237

238
        // Open/create file for locking
239
        $lockHandle = fopen($filePath, 'c+b');
28✔
240

241
        if ($lockHandle === false) {
28✔
NEW
242
            throw new RuntimeException('Unable to open file for locking: ' . $filePath);
×
243
        }
244

245
        try {
246
            // Acquire exclusive lock
247
            if (! flock($lockHandle, LOCK_EX)) {
28✔
NEW
248
                throw new RuntimeException('Unable to acquire lock on file: ' . $filePath);
×
249
            }
250

251
            // Clear file stat cache to get current file size
252
            clearstatcache(true, $filePath);
28✔
253

254
            $currentData = [];
28✔
255

256
            if (filesize($filePath) > 0) {
28✔
257
                $currentData = include $filePath;
8✔
258

259
                if (! is_array($currentData)) {
8✔
NEW
260
                    $currentData = [];
×
261
                }
262
            }
263

264
            // Merge our changes with current state
265
            $ourData    = $this->extractDataForContext($class, $context);
28✔
266
            $mergedData = array_merge($currentData, $ourData);
28✔
267

268
            // Generate PHP file content
269
            $content = '<?php' . PHP_EOL . PHP_EOL;
28✔
270
            $content .= 'return ' . var_export($mergedData, true) . ';' . PHP_EOL;
28✔
271

272
            // Write file
273
            if (file_put_contents($filePath, $content) === false) {
28✔
NEW
274
                throw new RuntimeException('Unable to write settings file: ' . $filePath);
×
275
            }
276

277
            @chmod($filePath, 0644);
28✔
278
        } finally {
279
            flock($lockHandle, LOCK_UN);
28✔
280
            fclose($lockHandle);
28✔
281
        }
282
    }
283

284
    /**
285
     * Extracts settings data for a specific class+context from in-memory storage.
286
     *
287
     * @return array<string,array>
288
     */
289
    private function extractDataForContext(string $class, ?string $context): array
290
    {
291
        $data       = [];
28✔
292
        $storedData = $this->getAllStored($class, $context);
28✔
293

294
        foreach ($storedData as $property => $valueData) {
28✔
295
            $data[$property] = [
27✔
296
                'value' => $valueData[0],
27✔
297
                'type'  => $valueData[1],
27✔
298
            ];
27✔
299
        }
300

301
        return $data;
28✔
302
    }
303

304
    /**
305
     * Generates a file path for a given class+context combination.
306
     *
307
     * Structure:
308
     * - Null context: writable/settings/Class_Name.php
309
     * - With context: writable/settings/{hash(context)}/Class_Name.php
310
     */
311
    private function getFilePath(string $class, ?string $context): string
312
    {
313
        $className = str_replace('\\', '_', $class);
30✔
314

315
        if ($context === null) {
30✔
316
            return $this->path . $className . '.php';
30✔
317
        }
318

319
        $contextHash = hash('xxh128', $context);
6✔
320

321
        return $this->path . $contextHash . DIRECTORY_SEPARATOR . $className . '.php';
6✔
322
    }
323

324
    /**
325
     * Generates a hydration key for a class+context combination.
326
     * Format: $class when context is null, $class::$context otherwise.
327
     */
328
    private function getHydrationKey(string $class, ?string $context): string
329
    {
330
        return $context === null ? $class : $class . '::' . $context;
30✔
331
    }
332
}
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