Skip to content

Commit 0726942

Browse files
committed
feat: deferred writes
1 parent 080b3bf commit 0726942

14 files changed

Lines changed: 631 additions & 61 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ config classes for CodeIgniter 4 framework.
99
[![Coverage Status](https://coveralls.io/repos/github/codeigniter4/settings/badge.svg?branch=develop)](https://coveralls.io/github/codeigniter4/settings?branch=develop)
1010

1111
![PHP](https://img.shields.io/badge/PHP-%5E8.1-blue)
12-
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.2.3-blue)
12+
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-blue)
1313
![License](https://img.shields.io/badge/License-MIT-blue)
1414

1515
## Installation

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
},
2222
"require-dev": {
2323
"codeigniter4/devkit": "^1.3",
24-
"codeigniter4/framework": "^4.2.3"
24+
"codeigniter4/framework": "^4.3.0"
2525
},
2626
"minimum-stability": "dev",
2727
"prefer-stable": true,

docs/configuration.md

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ Example:
2626
public $handlers = ['database'];
2727
```
2828

29+
### Deferred writes
30+
31+
Handlers like `database` and `file` support deferred writes. When `deferWrites` is enabled, multiple `set()` and `forget()` calls
32+
are batched into the minimum number of database calls or file writes. The actual changes happen during the `post_system` event.
33+
2934
### Multiple handlers
3035

3136
Example:
@@ -44,6 +49,8 @@ This configuration will:
4449

4550
Only handlers marked as `writeable => true` will be used when calling `set()`, `forget()`, or `flush()` methods.
4651

52+
---
53+
4754
## DatabaseHandler
4855

4956
This handler stores settings in a database table and is production-ready for high-traffic applications.
@@ -54,21 +61,36 @@ This handler stores settings in a database table and is production-ready for hig
5461
* `table` - The database table name for storing settings. Default: `'settings'`
5562
* `group` - The database connection group to use. Default: `null` (uses default connection)
5663
* `writeable` - Whether this handler supports write operations. Default: `true`
64+
* `deferWrites` - Whether to defer writes until the end of request (`post_system` event). Default: `false`
5765

5866
Example:
5967

6068
```php
6169
public $database = [
62-
'class' => DatabaseHandler::class,
63-
'table' => 'settings',
64-
'group' => null,
65-
'writeable' => true,
70+
'class' => DatabaseHandler::class,
71+
'table' => 'settings',
72+
'group' => null,
73+
'writeable' => true,
74+
'deferWrites' => false,
6675
];
6776
```
6877

6978
!!! note
7079
You need to run migrations to create the settings table: `php spark migrate -n CodeIgniter\\Settings`
7180

81+
**Deferred Writes**
82+
83+
When `deferWrites` is enabled, multiple `set()` or `forget()` calls are batched into a single database transaction at the end of the request. This significantly reduces database queries:
84+
85+
```php
86+
// With deferWrites = false: 3 separate queries (INSERT/UPDATE)
87+
$settings->set('Example.prop1', 'value1');
88+
$settings->set('Example.prop2', 'value2');
89+
$settings->set('Example.prop3', 'value3');
90+
91+
// With deferWrites = true: 1 query updating 3 properties at the end of the request
92+
```
93+
7294
---
7395

7496
## FileHandler
@@ -80,20 +102,35 @@ This handler stores settings as PHP files and is optimized for production use wi
80102
* `class` - The handler class. Default: `FileHandler::class`
81103
* `path` - The directory path where settings files are stored. Default: `WRITEPATH . 'settings'`
82104
* `writeable` - Whether this handler supports write operations. Default: `true`
105+
* `deferWrites` - Whether to defer writes until the end of request (`post_system` event). Default: `false`
83106

84107
Example:
85108

86109
```php
87110
public $file = [
88-
'class' => FileHandler::class,
89-
'path' => WRITEPATH . 'settings',
90-
'writeable' => true,
111+
'class' => FileHandler::class,
112+
'path' => WRITEPATH . 'settings',
113+
'writeable' => true,
114+
'deferWrites' => false,
91115
];
92116
```
93117

94118
!!! note
95119
The `FileHandler` automatically creates the directory if it doesn't exist and checks write permissions on instantiation.
96120

121+
**Deferred Writes**
122+
123+
When `deferWrites` is enabled, multiple `set()` or `forget()` calls to the same class are batched into a single file write at the end of the request. This significantly reduces I/O operations:
124+
125+
```php
126+
// With deferWrites = false: 3 file writes
127+
$settings->set('Example.prop1', 'value1');
128+
$settings->set('Example.prop2', 'value2');
129+
$settings->set('Example.prop3', 'value3');
130+
131+
// With deferWrites = true: 1 file write at end of request
132+
```
133+
97134
---
98135

99136
## ArrayHandler

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ service('settings')->forget('App.siteName');
3030
### Requirements
3131

3232
![PHP](https://img.shields.io/badge/PHP-%5E8.1-red)
33-
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.2.3-red)
33+
![CodeIgniter](https://img.shields.io/badge/CodeIgniter-%5E4.3-red)
3434

3535
### Acknowledgements
3636

docs/limitations.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@
22

33
The following are known limitations of the library:
44

5-
1. You can currently only store a single setting at a time. While the `DatabaseHandler` and `FileHandler`
6-
uses a local cache to keep performance as high as possible for reads, writes must be done one at a time.
7-
2. You can only access the first level within a property directly. In most config classes this is a non-issue,
8-
since the properties are simple values. Some config files, like the `database` file, contain properties that
9-
are arrays.
5+
1. **Immediate writes (`deferWrites => false`)**: Each setting is written to storage immediately when you call `set()` or `forget()`.
6+
While `DatabaseHandler` and `FileHandler` use an in-memory cache to maintain fast reads, write operations happen one at a time,
7+
which may result in multiple database queries or file writes per request.
8+
9+
2. **Deferred writes (`deferWrites => true`)**: All settings are batched and written to storage at the end of the request
10+
(during the `post_system` event). This minimizes the number of database queries and file writes, improving performance.
11+
However, this means write operations will not appear in CodeIgniter's Debug Toolbar, since the `post_system` event
12+
executes after the toolbar data is collected.
13+
14+
3. **First-level property access only**: You can only access the first level of a config property directly. In most config classes
15+
this is not an issue, since properties are simple values. However, some config files (like `Database`) contain properties that
16+
are nested arrays. For example, you cannot directly access `$config->database['default']['hostname']` - you would need to
17+
get the entire `database` property and then access the nested value.

src/Config/Settings.php

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,20 @@ class Settings extends BaseConfig
3030
* Database handler settings.
3131
*/
3232
public $database = [
33-
'class' => DatabaseHandler::class,
34-
'table' => 'settings',
35-
'group' => null,
36-
'writeable' => true,
33+
'class' => DatabaseHandler::class,
34+
'table' => 'settings',
35+
'group' => null,
36+
'writeable' => true,
37+
'deferWrites' => false,
3738
];
3839

3940
/**
4041
* File handler settings.
4142
*/
4243
public $file = [
43-
'class' => FileHandler::class,
44-
'path' => WRITEPATH . 'settings',
45-
'writeable' => true,
44+
'class' => FileHandler::class,
45+
'path' => WRITEPATH . 'settings',
46+
'writeable' => true,
47+
'deferWrites' => false,
4648
];
4749
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace CodeIgniter\Settings\Database\Migrations;
4+
5+
use CodeIgniter\Database\Forge;
6+
use CodeIgniter\Database\Migration;
7+
use CodeIgniter\Settings\Config\Settings;
8+
9+
class AddUniqueKey extends Migration
10+
{
11+
private Settings $config;
12+
13+
public function __construct(?Forge $forge = null)
14+
{
15+
$this->config = config('Settings');
16+
$this->DBGroup = $this->config->database['group'] ?? null;
17+
18+
parent::__construct($forge);
19+
}
20+
21+
public function up()
22+
{
23+
$table = $this->config->database['table'];
24+
25+
$this->forge->addUniqueKey(['class', 'key', 'context'], 'settings_class_key_context_idx');
26+
$this->forge->processIndexes($table);
27+
}
28+
29+
public function down()
30+
{
31+
$table = $this->config->database['table'];
32+
33+
$this->forge->dropKey($table, 'settings_class_key_context_idx', false);
34+
}
35+
}

src/Handlers/ArrayHandler.php

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace CodeIgniter\Settings\Handlers;
44

5+
use CodeIgniter\Events\Events;
6+
57
/**
68
* Array Settings Handler
79
*
@@ -15,18 +17,33 @@ class ArrayHandler extends BaseHandler
1517
* Storage for general settings.
1618
* Format: ['class' => ['property' => ['value', 'type']]]
1719
*
18-
* @var array<string,array<string,array>>
20+
* @var array<string, array<string, array{mixed, string}>>
1921
*/
2022
private array $general = [];
2123

2224
/**
2325
* Storage for context settings.
2426
* Format: ['context' => ['class' => ['property' => ['value', 'type']]]]
2527
*
26-
* @var array<string,array|null>
28+
* @var array<string, array<string, array<string, array{mixed, string}>>>
2729
*/
2830
private array $contexts = [];
2931

32+
/**
33+
* Whether to defer writes until the end of request.
34+
* Used by handlers that support deferred writes.
35+
*/
36+
protected bool $deferWrites = false;
37+
38+
/**
39+
* Array of properties that have been modified but not persisted.
40+
* Used by handlers that support deferred writes.
41+
* Format: ['key' => ['class' => ..., 'property' => ..., 'value' => ..., 'context' => ..., 'delete' => ...]]
42+
*
43+
* @var array<string, array{class: string, property: string, value: mixed, context: string|null, delete: bool}>
44+
*/
45+
protected array $pendingProperties = [];
46+
3047
public function has(string $class, string $property, ?string $context = null): bool
3148
{
3249
return $this->hasStored($class, $property, $context);
@@ -117,16 +134,62 @@ protected function forgetStored(string $class, string $property, ?string $contex
117134
}
118135

119136
/**
120-
* Retrieves all stored properties for a specific class and context.
137+
* Marks a property as pending (needs to be persisted).
138+
* Used by handlers that support deferred writes.
121139
*
122-
* @return array<string,array> Format: ['property' => ['value', 'type']]
140+
* @param mixed $value
123141
*/
124-
protected function getAllStored(string $class, ?string $context): array
142+
protected function markPending(string $class, string $property, $value, ?string $context, bool $isDelete = false): void
125143
{
126-
if ($context === null) {
127-
return $this->general[$class] ?? [];
144+
$key = $class . '::' . $property . ($context === null ? '' : '::' . $context);
145+
$this->pendingProperties[$key] = [
146+
'class' => $class,
147+
'property' => $property,
148+
'value' => $value,
149+
'context' => $context,
150+
'delete' => $isDelete,
151+
];
152+
}
153+
154+
/**
155+
* Groups pending properties by class+context combination.
156+
* Useful for handlers that need to persist changes on a per-class basis.
157+
* Format: ['key' => ['class' => ..., 'context' => ..., 'changes' => [...]]]
158+
*
159+
* @return array<string, array{class: string, context: string|null, changes: list<array{class: string, property: string, value: mixed, context: string|null, delete: bool}>}>
160+
*/
161+
protected function getPendingPropertiesGrouped(): array
162+
{
163+
$grouped = [];
164+
165+
foreach ($this->pendingProperties as $info) {
166+
$key = $info['class'] . ($info['context'] === null ? '' : '::' . $info['context']);
167+
168+
if (! isset($grouped[$key])) {
169+
$grouped[$key] = [
170+
'class' => $info['class'],
171+
'context' => $info['context'],
172+
'changes' => [],
173+
];
174+
}
175+
176+
$grouped[$key]['changes'][] = $info;
128177
}
129178

130-
return $this->contexts[$context][$class] ?? [];
179+
return $grouped;
180+
}
181+
182+
/**
183+
* Sets up deferred writes for handlers that support it.
184+
*
185+
* @param bool $enabled Whether deferred writes should be enabled
186+
*/
187+
protected function setupDeferredWrites(bool $enabled): void
188+
{
189+
$this->deferWrites = $enabled;
190+
191+
if ($this->deferWrites) {
192+
Events::on('post_system', [$this, 'persistPendingProperties']);
193+
}
131194
}
132195
}

src/Handlers/BaseHandler.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ public function flush()
6262
throw new RuntimeException('Flush method not implemented for current Settings handler.');
6363
}
6464

65+
/**
66+
* All handlers that support deferWrites MUST support this method.
67+
*
68+
* @return void
69+
*
70+
* @throws RuntimeException
71+
*/
72+
public function persistPendingProperties()
73+
{
74+
throw new RuntimeException('PersistPendingProperties method not implemented for current Settings handler.');
75+
}
76+
6577
/**
6678
* Takes care of converting some item types so they can be safely
6779
* stored and re-hydrated into the config files.

0 commit comments

Comments
 (0)