From 68af8af4c1699972dbdb86980597503e772f0884 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 25 Oct 2025 12:11:55 +0200 Subject: [PATCH 1/6] add support for FileHandler --- src/Commands/ClearSettings.php | 51 +++- src/Config/Settings.php | 10 + src/Handlers/ArrayHandler.php | 14 + src/Handlers/FileHandler.php | 310 ++++++++++++++++++++ tests/Commands/ClearSettingsTest.php | 198 +++++++++++++ tests/FileHandlerTest.php | 404 +++++++++++++++++++++++++++ 6 files changed, 983 insertions(+), 4 deletions(-) create mode 100644 src/Handlers/FileHandler.php create mode 100644 tests/Commands/ClearSettingsTest.php create mode 100644 tests/FileHandlerTest.php diff --git a/src/Commands/ClearSettings.php b/src/Commands/ClearSettings.php index 5fc5024..1b7bd45 100644 --- a/src/Commands/ClearSettings.php +++ b/src/Commands/ClearSettings.php @@ -7,18 +7,61 @@ class ClearSettings extends BaseCommand { - protected $group = 'Housekeeping'; + protected $group = 'Settings'; protected $name = 'settings:clear'; - protected $description = 'Clears all settings from the database.'; + protected $description = 'Clears all settings from persistent storage.'; public function run(array $params) { - if (CLI::prompt('This will delete all settings from the database. Are you sure you want to continue?', ['y', 'n'], 'required') !== 'y') { + $config = config('Settings'); + $handlers = $this->getHandlerNames($config); + + if ($handlers === null) { + CLI::write('No handlers available to clear in the config file.'); + + return; + } + + if (CLI::prompt('This will delete all settings from ' . $handlers . '. Are you sure you want to continue?', ['y', 'n'], 'required') !== 'y') { return; } service('settings')->flush(); - CLI::write('Settings cleared from the database.', 'green'); + CLI::write('Settings cleared from ' . $handlers . '.', 'green'); + } + + /** + * Gets a human-readable list of handler names. + * + * @param mixed $config + */ + private function getHandlerNames($config): ?string + { + if ($config->handlers === []) { + return null; + } + + $handlerNames = []; + + foreach ($config->handlers as $handler) { + // Get writeable handlers only (those that can be flushed) + if (isset($config->{$handler}['writeable']) && $config->{$handler}['writeable'] === true) { + $handlerNames[] = $handler; + } + } + + if ($handlerNames === []) { + return null; + } + + if (count($handlerNames) === 1) { + return $handlerNames[0] . ' handler'; + } + + // Multiple handlers: "database and file" + $last = array_pop($handlerNames); + + return implode(', ', $handlerNames) . ' and ' . $last . ' handlers'; } } diff --git a/src/Config/Settings.php b/src/Config/Settings.php index e23c8cc..61cedc4 100644 --- a/src/Config/Settings.php +++ b/src/Config/Settings.php @@ -5,6 +5,7 @@ use CodeIgniter\Config\BaseConfig; use CodeIgniter\Settings\Handlers\ArrayHandler; use CodeIgniter\Settings\Handlers\DatabaseHandler; +use CodeIgniter\Settings\Handlers\FileHandler; class Settings extends BaseConfig { @@ -34,4 +35,13 @@ class Settings extends BaseConfig 'group' => null, 'writeable' => true, ]; + + /** + * File handler settings. + */ + public $file = [ + 'class' => FileHandler::class, + 'path' => WRITEPATH . 'settings', + 'writeable' => true, + ]; } diff --git a/src/Handlers/ArrayHandler.php b/src/Handlers/ArrayHandler.php index 29467ee..8b4770e 100644 --- a/src/Handlers/ArrayHandler.php +++ b/src/Handlers/ArrayHandler.php @@ -115,4 +115,18 @@ protected function forgetStored(string $class, string $property, ?string $contex unset($this->contexts[$context][$class][$property]); } } + + /** + * Retrieves all stored properties for a specific class and context. + * + * @return array Format: ['property' => ['value', 'type']] + */ + protected function getAllStored(string $class, ?string $context): array + { + if ($context === null) { + return $this->general[$class] ?? []; + } + + return $this->contexts[$context][$class] ?? []; + } } diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php new file mode 100644 index 0000000..4e470ec --- /dev/null +++ b/src/Handlers/FileHandler.php @@ -0,0 +1,310 @@ + + */ + private array $hydrated = []; + + /** + * Base path where settings files are stored. + */ + private string $path; + + private Settings $config; + + /** + * Stores the configured file path and ensures it exists. + */ + public function __construct() + { + $this->config = config('Settings'); + $this->path = rtrim($this->config->file['path'] ?? WRITEPATH . 'settings', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + + if (! is_dir($this->path) && (! mkdir($this->path, 0755, true) && ! is_dir($this->path))) { + throw new RuntimeException('Unable to create settings directory: ' . $this->path); + } + + if (! is_writable($this->path)) { + throw new RuntimeException('Settings directory is not writable: ' . $this->path); + } + } + + /** + * Checks whether this handler has a value set. + */ + public function has(string $class, string $property, ?string $context = null): bool + { + $this->hydrate($class, $context); + + return $this->hasStored($class, $property, $context); + } + + /** + * Attempt to retrieve a value from the file. + * To boost performance, all values are read and stored + * on the first call for each class+context, then retrieved from storage. + * + * @return mixed|null + */ + public function get(string $class, string $property, ?string $context = null) + { + $this->hydrate($class, $context); + + return $this->getStored($class, $property, $context); + } + + /** + * Stores values into a file for later retrieval. + * + * @param mixed $value + * + * @return void + * + * @throws RuntimeException For file write failures + */ + public function set(string $class, string $property, $value = null, ?string $context = null) + { + $this->hydrate($class, $context); + + // Update in-memory storage first + $this->setStored($class, $property, $value, $context); + + // Persist to disk with file locking + $this->persist($class, $context); + } + + /** + * Deletes the record from persistent storage, if found, + * and from the local cache. + * + * @return void + * + * @throws RuntimeException For file write failures + */ + public function forget(string $class, string $property, ?string $context = null) + { + $this->hydrate($class, $context); + + // Delete from local storage + $this->forgetStored($class, $property, $context); + + // Persist to disk with file locking + $this->persist($class, $context); + } + + /** + * Deletes all settings files from persistent storage + * and clears the local cache. + * + * @return void + * + * @throws RuntimeException For file deletion failures + */ + public function flush() + { + // Get all settings files + $files = glob($this->path . '*.php', GLOB_NOSORT); + + if ($files === false) { + throw new RuntimeException('Unable to read settings directory: ' . $this->path); + } + + // Delete all files + foreach ($files as $file) { + if (! unlink($file)) { + throw new RuntimeException('Unable to delete settings file: ' . $file); + } + } + + // Clear local storage and hydration tracking + parent::flush(); + $this->hydrated = []; + } + + /** + * Fetches values from files in bulk to minimize I/O operations. + * Loads all properties for a specific class+context combination. + * + * @throws RuntimeException For file read failures + */ + private function hydrate(string $class, ?string $context): void + { + $key = $this->getHydrationKey($class, $context); + + // Check if already loaded + if (in_array($key, $this->hydrated, true)) { + return; + } + + // Load the specific class+context file + $this->loadFromFile($class, $context); + $this->hydrated[] = $key; + + // Also load general context for this class if not already loaded + if ($context !== null) { + $generalKey = $this->getHydrationKey($class, null); + + if (! in_array($generalKey, $this->hydrated, true)) { + $this->loadFromFile($class, null); + $this->hydrated[] = $generalKey; + } + } + } + + /** + * Loads settings from a file for a given class+context. + * + * @throws RuntimeException For file read failures + */ + private function loadFromFile(string $class, ?string $context): void + { + $filePath = $this->getFilePath($class, $context); + + // If file doesn't exist, that's fine - no settings stored yet + if (! file_exists($filePath)) { + return; + } + + // Use include to get the data array + $data = include $filePath; + + if (! is_array($data)) { + throw new RuntimeException('Settings file does not return an array: ' . $filePath); + } + + // Load data into in-memory storage + foreach ($data as $property => $valueData) { + if (! is_array($valueData) || ! isset($valueData['value'], $valueData['type'])) { + continue; + } + + $this->setStored($class, $property, $this->parseValue($valueData['value'], $valueData['type']), $context); + } + } + + /** + * Persists current in-memory settings for a class+context to disk. + * Uses file locking to prevent race conditions during concurrent writes. + * + * @throws RuntimeException For file write failures + */ + private function persist(string $class, ?string $context): void + { + $filePath = $this->getFilePath($class, $context); + + // Ensure directory exists (especially for context subdirectories) + $directory = dirname($filePath); + + if (! is_dir($directory) && (! mkdir($directory, 0755, true) && ! is_dir($directory))) { + throw new RuntimeException('Unable to create directory: ' . $directory); + } + + // Open/create file for locking + $lockHandle = fopen($filePath, 'c+b'); + + if ($lockHandle === false) { + throw new RuntimeException('Unable to open file for locking: ' . $filePath); + } + + try { + // Acquire exclusive lock + if (! flock($lockHandle, LOCK_EX)) { + throw new RuntimeException('Unable to acquire lock on file: ' . $filePath); + } + + // Clear file stat cache to get current file size + clearstatcache(true, $filePath); + + $currentData = []; + + if (filesize($filePath) > 0) { + $currentData = include $filePath; + + if (! is_array($currentData)) { + $currentData = []; + } + } + + // Merge our changes with current state + $ourData = $this->extractDataForContext($class, $context); + $mergedData = array_merge($currentData, $ourData); + + // Generate PHP file content + $content = ' + */ + private function extractDataForContext(string $class, ?string $context): array + { + $data = []; + $storedData = $this->getAllStored($class, $context); + + foreach ($storedData as $property => $valueData) { + $data[$property] = [ + 'value' => $valueData[0], + 'type' => $valueData[1], + ]; + } + + return $data; + } + + /** + * Generates a file path for a given class+context combination. + * + * Structure: + * - Null context: writable/settings/Class_Name.php + * - With context: writable/settings/{hash(context)}/Class_Name.php + */ + private function getFilePath(string $class, ?string $context): string + { + $className = str_replace('\\', '_', $class); + + if ($context === null) { + return $this->path . $className . '.php'; + } + + $contextHash = hash('xxh128', $context); + + return $this->path . $contextHash . DIRECTORY_SEPARATOR . $className . '.php'; + } + + /** + * Generates a hydration key for a class+context combination. + * Format: $class when context is null, $class::$context otherwise. + */ + private function getHydrationKey(string $class, ?string $context): string + { + return $context === null ? $class : $class . '::' . $context; + } +} diff --git a/tests/Commands/ClearSettingsTest.php b/tests/Commands/ClearSettingsTest.php new file mode 100644 index 0000000..a0d88b0 --- /dev/null +++ b/tests/Commands/ClearSettingsTest.php @@ -0,0 +1,198 @@ +handlers = ['array']; + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + $output = CITestStreamFilter::$buffer; + + $this->assertStringContainsString('array handler', $output); + $this->assertStringContainsString('Settings cleared from array handler', $output); + } + + public function testSingleHandlerFile() + { + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent("y\n"); + + $config = config('Settings'); + $config->handlers = ['file']; + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + $output = CITestStreamFilter::$buffer; + + $this->assertStringContainsString('file handler', $output); + $this->assertStringContainsString('Settings cleared from file handler', $output); + } + + public function testMultipleHandlers() + { + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent("y\n"); + + $config = config('Settings'); + $config->handlers = ['array', 'file']; + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + $output = CITestStreamFilter::$buffer; + + $this->assertStringContainsString('array and file handlers', $output); + $this->assertStringContainsString('Settings cleared from array and file handlers', $output); + } + + public function testThreeHandlers() + { + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent("y\n"); + + $config = config('Settings'); + $config->handlers = ['array', 'file']; + $config->array['writeable'] = false; // Make array not writeable + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + $output = CITestStreamFilter::$buffer; + + $this->assertStringContainsString('file handler', $output); + } + + public function testNoWriteableHandlers() + { + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent("y\n"); + + $config = config('Settings'); + $config->handlers = ['array']; + $config->array['writeable'] = false; + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + $output = CITestStreamFilter::$buffer; + + $this->assertStringContainsString('No handlers available to clear', $output); + } + + public function testEmptyHandlersArray() + { + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent("y\n"); + + $config = config('Settings'); + $config->handlers = []; + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + $output = CITestStreamFilter::$buffer; + + $this->assertStringContainsString('No handlers available to clear', $output); + } + + public function testUserCancelsOperation() + { + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent("n\n"); // User answers 'n' to prompt + + $config = config('Settings'); + $config->handlers = ['array']; + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + $output = CITestStreamFilter::$buffer; + + // Should show the prompt but not the success message + $this->assertStringContainsString('delete all settings from array handler', $output); + $this->assertStringNotContainsString('Settings cleared', $output); + } + + public function testUserConfirmsOperation() + { + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent("y\n"); // User answers 'y' to prompt + + $config = config('Settings'); + $config->handlers = ['array']; + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + $output = CITestStreamFilter::$buffer; + + // Should show both prompt and success message + $this->assertStringContainsString('delete all settings from array handler', $output); + $this->assertStringContainsString('Settings cleared from array handler', $output); + } + + public function testActuallyFlushesSettings() + { + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent("y\n"); + + // Set some settings + $settings = service('settings'); + $settings->set('Example.siteName', 'Test'); + + $this->assertSame('Test', $settings->get('Example.siteName')); + + // Run clear command + $config = config('Settings'); + $config->handlers = ['array']; + + command('settings:clear'); + + PhpStreamWrapper::restore(); + + // Verify settings were cleared + $this->assertSame('Settings Test', $settings->get('Example.siteName')); // Back to default + } +} diff --git a/tests/FileHandlerTest.php b/tests/FileHandlerTest.php new file mode 100644 index 0000000..6e9087a --- /dev/null +++ b/tests/FileHandlerTest.php @@ -0,0 +1,404 @@ +path = sys_get_temp_dir() . '/settings_test_' . uniqid() . '/'; + + /** @var ConfigSettings $config */ + $config = config('Settings'); + $config->handlers = ['file']; + $config->file['path'] = $this->path; + + $this->settings = new Settings($config); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // Clean up test directory + if (is_dir($this->path)) { + $files = glob($this->path . '*', GLOB_NOSORT); + + foreach ($files as $file) { + if (is_file($file)) { + @unlink($file); + } + } + @rmdir($this->path); + } + } + + public function testSetCreatesDirectory() + { + $this->assertDirectoryExists($this->path); + $this->assertIsWritable($this->path); + } + + public function testSetCreatesFile() + { + $this->settings->set('Example.siteName', 'Foo'); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(1, $files); + $this->assertStringEndsWith('.php', $files[0]); + } + + public function testSetStoresString() + { + $this->settings->set('Example.siteName', 'Foo'); + + $this->assertSame('Foo', $this->settings->get('Example.siteName')); + } + + public function testSetStoresBoolTrue() + { + $this->settings->set('Example.siteName', true); + + $this->assertTrue($this->settings->get('Example.siteName')); + } + + public function testSetStoresBoolFalse() + { + $this->settings->set('Example.siteName', false); + + $this->assertFalse($this->settings->get('Example.siteName')); + } + + public function testSetStoresNull() + { + $this->settings->set('Example.siteName', null); + + $this->assertNull($this->settings->get('Example.siteName')); + } + + public function testSetStoresInteger() + { + $this->settings->set('Example.siteName', 42); + + $this->assertSame(42, $this->settings->get('Example.siteName')); + } + + public function testSetStoresFloat() + { + $this->settings->set('Example.siteName', 3.14); + + $this->assertSame(3.14, $this->settings->get('Example.siteName')); + } + + public function testSetStoresArray() + { + $data = ['foo' => 'bar', 'baz' => 'qux']; + $this->settings->set('Example.siteName', $data); + + $this->assertSame($data, $this->settings->get('Example.siteName')); + } + + public function testSetStoresObject() + { + $data = (object) ['foo' => 'bar']; + $this->settings->set('Example.siteName', $data); + + $result = $this->settings->get('Example.siteName'); + $this->assertSame((array) $data, (array) $result); + } + + public function testSetUpdatesExistingValue() + { + $this->settings->set('Example.siteName', 'Foo'); + $this->assertSame('Foo', $this->settings->get('Example.siteName')); + + $this->settings->set('Example.siteName', 'Bar'); + $this->assertSame('Bar', $this->settings->get('Example.siteName')); + + // Should still only have one file + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(1, $files); + } + + public function testGetNonExistentReturnsNull() + { + $this->assertNull($this->settings->get('Example.nonExistent')); + } + + public function testWorksWithoutConfigClass() + { + $this->settings->set('Nada.siteName', 'Bar'); + + $this->assertSame('Bar', $this->settings->get('Nada.siteName')); + } + + public function testForgetRemovesValue() + { + $this->settings->set('Example.siteName', 'Foo'); + $this->assertSame('Foo', $this->settings->get('Example.siteName')); + + $this->settings->forget('Example.siteName'); + + // Should fall back to default value from config file + $this->assertSame('Settings Test', $this->settings->get('Example.siteName')); + } + + public function testForgetWithNoStoredRecord() + { + // Should not throw an exception + $this->settings->forget('Example.siteName'); + + // Should return default value from config file + $this->assertSame('Settings Test', $this->settings->get('Example.siteName')); + } + + public function testFlushRemovesAllFiles() + { + $this->settings->set('Example.siteName', 'Foo'); + $this->settings->set('Example.siteEmail', 'test@example.com'); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertNotEmpty($files); + + $this->settings->flush(); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertEmpty($files); + + // Should be back to the default value + $this->assertSame('Settings Test', $this->settings->get('Example.siteName')); + } + + public function testSetWithContext() + { + $this->settings->set('Example.siteName', 'Banana', 'environment:test'); + + $this->assertSame('Banana', $this->settings->get('Example.siteName', 'environment:test')); + } + + public function testSetUpdatesContextOnly() + { + $this->settings->set('Example.siteName', 'Humpty'); + $this->settings->set('Example.siteName', 'Jack', 'context:male'); + $this->settings->set('Example.siteName', 'Jill', 'context:female'); + $this->settings->set('Example.siteName', 'Jane', 'context:female'); + + $this->assertSame('Humpty', $this->settings->get('Example.siteName')); + $this->assertSame('Jack', $this->settings->get('Example.siteName', 'context:male')); + $this->assertSame('Jane', $this->settings->get('Example.siteName', 'context:female')); + } + + public function testContextFallsBackToGeneral() + { + $this->settings->set('Example.siteName', 'General'); + + // Should return general value when context-specific value doesn't exist + $this->assertSame('General', $this->settings->get('Example.siteName', 'context:nonexistent')); + } + + public function testMultiplePropertiesInSameFile() + { + $this->settings->set('Example.siteName', 'Foo'); + $this->settings->set('Example.siteEmail', 'test@example.com'); + + $this->assertSame('Foo', $this->settings->get('Example.siteName')); + $this->assertSame('test@example.com', $this->settings->get('Example.siteEmail')); + + // Should only have one file for same class + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(1, $files); + } + + public function testDifferentClassesCreateDifferentFiles() + { + $this->settings->set('Example.siteName', 'Foo'); + $this->settings->set('Nada.siteName', 'Bar'); + + $this->assertSame('Foo', $this->settings->get('Example.siteName')); + $this->assertSame('Bar', $this->settings->get('Nada.siteName')); + + // Should have two files - one per class + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(2, $files); + } + + public function testPersistenceAcrossInstances() + { + // Set value in first instance + $this->settings->set('Example.siteName', 'Persistent'); + + // Create new instance + /** @var ConfigSettings $config */ + $config = config('Settings'); + $config->handlers = ['file']; + $config->file['path'] = $this->path; + $newSettings = new Settings($config); + + // Should retrieve value from file + $this->assertSame('Persistent', $newSettings->get('Example.siteName')); + } + + public function testFileContentIsValidPHP() + { + $this->settings->set('Example.siteName', 'Test'); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertNotEmpty($files); + + $content = file_get_contents($files[0]); + + // Should start with PHP tag + $this->assertStringStartsWith('assertStringContainsString('return', $content); + + // Should be valid PHP (no syntax errors) + $data = include $files[0]; + $this->assertIsArray($data); + } + + public function testUsesClassNameForFilename() + { + $this->settings->set('Example.siteName', 'Test'); + + // Null context files use class name with backslashes replaced by underscores + $expectedFile = $this->path . 'Tests_Support_Config_Example.php'; + + $this->assertFileExists($expectedFile); + } + + public function testContextUsesHashedSubdirectory() + { + $context = 'environment:production'; + $this->settings->set('Example.siteName', 'Prod', $context); + + // Context files are in subdirectories named by context hash + $contextHash = hash('xxh128', $context); + $expectedFile = $this->path . $contextHash . '/Tests_Support_Config_Example.php'; + + $this->assertFileExists($expectedFile); + } + + public function testEmptyStringContextIsDifferentFromNull() + { + // Set with null context + $this->settings->set('Example.siteName', 'Null', null); + + // Set with empty string context + $this->settings->set('Example.siteName', 'Empty', ''); + + // Null context: file in main directory + $nullContextFile = $this->path . 'Tests_Support_Config_Example.php'; + $this->assertFileExists($nullContextFile); + + // Empty string context: file in subdirectory + $emptyContextHash = hash('xxh128', ''); + $emptyContextFile = $this->path . $emptyContextHash . '/Tests_Support_Config_Example.php'; + $this->assertFileExists($emptyContextFile); + + // Verify correct values + $this->assertSame('Null', $this->settings->get('Example.siteName', null)); + $this->assertSame('Empty', $this->settings->get('Example.siteName', '')); + } + + public function testConcurrentReadsDontLoadFileTwice() + { + $this->settings->set('Example.siteName', 'Test'); + $this->settings->set('Example.siteEmail', 'test@example.com'); + + // First get - loads from file + $value1 = $this->settings->get('Example.siteName'); + + // Modify file directly + $files = glob($this->path . '*.php', GLOB_NOSORT); + file_put_contents($files[0], " 'data'];"); + + // Second get - should use cached value, not reload from file + $value2 = $this->settings->get('Example.siteEmail'); + + $this->assertSame('Test', $value1); + $this->assertSame('test@example.com', $value2); + } + + public function testHasReturnsTrueWhenValueExists() + { + $this->settings->set('Example.siteName', 'Test'); + + // Access has() method through reflection since it's not exposed via Settings class + $reflection = new ReflectionClass($this->settings); + $handlersProperty = $reflection->getProperty('handlers'); + $handlers = $handlersProperty->getValue($this->settings); + + $this->assertTrue($handlers['file']->has('Tests\Support\Config\Example', 'siteName')); + } + + public function testHasReturnsFalseWhenValueDoesNotExist() + { + $reflection = new ReflectionClass($this->settings); + $handlersProperty = $reflection->getProperty('handlers'); + $handlers = $handlersProperty->getValue($this->settings); + + $this->assertFalse($handlers['file']->has('Tests\Support\Config\Example', 'nonExistent')); + } + + /** + * Simulate writes from different PHP processes (each with separate in-memory state) + * by manually modifying the file between operations + */ + public function testMergesChangesFromDifferentProcesses() + { + // Process A writes siteName + $this->settings->set('Example.siteName', 'First'); + + // Get the file path + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(1, $files); + $filePath = $files[0]; + + // Simulate Process B writing siteEmail (has different in-memory state) + // Process B doesn't know about siteName in its memory, but it exists on disk + $data = include $filePath; + $data['siteEmail'] = ['value' => 'concurrent@example.com', 'type' => 'string']; + $content = "settings->set('Example.siteTitle', 'Second'); + + // Verify all three properties exist + $this->assertSame('First', $this->settings->get('Example.siteName')); + $this->assertSame('Second', $this->settings->get('Example.siteTitle')); + + // Reload from disk to verify persistence + /** @var ConfigSettings $config */ + $config = config('Settings'); + $config->handlers = ['file']; + $config->file['path'] = $this->path; + $newSettings = new Settings($config); + + $this->assertSame('First', $newSettings->get('Example.siteName')); + $this->assertSame('concurrent@example.com', $newSettings->get('Example.siteEmail')); + $this->assertSame('Second', $newSettings->get('Example.siteTitle')); + } +} From 081bb2c13817c5b74995f8b3bf16630ea0df5cec Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 25 Oct 2025 14:32:10 +0200 Subject: [PATCH 2/6] add publish command --- src/Commands/PublishSettings.php | 53 ++++++++++++++++++++++++++ tests/Commands/PublishSettingsTest.php | 36 +++++++++++++++++ tests/_support/TestCase.php | 41 +++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/Commands/PublishSettings.php create mode 100644 tests/Commands/PublishSettingsTest.php diff --git a/src/Commands/PublishSettings.php b/src/Commands/PublishSettings.php new file mode 100644 index 0000000..5e468ff --- /dev/null +++ b/src/Commands/PublishSettings.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Settings\Commands; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Publisher\Publisher; +use Throwable; + +class PublishSettings extends BaseCommand +{ + protected $group = 'Settings'; + protected $name = 'settings:publish'; + protected $description = 'Publish Settings config file into the current application.'; + + public function run(array $params): void + { + $source = service('autoloader')->getNamespace('CodeIgniter\\Settings')[0]; + + $publisher = new Publisher($source, APPPATH); + + try { + $publisher->addPaths([ + 'Config/Settings.php', + ])->merge(false); + } catch (Throwable $e) { + $this->showError($e); + + return; + } + + foreach ($publisher->getPublished() as $file) { + $contents = file_get_contents($file); + $contents = str_replace('namespace CodeIgniter\\Settings\\Config', 'namespace Config', $contents); + $contents = str_replace('use CodeIgniter\\Config\\BaseConfig', 'use CodeIgniter\\Settings\\Config\\Settings as BaseSettings', $contents); + $contents = str_replace('class Settings extends BaseConfig', 'class Settings extends BaseSettings', $contents); + file_put_contents($file, $contents); + } + + CLI::write(CLI::color(' Published! ', 'green') . 'You can customize the configuration by editing the "app/Config/Settings.php" file.'); + } +} diff --git a/tests/Commands/PublishSettingsTest.php b/tests/Commands/PublishSettingsTest.php new file mode 100644 index 0000000..5370b47 --- /dev/null +++ b/tests/Commands/PublishSettingsTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\Test\Filters\CITestStreamFilter; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PublishSettingsTest extends TestCase +{ + public function testRun(): void + { + CITestStreamFilter::registration(); + CITestStreamFilter::addOutputFilter(); + + $this->assertNotFalse(command('settings:publish')); + $output = $this->parseOutput(CITestStreamFilter::$buffer); + + CITestStreamFilter::removeOutputFilter(); + + $this->assertSame(' Published! You can customize the configuration by editing the "app/Config/Settings.php" file.', $output); + } +} diff --git a/tests/_support/TestCase.php b/tests/_support/TestCase.php index a147ac6..ad11f40 100644 --- a/tests/_support/TestCase.php +++ b/tests/_support/TestCase.php @@ -2,13 +2,18 @@ namespace Tests\Support; -use CodeIgniter\Settings\Handlers\ArrayHandler; +use CodeIgniter\CLI\CLI; use CodeIgniter\Settings\Settings; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\ReflectionHelper; use Config\Services; abstract class TestCase extends CIUnitTestCase { + use ReflectionHelper; + + private array $lines = []; + /** * @var Settings */ @@ -34,4 +39,38 @@ protected function tearDown(): void $this->resetServices(); } + + protected function parseOutput(string $output): string + { + $this->lines = []; + $output = $this->removeColorCodes($output); + $this->lines = explode("\n", $output); + + return $output; + } + + protected function getLine(int $line = 0): ?string + { + return $this->lines[$line] ?? null; + } + + protected function getLines(): string + { + return implode('', $this->lines); + } + + protected function removeColorCodes(string $output): string + { + $colors = $this->getPrivateProperty(CLI::class, 'foreground_colors'); + $colors = array_values(array_map(static fn ($color) => "\033[" . $color . 'm', $colors)); + $colors = array_merge(["\033[0m"], $colors); + + $output = str_replace($colors, '', trim($output)); + + if (is_windows()) { + $output = str_replace("\r\n", "\n", $output); + } + + return $output; + } } From 0de68b3c7ff55ae3073564b0c47ec1196efb3844 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 25 Oct 2025 15:40:07 +0200 Subject: [PATCH 3/6] update flush method to work with context folders --- src/Handlers/FileHandler.php | 26 ++++++++++++++++++++++++-- tests/FileHandlerTest.php | 30 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index 4e470ec..c354fb4 100644 --- a/src/Handlers/FileHandler.php +++ b/src/Handlers/FileHandler.php @@ -116,20 +116,42 @@ public function forget(string $class, string $property, ?string $context = null) */ public function flush() { - // Get all settings files + // Delete all .php files in main directory (null context files) $files = glob($this->path . '*.php', GLOB_NOSORT); if ($files === false) { throw new RuntimeException('Unable to read settings directory: ' . $this->path); } - // Delete all files foreach ($files as $file) { if (! unlink($file)) { throw new RuntimeException('Unable to delete settings file: ' . $file); } } + // Delete all context subdirectories and their contents + $directories = glob($this->path . '*', GLOB_ONLYDIR | GLOB_NOSORT); + + if ($directories !== false) { + foreach ($directories as $directory) { + // Delete all files inside the directory + $contextFiles = glob($directory . '/*.php', GLOB_NOSORT); + + if ($contextFiles !== false) { + foreach ($contextFiles as $file) { + if (! unlink($file)) { + throw new RuntimeException('Unable to delete settings file: ' . $file); + } + } + } + + // Remove the empty directory + if (! rmdir($directory)) { + throw new RuntimeException('Unable to delete directory: ' . $directory); + } + } + } + // Clear local storage and hydration tracking parent::flush(); $this->hydrated = []; diff --git a/tests/FileHandlerTest.php b/tests/FileHandlerTest.php index 6e9087a..f794b20 100644 --- a/tests/FileHandlerTest.php +++ b/tests/FileHandlerTest.php @@ -188,6 +188,36 @@ public function testFlushRemovesAllFiles() $this->assertSame('Settings Test', $this->settings->get('Example.siteName')); } + public function testFlushRemovesFilesAndContextDirectories() + { + // Create files in main directory (null context) + $this->settings->set('Example.siteName', 'Main'); + $this->settings->set('Example.siteEmail', 'main@example.com'); + + // Create files in context subdirectories + $this->settings->set('Example.siteName', 'Production', 'production'); + $this->settings->set('Example.siteTitle', 'Prod Site', 'production'); + $this->settings->set('Example.siteName', 'Testing', 'testing'); + + $mainFiles = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertNotEmpty($mainFiles); + + $directories = glob($this->path . '*', GLOB_ONLYDIR | GLOB_NOSORT); + $this->assertCount(2, $directories); // production and testing + + $this->settings->flush(); + + $mainFiles = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertEmpty($mainFiles); + + $directories = glob($this->path . '*', GLOB_ONLYDIR | GLOB_NOSORT); + $this->assertEmpty($directories); + + // Should be back to default values + $this->assertSame('Settings Test', $this->settings->get('Example.siteName')); + $this->assertSame('Settings Test', $this->settings->get('Example.siteName', 'production')); + } + public function testSetWithContext() { $this->settings->set('Example.siteName', 'Banana', 'environment:test'); From b971015550e0c3d434cf8982596d00344c23e3a3 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 25 Oct 2025 17:46:14 +0200 Subject: [PATCH 4/6] update docs --- docs/configuration.md | 118 ++++++++++++++++++++++++++++++++++++++++++ docs/limitations.md | 4 +- mkdocs.yml | 3 +- 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 docs/configuration.md diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..8634790 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,118 @@ +# Configuration + +To make changes to the config file, you need to have your own copy in `app/Config/Settings.php`. The easiest way to do it is by using the publish command. + +When you run: + + php spark settings:publish + +You will get your copy ready for modifications. + +--- + +## Handlers + +An array of handler aliases to use for storing and retrieving settings. Handlers are checked in order, with the first handler that has a value returning it. + +**Type:** `array` + +**Default:** `['database']` + +**Available handlers:** `database`, `file`, `array` + +Example: + +```php +public $handlers = ['database']; +``` +### Multiple handlers + +When multiple handlers are configured, they are checked in the order specified in $handlers. The first handler that has a value for the requested setting will return it. + +Example with fallback: + +```php +public $handlers = ['file', 'database']; +``` +This configuration will: + +1. Check the file handler first +2. If not found, check the database handler +3. If not found in any handler, return the default value from the config file + +### Writeable Handlers + +Only handlers marked as `writeable => true` will be used when calling `set()`, `forget()`, or `flush()` methods. + +## DatabaseHandler + +This handler stores settings in a database table and is production-ready for high-traffic applications. + +**Available options:** + +* `class` - The handler class. Default: `DatabaseHandler::class` +* `table` - The database table name for storing settings. Default: `'settings'` +* `group` - The database connection group to use. Default: `null` (uses default connection) +* `writeable` - Whether this handler supports write operations. Default: `true` + +Example: + +```php +public $database = [ + 'class' => DatabaseHandler::class, + 'table' => 'settings', + 'group' => null, + 'writeable' => true, +]; +``` + +!!! note + You need to run migrations to create the settings table: `php spark migrate -n CodeIgniter\\Settings` + +--- + +## FileHandler + +This handler stores settings as PHP files and is optimized for production use with built-in race condition protection. + +**Available options:** + +* `class` - The handler class. Default: `FileHandler::class` +* `path` - The directory path where settings files are stored. Default: `WRITEPATH . 'settings'` +* `writeable` - Whether this handler supports write operations. Default: `true` + +Example: + +```php +public $file = [ + 'class' => FileHandler::class, + 'path' => WRITEPATH . 'settings', + 'writeable' => true, +]; +``` + +!!! note + The `FileHandler` automatically creates the directory if it doesn't exist and checks write permissions on instantiation. + +--- + +## ArrayHandler + +This handler stores settings in memory only and is primarily useful for testing or as a parent class for other handlers. + +**Available options:** + +* `class` - The handler class. Default: `ArrayHandler::class` +* `writeable` - Whether this handler supports write operations. Default: `true` + +Example: + +```php +public $array = [ + 'class' => ArrayHandler::class, + 'writeable' => true, +]; +``` + +!!! note + `ArrayHandler` does not persist data between requests. It's mainly used for testing or extended by other handlers. diff --git a/docs/limitations.md b/docs/limitations.md index c1c8cf3..5cc6cf3 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -2,8 +2,8 @@ The following are known limitations of the library: -1. You can currently only store a single setting at a time. While the `DatabaseHandler` uses a local cache to - keep performance as high as possible for reads, writes must be done one at a time. +1. You can currently only store a single setting at a time. While the `DatabaseHandler` and `FileHandler` + uses a local cache to keep performance as high as possible for reads, writes must be done one at a time. 2. You can only access the first level within a property directly. In most config classes this is a non-issue, since the properties are simple values. Some config files, like the `database` file, contain properties that are arrays. diff --git a/mkdocs.yml b/mkdocs.yml index 4795ac0..a80cf39 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,7 +53,7 @@ extra: site_url: https://settings.codeigniter.com/ repo_url: https://github.com/codeigniter4/settings edit_uri: edit/develop/docs/ -copyright: Copyright © 2023 CodeIgniter Foundation. +copyright: Copyright © 2025 CodeIgniter Foundation. markdown_extensions: - admonition @@ -73,5 +73,6 @@ extra_javascript: nav: - Home: index.md - Installation: installation.md + - Configuration: configuration.md - Basic usage: basic-usage.md - Limitations: limitations.md From d96fd5b6a225faa4c16b327f97e6e20773b15b93 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 26 Oct 2025 11:31:58 +0100 Subject: [PATCH 5/6] apply changes from the code review --- src/Commands/ClearSettings.php | 5 ++--- src/Handlers/ArrayHandler.php | 2 +- src/Handlers/DatabaseHandler.php | 2 +- src/Handlers/FileHandler.php | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Commands/ClearSettings.php b/src/Commands/ClearSettings.php index 1b7bd45..73c6bcc 100644 --- a/src/Commands/ClearSettings.php +++ b/src/Commands/ClearSettings.php @@ -4,6 +4,7 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use CodeIgniter\Settings\Config\Settings; class ClearSettings extends BaseCommand { @@ -33,10 +34,8 @@ public function run(array $params) /** * Gets a human-readable list of handler names. - * - * @param mixed $config */ - private function getHandlerNames($config): ?string + private function getHandlerNames(Settings $config): ?string { if ($config->handlers === []) { return null; diff --git a/src/Handlers/ArrayHandler.php b/src/Handlers/ArrayHandler.php index 8b4770e..5b37250 100644 --- a/src/Handlers/ArrayHandler.php +++ b/src/Handlers/ArrayHandler.php @@ -68,7 +68,7 @@ protected function hasStored(string $class, string $property, ?string $context): /** * Retrieves a value from storage. * - * @return mixed|null + * @return mixed */ protected function getStored(string $class, string $property, ?string $context) { diff --git a/src/Handlers/DatabaseHandler.php b/src/Handlers/DatabaseHandler.php index d93b34c..b3dea4c 100644 --- a/src/Handlers/DatabaseHandler.php +++ b/src/Handlers/DatabaseHandler.php @@ -59,7 +59,7 @@ public function has(string $class, string $property, ?string $context = null): b * read and stored the first call for each contexts * and then retrieved from storage. * - * @return mixed|null + * @return mixed */ public function get(string $class, string $property, ?string $context = null) { diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index c354fb4..2dad4fa 100644 --- a/src/Handlers/FileHandler.php +++ b/src/Handlers/FileHandler.php @@ -58,7 +58,7 @@ public function has(string $class, string $property, ?string $context = null): b * To boost performance, all values are read and stored * on the first call for each class+context, then retrieved from storage. * - * @return mixed|null + * @return mixed */ public function get(string $class, string $property, ?string $context = null) { From c647cb4445765b6895437e3ab9107e58553ae9a9 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 28 Oct 2025 07:52:09 +0100 Subject: [PATCH 6/6] apply docs changes from the review --- docs/configuration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8634790..a90effc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,15 +25,15 @@ Example: ```php public $handlers = ['database']; ``` -### Multiple handlers -When multiple handlers are configured, they are checked in the order specified in $handlers. The first handler that has a value for the requested setting will return it. +### Multiple handlers -Example with fallback: +Example: ```php public $handlers = ['file', 'database']; ``` + This configuration will: 1. Check the file handler first