Skip to content

Commit

Permalink
feat(Container) throwable casting control
Browse files Browse the repository at this point in the history
Improve the throwable catching and casting into a `ContainerException`
to allow for control over the feature.
Add cast `ContainerException` file and line alteration to allow better
debug with tooling that uses file and line information.
  • Loading branch information
lucatume committed Feb 25, 2023
1 parent 1c230c8 commit 16bef8b
Show file tree
Hide file tree
Showing 53 changed files with 778 additions and 239 deletions.
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
.idea
.cache
var
vendor/
tests/coverage
composer.lock
_build/profile/callgrind.out.*
_build/benchmark/vendor
.phpunit.result.cache
79 changes: 57 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ A quick overview of the Container features:
- [The power of `get`](#the-power-of--get-)
- [Storing variables](#storing-variables)
- [Binding implementations](#binding-implementations)
* [Controlling the resolution of unbound classes](#controlling-the-resolution-of-unbound-classes)
- [Binding implementations to slugs](#binding-implementations-to-slugs)
- [Contextual binding](#contextual-binding)
- [Binding decorator chains](#binding-decorator-chains)
Expand All @@ -45,6 +44,9 @@ A quick overview of the Container features:
* [Booting service providers](#booting-service-providers)
* [Deferred service providers](#deferred-service-providers)
* [Dependency injection with service providers](#dependency-injection-with-service-providers)
- [Customizing the container](#customizing-the-container)
* [Unbound classes resolution](#unbound-classes-resolution)
* [Exception masking](#exception-masking)

## Code Example

Expand Down Expand Up @@ -457,27 +459,7 @@ be built just the first time: any later call for that same interface should retu
Implementations can be redefined in any moment simple calling the `bind` or `singleton` methods again specifying a
different implementation.

### Controlling the resolution of unbound classes
The container will use reflection to work out the dependencies of an object, and will not require setup when resolving
objects with type-hinted object dependencies in the `__construct` method.
By default those _unbound_ classes will be resolved **as prototypes**, built new on each `get` request.

To control the mode used to resolve unbound classes, a flag property can be set on the container when constructing it:

```php
use lucatume\DI52\Container;

$container1 = new Container();
$container2 = new Container(true);

// Default resolution of unbound classes is prototype.
assert($container1->get(A::class) !== $container1->get(A::class));
// The second container will resolve unbound classes once, then store them as singletons.
assert($container2->get(A::class) === $container2->get(A::class));
```

This will only apply to unbound classes! Whatever the flag used to build the container instance, the mode set in the
binding phase using `Container::bind()` or `Container::singleton()` methods will **always** be respected.
You can customize how unbound classes are resolved by the container, check the [unbound classes](#unbound-classes-resolution) section.

## Binding implementations to slugs

Expand Down Expand Up @@ -864,3 +846,56 @@ $container->when(ProviderOne::class)

$container->register(ProviderOne::class);
```

## Customizing the container

The container will be built with some opinionated defaults; those are not set in stone and you can customize the
container to your needs.

### Unbound classes resolution
The container will use reflection to work out the dependencies of an object, and will not require setup when resolving
objects with type-hinted object dependencies in the `__construct` method.
By default those _unbound_ classes will be resolved **as prototypes**, built new on **each** `get` request.

To control the mode used to resolve unbound classes, a flag property can be set on the container when constructing it:

```php
use lucatume\DI52\Container;

$container1 = new Container();
$container2 = new Container(true);

// Default resolution of unbound classes is prototype.
assert($container1->get(A::class) !== $container1->get(A::class));
// The second container will resolve unbound classes once, then store them as singletons.
assert($container2->get(A::class) === $container2->get(A::class));
```

This will only apply to unbound classes! Whatever the flag used to build the container instance, the mode set in the
binding phase using `Container::bind()` or `Container::singleton()` methods will **always** be respected.

### Exception masking

By default the container will catch any exception thrown during a service resolution and wrap into a `ContainerException`
instance.
The container will modify the exception message and the trace file and line to provide information about the nested
resolution tree and point your debug to the file and line that caused the issue.
You can customize how the container will handle exceptions by using the `Container::setExceptionMask()` method:

```php
use lucatume\DI52\Container;

$container = new Container();

// The container will throw any exception thrown during a service resolution without any modification.
$container->setExceptionMask(Container::EXCEPTION_MASK_NONE);

// Wrap any exception thrown during a service resolution in a `ContainerException` instance, modify the message.
$container->setExceptionMask(Container::EXCEPTION_MASK_MESSAGE);

// Wrap any exception thrown during a service resolution in a `ContainerException` instance, modify the trace file and line.
$container->setExceptionMask(Container::EXCEPTION_MASK_FILE_LINE);

// You can combine the options, this is the default value.
$container->setExceptionMask(Container::EXCEPTION_MASK_MESSAGE | Container::EXCEPTION_MASK_FILE_LINE);
```
2 changes: 1 addition & 1 deletion .phan/config.php → config/phan-config.php
Original file line number Diff line number Diff line change
Expand Up @@ -335,5 +335,5 @@
// A list of individual files to include in analysis
// with a path relative to the root directory of the
// project.
'file_list' => ['autoload.php'],
'file_list' => [],
];
File renamed without changes.
File renamed without changes.
21 changes: 13 additions & 8 deletions makefile
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,13 @@ $(build_php_versions_gte_72): %:
docker run --rm lucatume/di52-profile:php-v$@ -v

build: $(build_php_versions_lt_72) $(build_php_versions_gte_72) ## Builds the project PHP images.
mkdir -p var/cache/composer
.PHONY: build

clean:
rm -rf var/cache/composer
.PHONY: clean

test_php_versions = 'php-v5.6' 'php-v7.0' 'php-v7.1' 'php-v7.2' 'php-v7.3' 'php-v7.4' 'php-v8.0' 'php-v8.1' 'php-v8.2'
$(test_php_versions): %:
echo "Running tests on $@"
Expand Down Expand Up @@ -152,7 +157,7 @@ else
-qrr ${PWD}/vendor/bin/phpunit -c ${PWD}/phpunit.xml \
${PWD}/tests
endif
open tests/coverage/index.html
open var/coverage/index.html
.PHONY: test_coverage

test_run: ## Run the test on the specified PHP version with XDebug support. Example `make test_run 7.2`.
Expand All @@ -178,7 +183,7 @@ code_sniff: ## Run PHP Code Sniffer on the project source files.
--colors \
-p \
-s \
--standard=${PWD}/phpcs.xml \
--standard=${PWD}/config/phpcs.xml \
${PWD}/src ${PWD}/aliases.php
.PHONY: code_sniff

Expand All @@ -189,8 +194,8 @@ code_fix: ## Run PHP Code Sniffer Beautifier on the project source files.
--colors \
-p \
-s \
--standard=${PWD}/phpcs.xml \
${PWD}/src ${PWD}/tests ${PWD}/aliases.php ${PWD}/docs/examples
--standard=${PWD}/config/phpcs.xml \
${PWD}/src ${PWD}/tests ${PWD}/aliases.php
.PHONY: code_fix

PHPSTAN_LEVEL?=max
Expand All @@ -199,15 +204,15 @@ phpstan: ## Run phpstan on the project source files.
-v ${PWD}:${PWD} \
-u "$$(id -u):$$(id -g)" \
ghcr.io/phpstan/phpstan analyze \
-c ${PWD}/_build/phpstan.neon \
-c ${PWD}/config/phpstan.neon \
-l ${PHPSTAN_LEVEL} ${PWD}/src ${PWD}/aliases.php
.PHONY: phpstan

phan: ## Run phan on the project source files.
docker run --rm \
-v ${PWD}:/mnt/src \
-u "$$(id -u):$$(id -g)" \
phanphp/phan
phanphp/phan -k config/phan-config.php
.PHONY: phan

pre_commit: code.lint code.fix code.sniff test phpstan phan ## Run pre-commit checks: code.lint, code.fix, code.sniff, test, phpstan, phan.
Expand Down Expand Up @@ -260,8 +265,8 @@ php_shell: ## Opens a shell in a PHP container.
docker run --rm -it \
-u "$(shell id -u):$(shell id -g)" \
-v "${PWD}:${PWD}" \
-v "${PWD}/.cache/composer:${PWD}/.cache/composer" \
-e COMPOSER_CACHE_DIR="${PWD}/.cache/composer" \
-v "${PWD}/var/cache/composer:${PWD}/var/cache/composer" \
-e COMPOSER_CACHE_DIR="${PWD}/var/cache/composer" \
-w "${PWD}" \
--entrypoint sh \
lucatume/di52-dev:php-v$(TARGET_ARGS)
Expand Down
3 changes: 2 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/|version|/phpunit.xsd"
bootstrap='tests/bootstrap.php'
colors='true'
cacheResultFile="var/cache/.phpunit.result.cache"
>
<testsuites>
<testsuite name='php81'>
Expand All @@ -22,7 +23,7 @@
</whitelist>
</filter>
<logging>
<log type="coverage-html" target="tests/coverage" lowUpperBound="35"
<log type="coverage-html" target="var/coverage" lowUpperBound="35"
highLowerBound="70"/>
</logging>
</phpunit>
88 changes: 26 additions & 62 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use ReflectionProperty;
use ReturnTypeWillChange;
use Throwable;
use function spl_object_hash;
Expand All @@ -29,6 +28,10 @@
*/
class Container implements ArrayAccess, ContainerInterface
{
const EXCEPTION_MASK_NONE = 0;
const EXCEPTION_MASK_MESSAGE = 1;
const EXCEPTION_MASK_FILE_LINE = 2;

/**
* An array cache to store the results of the class exists checks.
*
Expand Down Expand Up @@ -81,6 +84,12 @@ class Container implements ArrayAccess, ContainerInterface
* @var Builders\Factory
*/
protected $builders;
/**
* What kind of masking should be applied to throwables catched by the container during resolution.
*
* @var int
*/
private $maskThrowables = self::EXCEPTION_MASK_MESSAGE | self::EXCEPTION_MASK_FILE_LINE;

/**
* Container constructor.
Expand Down Expand Up @@ -214,56 +223,22 @@ public function get($id)
* Builds an instance of the exception with a pretty message.
*
* @param Exception|Throwable $thrown The exception to cast.
* @param string|object $id The top identifier the containe was attempting to build, or object.
* @param string|object $id The top identifier the containe was attempting to build, or object.
*
* @return ContainerException The cast exception.
* @return ContainerException|Exception|Throwable The cast exception.
*/
private function castThrown($thrown, $id)
{
$exceptionClass = $thrown instanceof ContainerException ? get_class($thrown) : ContainerException::class;
$thrownTraceProperty = $this->getTraceReflectionProperty($thrown);
$throwTraceValue = $thrownTraceProperty instanceof ReflectionProperty ?
$thrownTraceProperty->getValue($thrown) : null;

$rethrown = new $exceptionClass($this->makeBuildLineErrorMessage($id, $thrown));

if (is_array($throwTraceValue)) {
$rethrownTraceProperty = $this->getTraceReflectionProperty($rethrown);
if ($rethrownTraceProperty instanceof ReflectionProperty) {
$rethrownTraceProperty->setAccessible(true);
$rethrownTraceProperty->setValue($rethrown, $throwTraceValue);
}
if ($this->maskThrowables === self::EXCEPTION_MASK_NONE) {
return $thrown;
}

return $rethrown;
}

/**
* Formats an error message to provide a useful debug message.
*
* @param string|object $id The id of what is actually being built or the object that is being built.
* @param Exception|Throwable $thrown The original exception thrown while trying to make the target.
*
* @return string The formatted make error message.
*/
private function makeBuildLineErrorMessage($id, $thrown)
{
$buildLine = $this->resolver->getBuildLine();
$idString = is_string($id) ? $id : gettype($id);
if ($thrown instanceof NestedParseError) {
$last = $thrown->getType() . ' $' . $thrown->getName();
} else {
$last = array_pop($buildLine) ?: $idString;
}
$lastEntry = "Error while making {$last}: " . lcfirst(
rtrim(
str_replace('"', '', $thrown->getMessage()),
'.'
)
) . '.';
$frags = array_merge($buildLine, [$lastEntry]);

return implode("\n\t=> ", $frags);
return ContainerException::fromThrowable(
$id,
$thrown,
$this->maskThrowables,
$this->resolver->getBuildLine()
);
}

/**
Expand Down Expand Up @@ -884,27 +859,16 @@ public function isBound($id)
}

/**
* Extracts the trace property from a throwable.
* Sets the mask for the throwables that should be caught and re-thrown as container exceptions.
*
* @param Throwable|Exception $throwable The throwable to extract the trace from.
* @param int $maskThrowables The mask for the throwables that should be caught and re-thrown as container
*
* @return ReflectionProperty|null The trace property or `null` if not found on the throwable.
* @return $this This instance.
*/
private function getTraceReflectionProperty($throwable)
public function setExceptionMask($maskThrowables)
{
$traceProperty = null;
$reflectionClass = new ReflectionClass($throwable);
$this->maskThrowables = (int)$maskThrowables;

do {
if ($reflectionClass->hasProperty('trace')) {
$traceProperty = $reflectionClass->getProperty('trace');
$traceProperty->setAccessible(true);
break;
}

$reflectionClass = $reflectionClass->getParentClass();
} while ($reflectionClass instanceof ReflectionClass);

return $traceProperty;
return $this;
}
}
Loading

0 comments on commit 16bef8b

Please sign in to comment.