From 706a170a66017a1492110f3ce8e32943e87c817f Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:53:17 +0200 Subject: [PATCH 1/3] feat(settings): add batch setting operations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- docs/basic-usage.md | 37 ++++++ docs/configuration.md | 23 ++++ docs/limitations.md | 3 +- src/Handlers/BaseHandler.php | 30 +++++ src/Handlers/DatabaseHandler.php | 221 ++++++++++++++++++++++++------- src/Handlers/FileHandler.php | 79 +++++++++++ src/Settings.php | 61 +++++++++ tests/DatabaseHandlerTest.php | 174 ++++++++++++++++++++++++ tests/FileHandlerTest.php | 111 ++++++++++++++++ tests/SettingsTest.php | 50 +++++++ 10 files changed, 737 insertions(+), 52 deletions(-) diff --git a/docs/basic-usage.md b/docs/basic-usage.md index 0030fa2..1305f2b 100644 --- a/docs/basic-usage.md +++ b/docs/basic-usage.md @@ -30,6 +30,16 @@ when retrieved. service('settings')->set('App.siteName', 'My Great Site'); ``` +You can save multiple values with `setMany()`. This behaves like calling `set()` for each key/value pair, +but allows supported handlers to persist the changes more efficiently. + +```php +service('settings')->setMany([ + 'App.siteName' => 'My Great Site', + 'App.siteEmail' => 'support@example.com', +]); +``` + You can delete a value from the persistent storage with the `forget()` method. Since it is removed from the storage, it effectively resets itself back to the default value in config file, if any. @@ -37,6 +47,15 @@ it effectively resets itself back to the default value in config file, if any. service('settings')->forget('App.siteName'); ``` +You can delete multiple values with `forgetMany()`. + +```php +service('settings')->forgetMany([ + 'App.siteName', + 'App.siteEmail', +]); +``` + If you ever need to completely remove all settings from their persistent storage, you can use the `flush()` method. This immediately removes all settings from the database and the in-memory cache. ```php @@ -62,6 +81,16 @@ $context = 'user:' . user_id(); service('settings')->set('App.theme', 'dark', $context); ``` +The same context can be applied to a batch of values: + +```php +$context = 'user:' . user_id(); +service('settings')->setMany([ + 'App.theme' => 'dark', + 'App.locale' => 'en', +], $context); +``` + Now when your filter is determining which theme to apply it can check for the current user as the context: ```php @@ -91,9 +120,17 @@ setting('App.siteName', 'My Great Site'); // Using the service through the helper $name = setting()->get('App.siteName'); setting()->set('App.siteName', 'My Great Site'); +setting()->setMany([ + 'App.siteName' => 'My Great Site', + 'App.siteEmail' => 'support@example.com', +]); // Forgetting a value setting()->forget('App.siteName'); +setting()->forgetMany([ + 'App.siteName', + 'App.siteEmail', +]); ``` !!! Note diff --git a/docs/configuration.md b/docs/configuration.md index 22e0528..b48cd8d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,9 @@ Handlers like `database` and `file` support deferred writes. When `deferWrites` are batched and persisted efficiently at the end of the request during the `post_system` event. This minimizes the number of database queries or file I/O operations, improving performance for write-heavy operations. +This is separate from the explicit `setMany()` and `forgetMany()` APIs. Batch APIs allow callers to group multiple settings in +one method call, while deferred writes decide whether writes are persisted immediately or at the end of the request. + ### Multiple handlers Example: @@ -94,6 +97,16 @@ $settings->set('Example.prop3', 'value3'); The deferred approach is especially beneficial when updating existing records or performing many operations in a single request. +For explicit batches, use `setMany()` or `forgetMany()`: + +```php +$settings->setMany([ + 'Example.prop1' => 'value1', + 'Example.prop2' => 'value2', + 'Example.prop3' => 'value3', +]); +``` + --- ## FileHandler @@ -136,6 +149,16 @@ $settings->set('Example.prop3', 'value3'); The deferred approach is especially beneficial when updating multiple properties in the same class. +For explicit batches, use `setMany()` or `forgetMany()`: + +```php +$settings->setMany([ + 'Example.prop1' => 'value1', + 'Example.prop2' => 'value2', + 'Example.prop3' => 'value3', +]); +``` + --- ## ArrayHandler diff --git a/docs/limitations.md b/docs/limitations.md index d59c9c7..25f769f 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -5,7 +5,8 @@ The following are known limitations of the library: 1. **Immediate writes (`deferWrites => false`)**: Each setting is written to storage immediately when you call `set()` or `forget()`. The first operation hydrates all settings for that context (1 SELECT query), then each subsequent write performs a separate INSERT or UPDATE. While `DatabaseHandler` and `FileHandler` use an in-memory cache to maintain fast reads, individual write - operations may result in multiple database queries or file writes per request. + operations may result in multiple database queries or file writes per request. Use `setMany()` or `forgetMany()` when you + want to explicitly persist multiple settings in one batch operation. 2. **Deferred writes (`deferWrites => true`)**: All settings are batched and written to storage at the end of the request (during the `post_system` event). This minimizes the number of database queries and file writes, improving performance. diff --git a/src/Handlers/BaseHandler.php b/src/Handlers/BaseHandler.php index 95f96fd..8649c7a 100644 --- a/src/Handlers/BaseHandler.php +++ b/src/Handlers/BaseHandler.php @@ -35,6 +35,21 @@ public function set(string $class, string $property, $value = null, ?string $con throw new RuntimeException('Set method not implemented for current Settings handler.'); } + /** + * If the Handler supports saving values, it MAY override this method + * to provide optimized batch functionality. + * + * @param list $settings + * + * @throws RuntimeException + */ + public function setMany(array $settings, ?string $context = null): void + { + foreach ($settings as $setting) { + $this->set($setting['class'], $setting['property'], $setting['value'], $context); + } + } + /** * If the Handler supports forgetting values, it * MUST override this method to provide that functionality. @@ -48,6 +63,21 @@ public function forget(string $class, string $property, ?string $context = null) throw new RuntimeException('Forget method not implemented for current Settings handler.'); } + /** + * If the Handler supports forgetting values, it MAY override this method + * to provide optimized batch functionality. + * + * @param list $settings + * + * @throws RuntimeException + */ + public function forgetMany(array $settings, ?string $context = null): void + { + foreach ($settings as $setting) { + $this->forget($setting['class'], $setting['property'], $context); + } + } + /** * All handlers MUST support flushing all values. * diff --git a/src/Handlers/DatabaseHandler.php b/src/Handlers/DatabaseHandler.php index a5701a4..b92a36f 100644 --- a/src/Handlers/DatabaseHandler.php +++ b/src/Handlers/DatabaseHandler.php @@ -90,6 +90,35 @@ public function set(string $class, string $property, $value = null, ?string $con $this->setStored($class, $property, $value, $context); } + /** + * Stores multiple values into the database for later retrieval. + * + * @param list $settings + * + * @throws RuntimeException For database failures + */ + public function setMany(array $settings, ?string $context = null): void + { + if ($settings === []) { + return; + } + + if ($this->deferWrites) { + foreach ($settings as $setting) { + $this->markPending($setting['class'], $setting['property'], $setting['value'], $context); + $this->setStored($setting['class'], $setting['property'], $setting['value'], $context); + } + + return; + } + + $this->persistRows($this->prepareUpsertRows($settings, $context), []); + + foreach ($settings as $setting) { + $this->setStored($setting['class'], $setting['property'], $setting['value'], $context); + } + } + /** * Persists a single property to the database. * @@ -152,6 +181,36 @@ public function forget(string $class, string $property, ?string $context = null) $this->forgetStored($class, $property, $context); } + /** + * Deletes multiple records from persistent storage, if found, + * and from the local cache. + * + * @param list $settings + */ + public function forgetMany(array $settings, ?string $context = null): void + { + if ($settings === []) { + return; + } + + $this->hydrate($context); + + if ($this->deferWrites) { + foreach ($settings as $setting) { + $this->markPending($setting['class'], $setting['property'], null, $context, true); + $this->forgetStored($setting['class'], $setting['property'], $context); + } + + return; + } + + $this->persistRows([], $this->prepareDeleteRows($settings, $context)); + + foreach ($settings as $setting) { + $this->forgetStored($setting['class'], $setting['property'], $context); + } + } + /** * Deletes a single property from the database. * @@ -260,73 +319,133 @@ public function persistPendingProperties(): void } try { - $this->db->transStart(); + $this->persistRows($upserts, $deletes); - // Handle upserts: fetch existing records matching our pending data - if ($upserts !== []) { - // Build query to fetch only the specific records we need - $this->buildOrWhereConditions($upserts, 'class', 'key', 'context'); + $this->pendingProperties = []; + } catch (DatabaseException|RuntimeException $e) { + log_message('error', 'Failed to persist pending properties: ' . $e->getMessage()); - $existing = $this->builder->get()->getResultArray(); + $this->pendingProperties = []; + } + } - // Build a map of existing records for quick lookup - $existingMap = []; + /** + * Prepares database rows for setting persistence. + * + * @param list $settings + * + * @return list + */ + private function prepareUpsertRows(array $settings, ?string $context): array + { + $time = Time::now()->format('Y-m-d H:i:s'); + $rows = []; + + foreach ($settings as $setting) { + $rows[] = [ + 'class' => $setting['class'], + 'key' => $setting['property'], + 'value' => $this->prepareValue($setting['value']), + 'type' => gettype($setting['value']), + 'context' => $context, + 'created_at' => $time, + 'updated_at' => $time, + ]; + } - foreach ($existing as $row) { - $key = $this->buildCompositeKey($row['class'], $row['key'], $row['context']); - $existingMap[$key] = $row['id']; - } + return $rows; + } - // Separate into inserts and updates - $inserts = []; - $updates = []; - - foreach ($upserts as $row) { - $key = $this->buildCompositeKey($row['class'], $row['key'], $row['context']); - - if (isset($existingMap[$key])) { - // Record exists - prepare for update - $updates[] = [ - 'id' => $existingMap[$key], - 'value' => $row['value'], - 'type' => $row['type'], - 'updated_at' => $row['updated_at'], - ]; - } else { - // New record - prepare for insert - $inserts[] = $row; - } - } + /** + * Prepares database rows for delete persistence. + * + * @param list $settings + * + * @return list + */ + private function prepareDeleteRows(array $settings, ?string $context): array + { + $rows = []; + + foreach ($settings as $setting) { + $rows[] = [ + 'class' => $setting['class'], + 'key' => $setting['property'], + 'context' => $context, + ]; + } - // Batch insert new records - if ($inserts !== []) { - $this->builder->insertBatch($inserts); - } + return $rows; + } + + /** + * Persists prepared rows to the database. + * + * @param list $upserts + * @param list $deletes + */ + private function persistRows(array $upserts, array $deletes): void + { + $this->db->transStart(); + + // Handle upserts: fetch existing records matching our pending data + if ($upserts !== []) { + // Build query to fetch only the specific records we need + $this->buildOrWhereConditions($upserts, 'class', 'key', 'context'); + + $existing = $this->builder->get()->getResultArray(); + + // Build a map of existing records for quick lookup + $existingMap = []; - // Batch update existing records - if ($updates !== []) { - $this->builder->updateBatch($updates, 'id'); + foreach ($existing as $row) { + $key = $this->buildCompositeKey($row['class'], $row['key'], $row['context']); + $existingMap[$key] = $row['id']; + } + + // Separate into inserts and updates + $inserts = []; + $updates = []; + + foreach ($upserts as $row) { + $key = $this->buildCompositeKey($row['class'], $row['key'], $row['context']); + + if (isset($existingMap[$key])) { + // Record exists - prepare for update + $updates[] = [ + 'id' => $existingMap[$key], + 'value' => $row['value'], + 'type' => $row['type'], + 'updated_at' => $row['updated_at'], + ]; + } else { + // New record - prepare for insert + $inserts[] = $row; } } - // Batch delete all delete operations - if ($deletes !== []) { - $this->buildOrWhereConditions($deletes, 'class', 'key', 'context'); + // Batch insert new records + if ($inserts !== []) { + $this->builder->insertBatch($inserts); + } - $this->builder->delete(); + // Batch update existing records + if ($updates !== []) { + $this->builder->updateBatch($updates, 'id'); } + } - $this->db->transComplete(); + // Batch delete all delete operations + if ($deletes !== []) { + $this->buildOrWhereConditions($deletes, 'class', 'key', 'context'); - if ($this->db->transStatus() === false) { - log_message('error', 'Failed to persist pending properties to database.'); - } + $this->builder->delete(); + } - $this->pendingProperties = []; - } catch (DatabaseException $e) { - log_message('error', 'Failed to persist pending properties: ' . $e->getMessage()); + $this->db->transComplete(); - $this->pendingProperties = []; + if ($this->db->transStatus() === false) { + throw new RuntimeException('Failed to persist settings to database.'); } } diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php index ec6d65c..ffd406b 100644 --- a/src/Handlers/FileHandler.php +++ b/src/Handlers/FileHandler.php @@ -97,6 +97,45 @@ public function set(string $class, string $property, $value = null, ?string $con } } + /** + * Stores multiple values into files for later retrieval. + * + * @param list $settings + * + * @throws RuntimeException For file write failures + */ + public function setMany(array $settings, ?string $context = null): void + { + if ($settings === []) { + return; + } + + $changesByClass = []; + + foreach ($settings as $setting) { + $this->hydrate($setting['class'], $context); + $this->setStored($setting['class'], $setting['property'], $setting['value'], $context); + + if ($this->deferWrites) { + $this->markPending($setting['class'], $setting['property'], $setting['value'], $context); + } else { + $changesByClass[$setting['class']][] = [ + 'property' => $setting['property'], + 'value' => $setting['value'], + 'delete' => false, + ]; + } + } + + if ($this->deferWrites) { + return; + } + + foreach ($changesByClass as $class => $changes) { + $this->persist($class, $context, $changes); + } + } + /** * Deletes the record from persistent storage, if found, * and from the local cache. @@ -122,6 +161,46 @@ public function forget(string $class, string $property, ?string $context = null) } } + /** + * Deletes multiple records from persistent storage, if found, + * and from the local cache. + * + * @param list $settings + * + * @throws RuntimeException For file write failures + */ + public function forgetMany(array $settings, ?string $context = null): void + { + if ($settings === []) { + return; + } + + $changesByClass = []; + + foreach ($settings as $setting) { + $this->hydrate($setting['class'], $context); + $this->forgetStored($setting['class'], $setting['property'], $context); + + if ($this->deferWrites) { + $this->markPending($setting['class'], $setting['property'], null, $context, true); + } else { + $changesByClass[$setting['class']][] = [ + 'property' => $setting['property'], + 'value' => null, + 'delete' => true, + ]; + } + } + + if ($this->deferWrites) { + return; + } + + foreach ($changesByClass as $class => $changes) { + $this->persist($class, $context, $changes); + } + } + /** * Deletes all settings files from persistent storage * and clears the local cache. diff --git a/src/Settings.php b/src/Settings.php index 9894177..b78f5a1 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -86,6 +86,36 @@ public function set(string $key, $value = null, ?string $context = null): void } } + /** + * Save multiple values to the writable handler for later retrieval. + * + * @param array $settings + */ + public function setMany(array $settings, ?string $context = null): void + { + if ($settings === []) { + return; + } + + $prepared = []; + + foreach ($settings as $key => $value) { + [$class, $property] = $this->prepareClassAndProperty($key); + + $prepared[$class . '::' . $property] = [ + 'class' => $class, + 'property' => $property, + 'value' => $value, + ]; + } + + $prepared = array_values($prepared); + + foreach ($this->getWriteHandlers() as $handler) { + $handler->setMany($prepared, $context); + } + } + /** * Removes a setting from the persistent storage, * effectively returning the value to the default value @@ -100,6 +130,37 @@ public function forget(string $key, ?string $context = null): void } } + /** + * Removes multiple settings from the persistent storage, + * effectively returning the values to the default values + * found in the config file, if any. + * + * @param list $keys + */ + public function forgetMany(array $keys, ?string $context = null): void + { + if ($keys === []) { + return; + } + + $prepared = []; + + foreach ($keys as $key) { + [$class, $property] = $this->prepareClassAndProperty($key); + + $prepared[$class . '::' . $property] = [ + 'class' => $class, + 'property' => $property, + ]; + } + + $prepared = array_values($prepared); + + foreach ($this->getWriteHandlers() as $handler) { + $handler->forgetMany($prepared, $context); + } + } + /** * Removes all settings from the persistent storage, * Useful during testing. Use with caution. diff --git a/tests/DatabaseHandlerTest.php b/tests/DatabaseHandlerTest.php index d55836a..89c50cd 100644 --- a/tests/DatabaseHandlerTest.php +++ b/tests/DatabaseHandlerTest.php @@ -276,6 +276,113 @@ public function testSetWithContext(): void ]); } + public function testSetManyInsertsNewRows(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + 'Example.siteTitle' => 'BatchTitle', + 'Example.siteEnabled' => true, + ]); + + foreach ([ + ['key' => 'siteName', 'value' => 'BatchName', 'type' => 'string'], + ['key' => 'siteEmail', 'value' => 'batch@example.com', 'type' => 'string'], + ['key' => 'siteTitle', 'value' => 'BatchTitle', 'type' => 'string'], + ['key' => 'siteEnabled', 'value' => '1', 'type' => 'boolean'], + ] as $expected) { + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => $expected['key'], + 'value' => $expected['value'], + 'type' => $expected['type'], + ]); + } + } + + public function testSetManyUpdatesExistingRowsWithoutDuplicates(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'InitialName', + 'Example.siteEmail' => 'initial@example.com', + ]); + + $this->settings->setMany([ + 'Example.siteName' => 'UpdatedName', + 'Example.siteEmail' => 'updated@example.com', + 'Example.siteTitle' => 'NewTitle', + ]); + + $totalCount = $this->db->table($this->table) + ->where('class', 'Tests\Support\Config\Example') + ->countAllResults(); + + $this->assertSame(3, $totalCount); + + foreach ([ + 'siteName' => 'UpdatedName', + 'siteEmail' => 'updated@example.com', + 'siteTitle' => 'NewTitle', + ] as $key => $value) { + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => $key, + 'value' => $value, + ]); + } + } + + public function testSetManyWithContext(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'ContextName', + 'Example.siteEmail' => 'context@example.com', + ], 'environment:test'); + + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + 'value' => 'ContextName', + 'context' => 'environment:test', + ]); + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteEmail', + 'value' => 'context@example.com', + 'context' => 'environment:test', + ]); + + $this->assertSame('ContextName', $this->settings->get('Example.siteName', 'environment:test')); + } + + public function testForgetManyDeletesRows(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + 'Example.siteTitle' => 'BatchTitle', + ]); + + $this->settings->forgetMany([ + 'Example.siteName', + 'Example.siteEmail', + ]); + + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + ]); + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteEmail', + ]); + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteTitle', + 'value' => 'BatchTitle', + ]); + } + /** * @see https://github.com/codeigniter4/settings/issues/20 */ @@ -310,6 +417,73 @@ public function testSetUpdatesContextOnly(): void ]); } + public function testDeferredSetManyPersistsAfterPersist(): void + { + $deferredSettings = $this->createDeferredSettings(); + + $deferredSettings->setMany([ + 'Example.siteName' => 'DeferredName', + 'Example.siteEmail' => 'deferred@example.com', + ]); + + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + ]); + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteEmail', + ]); + + $this->persistDeferredWrites($deferredSettings); + + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + 'value' => 'DeferredName', + ]); + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteEmail', + 'value' => 'deferred@example.com', + ]); + } + + public function testDeferredForgetManyDeletesAfterPersist(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + ]); + + $deferredSettings = $this->createDeferredSettings(); + + $deferredSettings->forgetMany([ + 'Example.siteName', + 'Example.siteEmail', + ]); + + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + ]); + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteEmail', + ]); + + $this->persistDeferredWrites($deferredSettings); + + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + ]); + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteEmail', + ]); + } + public function testDeferredWritesReducesDatabaseQueries(): void { // Create new settings instance with deferred writes enabled diff --git a/tests/FileHandlerTest.php b/tests/FileHandlerTest.php index 503846c..bb962c6 100644 --- a/tests/FileHandlerTest.php +++ b/tests/FileHandlerTest.php @@ -285,6 +285,67 @@ public function testMultiplePropertiesInSameFile(): void $this->assertCount(1, $files); } + public function testSetManyStoresMultiplePropertiesInSameFile(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + 'Example.siteTitle' => 'BatchTitle', + ]); + + $this->assertSame('BatchName', $this->settings->get('Example.siteName')); + $this->assertSame('batch@example.com', $this->settings->get('Example.siteEmail')); + $this->assertSame('BatchTitle', $this->settings->get('Example.siteTitle')); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(1, $files); + } + + public function testSetManyStoresDifferentClassesInDifferentFiles(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Nada.siteName' => 'NadaName', + ]); + + $this->assertSame('BatchName', $this->settings->get('Example.siteName')); + $this->assertSame('NadaName', $this->settings->get('Nada.siteName')); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(2, $files); + } + + public function testSetManyWithContext(): void + { + $context = 'environment:production'; + + $this->settings->setMany([ + 'Example.siteName' => 'ContextName', + 'Example.siteEmail' => 'context@example.com', + ], $context); + + $this->assertSame('ContextName', $this->settings->get('Example.siteName', $context)); + $this->assertSame('context@example.com', $this->settings->get('Example.siteEmail', $context)); + } + + public function testForgetManyRemovesMultipleProperties(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + 'Example.siteTitle' => 'BatchTitle', + ]); + + $this->settings->forgetMany([ + 'Example.siteName', + 'Example.siteEmail', + ]); + + $this->assertSame('Settings Test', $this->settings->get('Example.siteName')); + $this->assertNull($this->settings->get('Example.siteEmail')); + $this->assertSame('BatchTitle', $this->settings->get('Example.siteTitle')); + } + public function testDifferentClassesCreateDifferentFiles(): void { $this->settings->set('Example.siteName', 'Foo'); @@ -568,4 +629,54 @@ public function testDeferredWritesDeleteThenSet(): void $this->assertArrayHasKey('siteName', $data); $this->assertSame('NewValue', $data['siteName']['value']); } + + public function testDeferredSetManyPersistsAfterPersist(): void + { + $deferredSettings = $this->createDeferredSettings(); + + $deferredSettings->setMany([ + 'Example.siteName' => 'DeferredName', + 'Example.siteEmail' => 'deferred@example.com', + ]); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertEmpty($files); + + $this->persistDeferredWrites($deferredSettings); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(1, $files); + + $data = include $files[0]; + $this->assertSame('DeferredName', $data['siteName']['value']); + $this->assertSame('deferred@example.com', $data['siteEmail']['value']); + } + + public function testDeferredForgetManyDeletesAfterPersist(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + ]); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertCount(1, $files); + + $deferredSettings = $this->createDeferredSettings(); + + $deferredSettings->forgetMany([ + 'Example.siteName', + 'Example.siteEmail', + ]); + + $data = include $files[0]; + $this->assertArrayHasKey('siteName', $data); + $this->assertArrayHasKey('siteEmail', $data); + + $this->persistDeferredWrites($deferredSettings); + + $data = include $files[0]; + $this->assertArrayNotHasKey('siteName', $data); + $this->assertArrayNotHasKey('siteEmail', $data); + } } diff --git a/tests/SettingsTest.php b/tests/SettingsTest.php index 577ab87..44a145f 100644 --- a/tests/SettingsTest.php +++ b/tests/SettingsTest.php @@ -6,6 +6,7 @@ use CodeIgniter\Settings\Settings; use Config\Services; +use Tests\Support\Config\Example; use Tests\Support\TestCase; /** @@ -56,6 +57,39 @@ public function testGetWithContext(): void $this->assertSame('YesContext', $this->settings->get('Example.siteName', 'testing:true')); } + public function testSetManyStoresMultipleValues(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + ]); + + $this->assertSame('BatchName', $this->settings->get('Example.siteName')); + $this->assertSame('batch@example.com', $this->settings->get('Example.siteEmail')); + } + + public function testSetManyWithContext(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + ], 'testing:true'); + + $this->assertSame(config('Example')->siteName, $this->settings->get('Example.siteName')); + $this->assertSame('BatchName', $this->settings->get('Example.siteName', 'testing:true')); + $this->assertSame('batch@example.com', $this->settings->get('Example.siteEmail', 'testing:true')); + } + + public function testSetManyUsesLastNormalizedKey(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'ShortName', + Example::class . '.siteName' => 'FullName', + ]); + + $this->assertSame('FullName', $this->settings->get('Example.siteName')); + } + public function testGetWithoutContextUsesGlobal(): void { $this->settings->set('Example.siteName', 'NoContext'); @@ -72,4 +106,20 @@ public function testForgetWithContext(): void $this->assertSame('Bar', $this->settings->get('Example.siteName', 'category:disease')); } + + public function testForgetManyRemovesMultipleValues(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Example.siteEmail' => 'batch@example.com', + ]); + + $this->settings->forgetMany([ + 'Example.siteName', + 'Example.siteEmail', + ]); + + $this->assertSame(config('Example')->siteName, $this->settings->get('Example.siteName')); + $this->assertNull($this->settings->get('Example.siteEmail')); + } } From 00a41fb12dc6c8178c8671e97dd699477c513d2c Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:41:58 +0200 Subject: [PATCH 2/3] docs: clarify batch API limitation guidance Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- docs/limitations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/limitations.md b/docs/limitations.md index 25f769f..b236d33 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -5,8 +5,8 @@ The following are known limitations of the library: 1. **Immediate writes (`deferWrites => false`)**: Each setting is written to storage immediately when you call `set()` or `forget()`. The first operation hydrates all settings for that context (1 SELECT query), then each subsequent write performs a separate INSERT or UPDATE. While `DatabaseHandler` and `FileHandler` use an in-memory cache to maintain fast reads, individual write - operations may result in multiple database queries or file writes per request. Use `setMany()` or `forgetMany()` when you - want to explicitly persist multiple settings in one batch operation. + operations may result in multiple database queries or file writes per request. When multiple changes are known ahead of time, + use `setMany()` or `forgetMany()` to group them explicitly and allow supported handlers to persist them more efficiently. 2. **Deferred writes (`deferWrites => true`)**: All settings are batched and written to storage at the end of the request (during the `post_system` event). This minimizes the number of database queries and file writes, improving performance. From d8e66a1c5de858e18a41427418a661a613e9ea67 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:39:19 +0200 Subject: [PATCH 3/3] test: cover batch operation review cases - document that explicit batches and deferred writes can be combined - skip database persistence when there are no prepared rows - add cross-class and empty-input coverage for batch operations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- docs/configuration.md | 6 +- src/Handlers/DatabaseHandler.php | 4 ++ tests/DatabaseHandlerTest.php | 108 +++++++++++++++++++++++++++++++ tests/FileHandlerTest.php | 75 +++++++++++++++++++++ tests/SettingsTest.php | 10 +++ 5 files changed, 201 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b48cd8d..d002357 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,8 +32,10 @@ Handlers like `database` and `file` support deferred writes. When `deferWrites` are batched and persisted efficiently at the end of the request during the `post_system` event. This minimizes the number of database queries or file I/O operations, improving performance for write-heavy operations. -This is separate from the explicit `setMany()` and `forgetMany()` APIs. Batch APIs allow callers to group multiple settings in -one method call, while deferred writes decide whether writes are persisted immediately or at the end of the request. +!!! note + This is separate from the explicit `setMany()` and `forgetMany()` APIs. Batch APIs allow callers to group multiple settings + in one method call, while deferred writes decide whether writes are persisted immediately or at the end of the request. + The two features are independent and can be combined. ### Multiple handlers diff --git a/src/Handlers/DatabaseHandler.php b/src/Handlers/DatabaseHandler.php index b92a36f..c0ec985 100644 --- a/src/Handlers/DatabaseHandler.php +++ b/src/Handlers/DatabaseHandler.php @@ -386,6 +386,10 @@ private function prepareDeleteRows(array $settings, ?string $context): array */ private function persistRows(array $upserts, array $deletes): void { + if ($upserts === [] && $deletes === []) { + return; + } + $this->db->transStart(); // Handle upserts: fetch existing records matching our pending data diff --git a/tests/DatabaseHandlerTest.php b/tests/DatabaseHandlerTest.php index 89c50cd..7a9f3ec 100644 --- a/tests/DatabaseHandlerTest.php +++ b/tests/DatabaseHandlerTest.php @@ -355,6 +355,25 @@ public function testSetManyWithContext(): void $this->assertSame('ContextName', $this->settings->get('Example.siteName', 'environment:test')); } + public function testSetManyStoresDifferentClasses(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Nada.siteName' => 'NadaName', + ]); + + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + 'value' => 'BatchName', + ]); + $this->seeInDatabase($this->table, [ + 'class' => 'Nada', + 'key' => 'siteName', + 'value' => 'NadaName', + ]); + } + public function testForgetManyDeletesRows(): void { $this->settings->setMany([ @@ -383,6 +402,28 @@ public function testForgetManyDeletesRows(): void ]); } + public function testForgetManyDeletesDifferentClasses(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Nada.siteName' => 'NadaName', + ]); + + $this->settings->forgetMany([ + 'Example.siteName', + 'Nada.siteName', + ]); + + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + ]); + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Nada', + 'key' => 'siteName', + ]); + } + /** * @see https://github.com/codeigniter4/settings/issues/20 */ @@ -449,6 +490,38 @@ public function testDeferredSetManyPersistsAfterPersist(): void ]); } + public function testDeferredSetManyPersistsDifferentClassesAfterPersist(): void + { + $deferredSettings = $this->createDeferredSettings(); + + $deferredSettings->setMany([ + 'Example.siteName' => 'DeferredName', + 'Nada.siteName' => 'DeferredNada', + ]); + + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + ]); + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Nada', + 'key' => 'siteName', + ]); + + $this->persistDeferredWrites($deferredSettings); + + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + 'value' => 'DeferredName', + ]); + $this->seeInDatabase($this->table, [ + 'class' => 'Nada', + 'key' => 'siteName', + 'value' => 'DeferredNada', + ]); + } + public function testDeferredForgetManyDeletesAfterPersist(): void { $this->settings->setMany([ @@ -484,6 +557,41 @@ public function testDeferredForgetManyDeletesAfterPersist(): void ]); } + public function testDeferredForgetManyDeletesDifferentClassesAfterPersist(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Nada.siteName' => 'NadaName', + ]); + + $deferredSettings = $this->createDeferredSettings(); + + $deferredSettings->forgetMany([ + 'Example.siteName', + 'Nada.siteName', + ]); + + $this->seeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + ]); + $this->seeInDatabase($this->table, [ + 'class' => 'Nada', + 'key' => 'siteName', + ]); + + $this->persistDeferredWrites($deferredSettings); + + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Tests\Support\Config\Example', + 'key' => 'siteName', + ]); + $this->dontSeeInDatabase($this->table, [ + 'class' => 'Nada', + 'key' => 'siteName', + ]); + } + public function testDeferredWritesReducesDatabaseQueries(): void { // Create new settings instance with deferred writes enabled diff --git a/tests/FileHandlerTest.php b/tests/FileHandlerTest.php index bb962c6..e966859 100644 --- a/tests/FileHandlerTest.php +++ b/tests/FileHandlerTest.php @@ -68,6 +68,20 @@ private function createDeferredSettings(): Settings return new Settings($config); } + /** + * Creates a new Settings instance for reading persisted file values. + */ + private function createSettings(): Settings + { + /** @var ConfigSettings $config */ + $config = config('Settings'); + $config->handlers = ['file']; + $config->file['path'] = $this->path; + $config->file['deferWrites'] = false; + + return new Settings($config); + } + /** * Manually triggers deferred writes for a Settings instance. */ @@ -346,6 +360,22 @@ public function testForgetManyRemovesMultipleProperties(): void $this->assertSame('BatchTitle', $this->settings->get('Example.siteTitle')); } + public function testForgetManyRemovesDifferentClasses(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Nada.siteName' => 'NadaName', + ]); + + $this->settings->forgetMany([ + 'Example.siteName', + 'Nada.siteName', + ]); + + $this->assertSame('Settings Test', $this->settings->get('Example.siteName')); + $this->assertNull($this->settings->get('Nada.siteName')); + } + public function testDifferentClassesCreateDifferentFiles(): void { $this->settings->set('Example.siteName', 'Foo'); @@ -652,6 +682,26 @@ public function testDeferredSetManyPersistsAfterPersist(): void $this->assertSame('deferred@example.com', $data['siteEmail']['value']); } + public function testDeferredSetManyPersistsDifferentClassesAfterPersist(): void + { + $deferredSettings = $this->createDeferredSettings(); + + $deferredSettings->setMany([ + 'Example.siteName' => 'DeferredName', + 'Nada.siteName' => 'DeferredNada', + ]); + + $files = glob($this->path . '*.php', GLOB_NOSORT); + $this->assertEmpty($files); + + $this->persistDeferredWrites($deferredSettings); + + $settings = $this->createSettings(); + + $this->assertSame('DeferredName', $settings->get('Example.siteName')); + $this->assertSame('DeferredNada', $settings->get('Nada.siteName')); + } + public function testDeferredForgetManyDeletesAfterPersist(): void { $this->settings->setMany([ @@ -679,4 +729,29 @@ public function testDeferredForgetManyDeletesAfterPersist(): void $this->assertArrayNotHasKey('siteName', $data); $this->assertArrayNotHasKey('siteEmail', $data); } + + public function testDeferredForgetManyDeletesDifferentClassesAfterPersist(): void + { + $this->settings->setMany([ + 'Example.siteName' => 'BatchName', + 'Nada.siteName' => 'NadaName', + ]); + + $deferredSettings = $this->createDeferredSettings(); + + $deferredSettings->forgetMany([ + 'Example.siteName', + 'Nada.siteName', + ]); + + $this->assertSame('BatchName', $this->settings->get('Example.siteName')); + $this->assertSame('NadaName', $this->settings->get('Nada.siteName')); + + $this->persistDeferredWrites($deferredSettings); + + $settings = $this->createSettings(); + + $this->assertSame('Settings Test', $settings->get('Example.siteName')); + $this->assertNull($settings->get('Nada.siteName')); + } } diff --git a/tests/SettingsTest.php b/tests/SettingsTest.php index 44a145f..ae7fcfd 100644 --- a/tests/SettingsTest.php +++ b/tests/SettingsTest.php @@ -90,6 +90,16 @@ public function testSetManyUsesLastNormalizedKey(): void $this->assertSame('FullName', $this->settings->get('Example.siteName')); } + public function testBatchMethodsAcceptEmptyArrays(): void + { + $this->settings->set('Example.siteName', 'ExistingName'); + + $this->settings->setMany([]); + $this->settings->forgetMany([]); + + $this->assertSame('ExistingName', $this->settings->get('Example.siteName')); + } + public function testGetWithoutContextUsesGlobal(): void { $this->settings->set('Example.siteName', 'NoContext');