Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
339 changes: 292 additions & 47 deletions system/Config/BaseConfig.php

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions tests/system/Config/BaseConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,20 @@
use CodeIgniter\Superglobals;
use CodeIgniter\Test\CIUnitTestCase;
use Config\Modules;
use CustomEnvHookConfig;
use Encryption;
use EnvIndexConfig;
use GrowingEnvConfig;
use MergeRegistrarConfig;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\Attributes\WithoutErrorHandler;
use PHPUnit\Framework\MockObject\MockObject;
use RegistrarConfig;
use SimpleConfig;
use Snake_Config;
use Tests\Support\Config\BadRegistrar;
use Tests\Support\Config\MergeOrderRegistrar;
use Tests\Support\Config\MergePlainRegistrar;
Expand Down Expand Up @@ -67,6 +72,12 @@ protected function setUp(): void
require $this->fixturesFolder . '/Encryption.php';
}

foreach (['EnvIndexConfig', 'Snake_Config', 'CustomEnvHookConfig', 'GrowingEnvConfig'] as $class) {
if (! class_exists($class, false)) {
require $this->fixturesFolder . '/' . $class . '.php';
}
}

BaseConfig::reset();

Services::injectMock('superglobals', new Superglobals());
Expand All @@ -77,6 +88,7 @@ protected function tearDown(): void
parent::tearDown();

$this->clearEnvironmentOverrides();
GrowingEnvConfig::$grow = false;
// This test modifies BaseConfig::$modules, so should reset.
BaseConfig::reset();
// This test modifies Services locator, so should reset.
Expand All @@ -93,6 +105,16 @@ private function clearEnvironmentOverrides(): void
'SimpleConfig.QZEROSTR',
'SimpleConfig.QEMPTYSTR',
'SimpleConfig.QFALSE',
'envindexconfig.name',
'envindexconfig_name',
'EnvIndexConfig.name',
'envindexconfig.list.key',
'envindexconfig.list.missing',
'envindexconfig_list_some key',
'snake_config.name',
'Snake_Config_name',
'customenvhookconfig.name',
'growingenvconfig.list.added',
] as $key) {
putenv($key);
unset($_ENV[$key]);
Expand Down Expand Up @@ -425,4 +447,141 @@ public function testRedoingDiscoveryWillStillSetDidDiscoveryPropertyToTrue(): vo

$this->assertTrue($this->getPrivateProperty($config, 'didDiscovery'));
}

public function testEnvWritesAfterConstructionArePickedUp(): void
{
$this->assertSame('default', (new EnvIndexConfig())->name);

$_ENV['envindexconfig.name'] = 'from-env';
$this->assertSame('from-env', (new EnvIndexConfig())->name);

unset($_ENV['envindexconfig.name']);
service('superglobals')->setServer('envindexconfig.name', 'from-server');
$this->assertSame('from-server', (new EnvIndexConfig())->name);
}

public function testResetPicksUpBarePutenv(): void
{
$this->assertSame('default', (new EnvIndexConfig())->name);

putenv('envindexconfig.name=from-getenv');
BaseConfig::reset();

$this->assertSame('from-getenv', (new EnvIndexConfig())->name);
}

public function testOverriddenEnvHookIsCalledAndReadsLiveGetenv(): void
{
$config = new CustomEnvHookConfig();

$this->assertSame('from-hook', $config->hooked);
$this->assertSame('default', $config->name);

putenv('customenvhookconfig.name=live');

$this->assertSame('live', (new CustomEnvHookConfig())->name);
}

public function testShortPrefixWithUnderscore(): void
{
$_ENV['snake_config.name'] = 'short';
$this->assertSame('short', (new Snake_Config())->name);

unset($_ENV['snake_config.name']);
$_ENV['Snake_Config_name'] = 'full';
$this->assertSame('full', (new Snake_Config())->name);
}

/**
* @param array<string, string> $env
* @param array<string, string> $server
* @param array<string, string> $getenv
*/
#[DataProvider('provideEnvPrecedence')]
public function testEnvPrecedence(array $env, array $server, array $getenv, string $expected): void
{
foreach ($env as $key => $value) {
$_ENV[$key] = $value;
}

foreach ($server as $key => $value) {
service('superglobals')->setServer($key, $value);
}

foreach ($getenv as $key => $value) {
putenv("{$key}={$value}");
}

$this->assertSame($expected, (new EnvIndexConfig())->name);
}

/**
* @return iterable<string, array{array<string, string>, array<string, string>, array<string, string>, string}>
*/
public static function provideEnvPrecedence(): iterable
{
yield 'short prefix in $_SERVER beats full prefix in $_ENV' => [
['EnvIndexConfig.name' => 'full'], ['envindexconfig.name' => 'short'], [], 'short',
];

yield '$_ENV beats $_SERVER' => [
['envindexconfig.name' => 'env'], ['envindexconfig.name' => 'server'], [], 'env',
];

yield '$_SERVER beats getenv()' => [
[], ['envindexconfig.name' => 'server'], ['envindexconfig.name' => 'getenv'], 'server',
];

yield 'full prefix in $_ENV beats short prefix in getenv()' => [
['EnvIndexConfig.name' => 'full'], [], ['envindexconfig.name' => 'getenv'], 'full',
];

yield 'dot form beats underscore form' => [
['envindexconfig_name' => 'underscore', 'envindexconfig.name' => 'dot'], [], [], 'dot',
];

yield 'getenv() only' => [
[], [], ['envindexconfig.name' => 'getenv'], 'getenv',
];
}

public function testArrayOverridesOnlyApplyToExistingKeys(): void
{
$_ENV['envindexconfig.list.key'] = 'overridden';
$_ENV['envindexconfig.list.missing'] = 'ignored';

$this->assertSame(['key' => 'overridden', 'some key' => 'default'], (new EnvIndexConfig())->list);
}

public function testGetenvOnlyNameWithSpace(): void
{
// The getenv() array omits names with a space, like names with a dot.
putenv('envindexconfig_list_some key=from-getenv');

$this->assertSame('from-getenv', (new EnvIndexConfig())->list['some key']);
}

public function testRemovedGetenvOnlyNameIsNotReused(): void
{
putenv('envindexconfig.name=old');
$_ENV['envindexconfig.name'] = 'old';
service('superglobals')->setServer('envindexconfig.name', 'old');
$this->assertSame('old', (new EnvIndexConfig())->name);

putenv('envindexconfig.name');
unset($_ENV['envindexconfig.name']);
service('superglobals')->unsetServer('envindexconfig.name');

$this->assertSame('default', (new EnvIndexConfig())->name);
}

public function testGetenvOnlyNameForLeafAddedOnLaterInstance(): void
{
putenv('growingenvconfig.list.added=from-getenv');
$this->assertSame(['key' => 'default'], (new GrowingEnvConfig())->list);

GrowingEnvConfig::$grow = true;

$this->assertSame(['key' => 'default', 'added' => 'from-getenv'], (new GrowingEnvConfig())->list);
}
}
29 changes: 29 additions & 0 deletions tests/system/Config/fixtures/CustomEnvHookConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

use CodeIgniter\Config\BaseConfig;

class CustomEnvHookConfig extends BaseConfig
{
public string $name = 'default';
public string $hooked = 'default';

protected function getEnvValue(string $property, string $prefix, string $shortPrefix)
{
if ($property === 'hooked') {
return 'from-hook';
}

return parent::getEnvValue($property, $prefix, $shortPrefix);
}
}
27 changes: 27 additions & 0 deletions tests/system/Config/fixtures/EnvIndexConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

use CodeIgniter\Config\BaseConfig;

class EnvIndexConfig extends BaseConfig
{
public string $name = 'default';

/**
* @var array<string, string>
*/
public array $list = [
'key' => 'default',
'some key' => 'default',
];
}
37 changes: 37 additions & 0 deletions tests/system/Config/fixtures/GrowingEnvConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

use CodeIgniter\Config\BaseConfig;

/**
* Adds an array leaf only when $grow is set, like a constructor or registrar
* that changes the shape between instances.
*/
class GrowingEnvConfig extends BaseConfig
{
public static bool $grow = false;

/**
* @var array<string, string>
*/
public array $list = ['key' => 'default'];

public function __construct()
{
if (self::$grow) {
$this->list['added'] = 'default';
}

parent::__construct();
}
}
19 changes: 19 additions & 0 deletions tests/system/Config/fixtures/Snake_Config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

use CodeIgniter\Config\BaseConfig;

class Snake_Config extends BaseConfig
{
public string $name = 'default';
}
3 changes: 3 additions & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ Behavior Changes
- **Commands:** The ``worker:uninstall`` command now returns ``EXIT_SUCCESS`` (previously ``EXIT_ERROR``) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. In non-interactive mode without ``--force`` it now aborts with ``EXIT_ERROR`` instead of prompting, and the redundant ``Uninstalling FrankenPHP Worker Mode`` header was dropped. Scripts that branch on the previous exit code or wording will need updating.
- **Commands:** The ``phpini:check`` command's invalid-argument error now prints only ``You must specify a correct argument.``. The usage example and the argument listing moved to ``phpini:check --help``, where the argument is now named ``section``. Scripts that grep the previous output will need updating.
- **Config:** ``CodeIgniter\Config\BaseService`` is now ``abstract``. It is intended to be extended (as ``Config\Services`` does) and accessed statically; code that instantiated it directly with ``new BaseService()`` will now throw an ``Error``.
- **Config:** ``BaseConfig`` now scans the environment once instead of checking every property against it, which makes building config classes
several times faster without config caching and in Worker Mode. As a result, a bare ``putenv()`` call (adding, changing, or removing a variable) made
after the first config class was built may not be seen until ``BaseConfig::reset()`` runs, including in later requests in Worker Mode. See :ref:`upgrade-480-config-env-lookups`.
- **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating.
- **Debug:** ``CodeIgniter\Debug\Toolbar\Collectors\BaseCollector`` is now ``abstract``. Custom toolbar collectors must extend it; code that instantiated it directly with ``new BaseCollector()`` will now throw an ``Error``.
- **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method
Expand Down
20 changes: 20 additions & 0 deletions user_guide_src/source/installation/upgrade_480.rst
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,26 @@ RedirectException Default Status Code
protected int $defaultStatusCode = 307;
}

.. _upgrade-480-config-env-lookups:

Config Environment Lookups
==========================

``BaseConfig`` now reads the environment once and reuses the result, instead of
looking up every property of every config class. The matching rules and their
order are unchanged. One edge case behaves differently: a ``putenv()`` call
that does not also write ``$_ENV`` or ``$_SERVER``, made after the first config
class was built, may not be seen by config classes built later until
``BaseConfig::reset()`` runs. This applies whether the variable is added,
changed, or removed. In Worker Mode, this cache persists across requests, so
such changes may not be seen in later requests either. To make the change
visible, also set the value in ``$_ENV`` (or unset it there), or call
``BaseConfig::reset()``. Values loaded from the **.env** file are not
affected, since they are written to ``$_ENV`` and ``$_SERVER`` as well.

Config classes that override ``getEnvValue()`` or ``initEnvValue()`` keep the
previous behavior.

RouteCollection Identifier Transformation
=========================================

Expand Down
Loading