Skip to content

Commit

Permalink
Merge pull request #13 from thephpleague/validate-relevant-schema-only
Browse files Browse the repository at this point in the history
Validate relevant schema only
  • Loading branch information
colinodell committed Dec 11, 2022
2 parents c9e139a + 9188249 commit 7f83d8b
Show file tree
Hide file tree
Showing 3 changed files with 66 additions and 26 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Expand Up @@ -4,6 +4,11 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi

## [Unreleased][unreleased]

### Changed

- Values can now be set prior to the corresponding schema being registered.
- `exists()` and `get()` now only trigger validation for the relevant schema, not the entire config at once.

## [1.1.1] - 2021-08-14

### Changed
Expand Down
67 changes: 51 additions & 16 deletions src/Configuration.php
Expand Up @@ -14,6 +14,7 @@
namespace League\Config;

use Dflydev\DotAccessData\Data;
use Dflydev\DotAccessData\DataInterface;
use Dflydev\DotAccessData\Exception\DataException;
use Dflydev\DotAccessData\Exception\InvalidPathException;
use Dflydev\DotAccessData\Exception\MissingPathException;
Expand All @@ -37,7 +38,7 @@ final class Configuration implements ConfigurationBuilderInterface, Configuratio
private array $configSchemas = [];

/** @psalm-allow-private-mutation */
private ?Data $finalConfig = null;
private Data $finalConfig;

/**
* @var array<string, mixed>
Expand All @@ -56,6 +57,7 @@ public function __construct(array $baseSchemas = [])
{
$this->configSchemas = $baseSchemas;
$this->userConfig = new Data();
$this->finalConfig = new Data();

$this->reader = new ReadOnlyConfiguration($this);
}
Expand All @@ -81,7 +83,7 @@ public function merge(array $config = []): void
{
$this->invalidate();

$this->userConfig->import($config, Data::REPLACE);
$this->userConfig->import($config, DataInterface::REPLACE);
}

/**
Expand All @@ -107,13 +109,13 @@ public function set(string $key, $value): void
*/
public function get(string $key)
{
if ($this->finalConfig === null) {
$this->finalConfig = $this->build();
} elseif (\array_key_exists($key, $this->cache)) {
if (\array_key_exists($key, $this->cache)) {
return $this->cache[$key];
}

try {
$this->build(self::getTopLevelKey($key));

return $this->cache[$key] = $this->finalConfig->get($key);
} catch (InvalidPathException | MissingPathException $ex) {
throw new UnknownOptionException($ex->getMessage(), $key, (int) $ex->getCode(), $ex);
Expand All @@ -127,15 +129,15 @@ public function get(string $key)
*/
public function exists(string $key): bool
{
if ($this->finalConfig === null) {
$this->finalConfig = $this->build();
} elseif (\array_key_exists($key, $this->cache)) {
if (\array_key_exists($key, $this->cache)) {
return true;
}

try {
$this->build(self::getTopLevelKey($key));

return $this->finalConfig->has($key);
} catch (InvalidPathException $ex) {
} catch (InvalidPathException | UnknownOptionException $ex) {
return false;
}
}
Expand All @@ -154,27 +156,41 @@ public function reader(): ConfigurationInterface
private function invalidate(): void
{
$this->cache = [];
$this->finalConfig = null;
$this->finalConfig = new Data();
}

/**
* Applies the schema against the configuration to return the final configuration
*
* @throws ValidationException
* @throws ValidationException|UnknownOptionException|InvalidPathException
*
* @psalm-allow-private-mutation
*/
private function build(): Data
private function build(string $topLevelKey): void
{
if ($this->finalConfig->has($topLevelKey)) {
return;
}

if (! isset($this->configSchemas[$topLevelKey])) {
throw new UnknownOptionException(\sprintf('Missing config schema for "%s"', $topLevelKey), $topLevelKey);
}

try {
$schema = Expect::structure($this->configSchemas);
$userData = [$topLevelKey => $this->userConfig->get($topLevelKey)];
} catch (DataException $ex) {
$userData = [];
}

try {
$schema = $this->configSchemas[$topLevelKey];
$processor = new Processor();
$processed = $processor->process($schema, $this->userConfig->export());
\assert($processed instanceof \stdClass);

$processed = $processor->process(Expect::structure([$topLevelKey => $schema]), $userData);

$this->raiseAnyDeprecationNotices($processor->getWarnings());

return $this->finalConfig = new Data(self::convertStdClassesToArrays($processed));
$this->finalConfig->import((array) self::convertStdClassesToArrays($processed));
} catch (NetteValidationException $ex) {
throw new ValidationException($ex);
}
Expand Down Expand Up @@ -217,4 +233,23 @@ private function raiseAnyDeprecationNotices(array $warnings): void
@\trigger_error($warning, \E_USER_DEPRECATED);
}
}

/**
* @throws InvalidPathException
*/
private static function getTopLevelKey(string $path): string
{
if (\strlen($path) === 0) {
throw new InvalidPathException('Path cannot be an empty string');
}

$path = \str_replace(['.', '/'], '.', $path);

$firstDelimiter = \strpos($path, '.');
if ($firstDelimiter === false) {
return $path;
}

return \substr($path, 0, $firstDelimiter);
}
}
20 changes: 10 additions & 10 deletions tests/ConfigurationTest.php
Expand Up @@ -39,9 +39,13 @@ public function testAddSchema(): void

$config->addSchema('a', Expect::string()->required());

// Even though 'a' requires data, reading 'foo' should still work
$config->get('foo');

// But reading 'a' should fail
try {
$config->get('foo');
$this->fail('A validation exception should be thrown since the full schema doesn\'t pass validation');
$config->get('a');
$this->fail('A validation exception should be thrown since the "a" schema doesn\'t pass validation');
} catch (\Throwable $t) {
$this->assertInstanceOf(ValidationException::class, $t);
}
Expand Down Expand Up @@ -116,9 +120,8 @@ public function testGetWhenSchemaValidationFails(): void
$this->fail('A validation exception should have been thrown');
} catch (\Throwable $t) {
$this->assertInstanceOf(ValidationException::class, $t);
$this->assertCount(2, $t->getMessages());
$this->assertStringContainsString("item 'foo' is missing", $t->getMessages()[0]);
$this->assertStringContainsString("item 'bar' expects to be int", $t->getMessages()[1]);
$this->assertCount(1, $t->getMessages());
$this->assertStringContainsString("item 'bar' expects to be int", $t->getMessages()[0]);
}
}

Expand Down Expand Up @@ -207,15 +210,12 @@ public function testSetInvalidType(): void
$config->get('foo');
}

public function testSetUndefinedKey(): void
public function testSetUndefinedKeyDoesNotThrowException(): void
{
$this->expectException(ValidationException::class);
$this->expectExceptionMessageMatches("/Unexpected item 'bar'/");

$config = new Configuration(['foo' => Expect::int(42)]);

$config->set('bar', 3);
$config->get('foo');
$this->assertSame(42, $config->get('foo'));
}

public function testSetNestedWhenOptionNotNested(): void
Expand Down

0 comments on commit 7f83d8b

Please sign in to comment.