diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 0d65129..85e498a 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -4,20 +4,99 @@ on:
push:
branches:
- develop
+
+ tags:
+ - 'v*'
+
paths:
- - 'docs/*'
+ - 'docs/**'
- 'mkdocs.yml'
+ - '.github/workflows/docs.yml'
permissions:
contents: write
+concurrency:
+ group: docs-deploy
+ cancel-in-progress: false
+
jobs:
deploy:
runs-on: ubuntu-latest
+
steps:
- - uses: actions/checkout@v6
- - uses: actions/setup-python@v6
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
with:
- python-version: 3.x
- - run: pip install mkdocs-material
- - run: mkdocs gh-deploy --force
+ python-version: '3.x'
+
+ - name: Install dependencies
+ run: pip install mkdocs-material mike
+
+ - name: Fetch gh-pages
+ run: git fetch origin gh-pages --depth=1 || true
+
+ - name: Configure git
+ run: |
+ git config user.name github-actions[bot]
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+
+ - name: Deploy docs with mike
+ shell: bash
+ run: |
+ set -e
+
+ deploy_version () {
+ local VERSION="$1"
+ local UPDATE_LATEST="${2:-false}"
+
+ VERSION="${VERSION#v}"
+
+ MINOR=$(echo "$VERSION" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/')
+
+ echo "Deploying docs version: $MINOR"
+
+ if [ "$UPDATE_LATEST" = "true" ]; then
+ mike deploy --push --update-aliases "$MINOR" latest
+ return
+ fi
+
+ mike deploy --push "$MINOR"
+ }
+
+ #
+ # Develop branch
+ #
+ if [ "${{ github.ref_type }}" = "branch" ] && [ "${{ github.ref_name }}" = "develop" ]; then
+ echo "Deploying dev docs"
+
+ mike deploy --push dev
+
+ exit 0
+ fi
+
+ #
+ # Release tags
+ #
+ if [ "${{ github.ref_type }}" = "tag" ]; then
+ VERSION="${{ github.ref_name }}"
+
+ if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Skipping non-stable release tag: $VERSION"
+ exit 0
+ fi
+
+ deploy_version "$VERSION" true
+
+ echo "Setting default docs version: latest"
+
+ mike set-default --push latest
+
+ exit 0
+ fi
+
+ echo "Unsupported ref"
+ exit 1
diff --git a/docs/CNAME b/docs/CNAME
deleted file mode 100644
index 035b9d9..0000000
--- a/docs/CNAME
+++ /dev/null
@@ -1 +0,0 @@
-settings.codeigniter.com
diff --git a/docs/basic-usage.md b/docs/basic-usage.md
index 0030fa2..250dfcb 100644
--- a/docs/basic-usage.md
+++ b/docs/basic-usage.md
@@ -15,6 +15,17 @@ To retrieve a config value use the `settings` service.
$siteName = service('settings')->get('App.siteName');
```
+You can retrieve multiple values with `getMany()`. This behaves like calling `get()` for each key.
+
+```php
+$settings = service('settings')->getMany([
+ 'App.siteName',
+ 'App.siteEmail',
+]);
+```
+
+Unlike `get()`, which returns a single value, `getMany()` returns an array keyed by the exact setting names you requested.
+
In this case we used the short class name, `App`, which the `config()` method automatically locates within the
`app/Config` directory. If it was from a module, it would be found there. Either way, the fully qualified name
is automatically detected by the Settings class to keep values separated from config files that may share the
@@ -30,6 +41,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 +58,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 +92,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
@@ -90,10 +130,22 @@ setting('App.siteName', 'My Great Site');
// Using the service through the helper
$name = setting()->get('App.siteName');
+$settings = setting()->getMany([
+ 'App.siteName',
+ 'App.siteEmail',
+]);
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..d002357 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -32,6 +32,11 @@ 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.
+!!! 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
Example:
@@ -94,6 +99,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 +151,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..b236d33 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. 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.
diff --git a/docs/overrides/main.html b/docs/overrides/main.html
new file mode 100644
index 0000000..bd5293a
--- /dev/null
+++ b/docs/overrides/main.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+
+{% block outdated %}
+ {% if config.site_url.rstrip("/").endswith("/dev") %}
+ You're viewing the development version.
+ Some features may not be available in a stable release yet.
+
+ Go to the latest stable version.
+
+ {% else %}
+ You're not viewing the latest version.
+
+ Click here to go to latest.
+
+ {% endif %}
+{% endblock %}
diff --git a/mkdocs.yml b/mkdocs.yml
index 0702b76..62ac076 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,8 +1,12 @@
site_name: CodeIgniter Settings
site_description: Settings documentation for CodeIgniter 4 framework
+exclude_docs: |
+ overrides/
+
theme:
name: material
+ custom_dir: docs/overrides
logo: assets/flame.svg
favicon: assets/favicon.ico
icon:
@@ -35,6 +39,8 @@ theme:
extra:
homepage: https://codeigniter.com
generator: false
+ version:
+ provider: mike
social:
- icon: material/github
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..3ae38dd 100644
--- a/src/Handlers/DatabaseHandler.php
+++ b/src/Handlers/DatabaseHandler.php
@@ -81,6 +81,7 @@ public function get(string $class, string $property, ?string $context = null)
public function set(string $class, string $property, $value = null, ?string $context = null): void
{
if ($this->deferWrites) {
+ $this->hydrate($context);
$this->markPending($class, $property, $value, $context);
} else {
$this->persist($class, $property, $value, $context);
@@ -90,6 +91,37 @@ 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) {
+ $this->hydrate($context);
+
+ 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 +184,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 +322,137 @@ 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;
+ }
- // Batch update existing records
- if ($updates !== []) {
- $this->builder->updateBatch($updates, 'id');
+ /**
+ * Persists prepared rows to the database.
+ *
+ * @param list $upserts
+ * @param list $deletes
+ */
+ private function persistRows(array $upserts, array $deletes): void
+ {
+ if ($upserts === [] && $deletes === []) {
+ return;
+ }
+
+ $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 = [];
+
+ 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..ada0783 100644
--- a/src/Settings.php
+++ b/src/Settings.php
@@ -72,6 +72,24 @@ public function get(string $key, ?string $context = null)
return $config->{$property} ?? null;
}
+ /**
+ * Retrieve multiple values using the same behavior as get().
+ *
+ * @param list $keys
+ *
+ * @return array
+ */
+ public function getMany(array $keys, ?string $context = null): array
+ {
+ $settings = [];
+
+ foreach ($keys as $key) {
+ $settings[$key] = $this->get($key, $context);
+ }
+
+ return $settings;
+ }
+
/**
* Save a value to the writable handler for later retrieval.
*
@@ -86,6 +104,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 +148,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..adcca75 100644
--- a/tests/DatabaseHandlerTest.php
+++ b/tests/DatabaseHandlerTest.php
@@ -276,6 +276,154 @@ 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 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([
+ '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',
+ ]);
+ }
+
+ 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
*/
@@ -310,6 +458,164 @@ 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 testDeferredSetReturnsPendingValueBeforePersist(): void
+ {
+ $this->settings->set('Example.siteName', 'StoredSingle');
+
+ $deferredSettings = $this->createDeferredSettings();
+
+ $deferredSettings->set('Example.siteName', 'PendingSingle');
+
+ $this->assertSame('PendingSingle', $deferredSettings->get('Example.siteName'));
+ }
+
+ public function testDeferredSetManyReturnsPendingValueBeforePersist(): void
+ {
+ $this->settings->set('Example.siteName', 'StoredBatch');
+
+ $deferredSettings = $this->createDeferredSettings();
+
+ $deferredSettings->setMany([
+ 'Example.siteName' => 'PendingBatch',
+ ]);
+
+ $this->assertSame('PendingBatch', $deferredSettings->get('Example.siteName'));
+ }
+
+ 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([
+ '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 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 503846c..9cb1f4d 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.
*/
@@ -285,6 +299,83 @@ 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 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');
@@ -568,4 +659,123 @@ 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 testDeferredSetReturnsPendingValueBeforePersist(): void
+ {
+ $this->settings->set('Example.siteName', 'FileStoredSingle');
+
+ $deferredSettings = $this->createDeferredSettings();
+
+ $deferredSettings->set('Example.siteName', 'FilePendingSingle');
+
+ $this->assertSame('FilePendingSingle', $deferredSettings->get('Example.siteName'));
+ }
+
+ public function testDeferredSetManyReturnsPendingValueBeforePersist(): void
+ {
+ $this->settings->set('Example.siteName', 'FileStoredBatch');
+
+ $deferredSettings = $this->createDeferredSettings();
+
+ $deferredSettings->setMany([
+ 'Example.siteName' => 'FilePendingBatch',
+ ]);
+
+ $this->assertSame('FilePendingBatch', $deferredSettings->get('Example.siteName'));
+ }
+
+ 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([
+ '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);
+ }
+
+ 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 577ab87..338107a 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,113 @@ public function testGetWithContext(): void
$this->assertSame('YesContext', $this->settings->get('Example.siteName', 'testing:true'));
}
+ public function testGetManyReturnsMultipleValues(): void
+ {
+ $this->settings->setMany([
+ 'Example.siteName' => 'BatchName',
+ 'Example.siteEmail' => 'batch@example.com',
+ ]);
+
+ $this->assertSame([
+ 'Example.siteName' => 'BatchName',
+ 'Example.siteEmail' => 'batch@example.com',
+ ], $this->settings->getMany([
+ 'Example.siteName',
+ 'Example.siteEmail',
+ ]));
+ }
+
+ public function testGetManyFallsBackToConfigValues(): void
+ {
+ $this->assertSame([
+ 'Example.siteName' => 'Settings Test',
+ 'Example.siteEmail' => null,
+ ], $this->settings->getMany([
+ 'Example.siteName',
+ 'Example.siteEmail',
+ ]));
+ }
+
+ public function testGetManyWithContext(): void
+ {
+ $this->settings->setMany([
+ 'Example.siteName' => 'NoContext',
+ 'Example.siteEmail' => 'general@example.com',
+ ]);
+ $this->settings->setMany([
+ 'Example.siteName' => 'YesContext',
+ ], 'testing:true');
+
+ $this->assertSame([
+ 'Example.siteName' => 'YesContext',
+ 'Example.siteEmail' => 'general@example.com',
+ ], $this->settings->getMany([
+ 'Example.siteName',
+ 'Example.siteEmail',
+ ], 'testing:true'));
+ }
+
+ public function testGetManyPreservesRequestedKeys(): void
+ {
+ $this->settings->set('Example.siteName', 'BatchName');
+
+ $this->assertSame([
+ Example::class . '.siteName' => 'BatchName',
+ 'Nada.siteName' => null,
+ ], $this->settings->getMany([
+ Example::class . '.siteName',
+ 'Nada.siteName',
+ ]));
+ }
+
+ public function testGetManyAcceptsEmptyArray(): void
+ {
+ $this->assertSame([], $this->settings->getMany([]));
+ }
+
+ 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 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');
@@ -72,4 +180,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'));
+ }
}