Skip to content

Commit

Permalink
Feature: inherit contracts (#31)
Browse files Browse the repository at this point in the history
* Made AbstractFetcher accept more then one annotation type

* Add inherit annotation

This will serve as a contract inherit flag.
It will take all existing annotations (ensure, verify, invariant)
and check current as well as parent classes for contracts

* Update Inherit implementation

- Inherit will now always inherit contracts (despite missing @inheritdoc)
- provide Inherit tests
- update Demo implementation
- update Readme with Inherit documentation
  • Loading branch information
icanhazstring committed Jan 18, 2019
1 parent 51150fd commit 078ca37
Show file tree
Hide file tree
Showing 18 changed files with 376 additions and 27 deletions.
1 change: 1 addition & 0 deletions .gitignore
@@ -1,3 +1,4 @@
/vendor/
composer.lock
/tests/cache/
/demo/cache/
63 changes: 52 additions & 11 deletions README.md
Expand Up @@ -135,17 +135,30 @@ class Account
```
Invariants contain assert expressions, and so when they fail, they throw a ContractViolation exception.

NOTE! The code in the invariant may not call any public non-static members of the class, either directly or
__NOTE__: The code in the invariant may not call any public non-static members of the class, either directly or
indirectly. Doing so will result in a stack overflow, as the invariant will wind up being called in an
infinitely recursive manner.

Contract propagation
----------

All contracts are propagated from parent classes and interfaces.

For preconditions (Verify contracts) subclasses do not inherit contracts of parents' methods if they don't have the @inheritdoc annotation. Example:

There a some differences in inheritance of the contracts:

1. Ensure
- if provided `Ensure` will automatically inherit all contracts from parent class or interface
2. Verify
- if provided `Verify` will _not_ inherit contracts from parent class or interface
- to inherit contracts you will ne to provide `@inheritdoc` or the `Inherit` contract
3. Invariant
- if provided `Invariant` will inherit all contracts from parent class or interface
4. Inherit
- if provided `Inherit` will inherit all contracts from the given leven (class, method) without the
need to provide a contract on your current class or method

__Notes__:
- The parsing of a contract only happens __IF__ you provide any given annotation from this package.
Without it your contracts won't work!
- The annotation __must not__ have curly braces (`{}`) otherwise the annotation reader can't find them.

```php

Expand Down Expand Up @@ -175,7 +188,7 @@ class FooParent

```

Foo::bar accepts '2' literal as a parameter and does not accept '1'.
`Foo::bar` accepts `2` literal as a parameter and does not accept `1`.

With @inheritdoc:

Expand Down Expand Up @@ -208,12 +221,10 @@ class FooParent

```

Foo::bar does not accept '1' and '2' literals as a parameter.


`Foo::bar` does not accept `1` and `2` literals as a parameter.


For postconditions (Ensure and Invariants contracts) subclasses inherit contracts and they don't need @inheritdoc. Example:
For postconditions (Ensure and Invariants contracts) subclasses inherit contracts and they don't need `@inheritdoc`. Example:

```php

Expand Down Expand Up @@ -246,7 +257,37 @@ class FooParent

```

Foo::setBar does not accept '1' and '2' literals as a parameter.
`Foo::setBar` does not accept `1` and `2` literals as a parameter.

If you don't want to provide a contract on your curent method/class you can use the `Inherit` annotation:

```php
class Foo extends FooParent
{
/**
* @param int $amount
* @Contract\Inherit
*/
public function bar($amount)
{
...
}
}

class FooParent
{
/**
* @param int $amount
* @Contract\Verify("$amount != 2")
*/
public function bar($amount)
{
...
}
}
```

`Foo:bar()` does accept eveything, except: `2`

Integration with assertion library
----------
Expand Down
12 changes: 11 additions & 1 deletion demo/Demo/Account.php
Expand Up @@ -14,7 +14,7 @@

/**
* Simple trade account class
* @Contract\Invariant("$this->balance > 0")
* @Contract\Invariant("$this->balance >= 0")
*/
class Account implements AccountContractInterface
{
Expand All @@ -39,10 +39,20 @@ public function deposit($amount)
$this->balance += $amount;
}

/**
* @Contract\Inherit()
* @param float $amount
*/
public function withdraw($amount)
{
$this->balance -= $amount;
}

/**
* Returns current balance
*
* @Contract\Ensure("$__result == $this->balance")
*
* @return float
*/
public function getBalance()
Expand Down
11 changes: 11 additions & 0 deletions demo/Demo/AccountContractInterface.php
Expand Up @@ -27,6 +27,17 @@ interface AccountContractInterface
*/
public function deposit($amount);

/**
* Withdraw amount of money from account.
*
* We don't allow withdrawal of more than 50
* @Contract\Verify("$amount <= $this->balance")
* @Contract\Verify("$amount <= 50")
* @Contract\Ensure("$this->balance == $__old->balance-$amount")
* @param float $amount
*/
public function withdraw($amount);

/**
* Returns current balance
*
Expand Down
8 changes: 7 additions & 1 deletion demo/demo.php
Expand Up @@ -13,5 +13,11 @@
include_once __DIR__.'/aspect_bootstrap.php';

$account = new Demo\Account();

echo 'Deposit: 100' . PHP_EOL;
$account->deposit(100);
echo $account->getBalance();
echo 'Current balance: ' . $account->getBalance();
echo PHP_EOL;
echo 'Withdraw: 100' . PHP_EOL;
$account->withdraw(50);
echo 'Current balance: ' . $account->getBalance();
26 changes: 26 additions & 0 deletions src/Annotation/Inherit.php
@@ -0,0 +1,26 @@
<?php
/**
* PHP Deal framework
*
* @copyright Copyright 2014, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpDeal\Annotation;

use Doctrine\Common\Annotations\Annotation as BaseAnnotation;

/**
* This annotation defines a contract inheritance check, applied to the method or class
*
* @Annotation
* @Target({"METHOD", "CLASS"})
*/
class Inherit extends BaseAnnotation
{
public function __toString()
{
return $this->value;
}
}
5 changes: 5 additions & 0 deletions src/Aspect/AbstractContractAspect.php
Expand Up @@ -67,6 +67,7 @@ protected function fetchMethodArguments(MethodInvocation $invocation)
* @param array $args List of arguments for the method
*
* @throws DomainException
* @throws ContractViolation
*/
protected function ensureContracts(MethodInvocation $invocation, array $contracts, $instance, $scope, array $args)
{
Expand All @@ -87,6 +88,10 @@ protected function ensureContracts(MethodInvocation $invocation, array $contract
try {
$invocationResult = $boundInvoker->__invoke($args, $contractExpression);

// if ($invocationResult === false) {
// throw new ContractViolation($invocation, $contractExpression);
// }

// we accept as a result only true or null
// null may be a result of assertions from beberlei/assert which passed
if ($invocationResult !== null && $invocationResult !== true) {
Expand Down
130 changes: 130 additions & 0 deletions src/Aspect/InheritCheckerAspect.php
@@ -0,0 +1,130 @@
<?php

namespace PhpDeal\Aspect;

use Doctrine\Common\Annotations\Reader;
use Go\Aop\Aspect;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\Around;
use PhpDeal\Annotation\Ensure;
use PhpDeal\Annotation\Invariant;
use PhpDeal\Annotation\Verify;
use PhpDeal\Contract\Fetcher\Parent\InvariantFetcher;
use PhpDeal\Contract\Fetcher\Parent\MethodConditionFetcher;
use PhpDeal\Exception\ContractViolation;
use ReflectionClass;

class InheritCheckerAspect extends AbstractContractAspect implements Aspect
{
/**
* @var MethodConditionFetcher
*/
private $methodConditionFetcher;

/** @var InvariantFetcher */
private $invariantFetcher;

public function __construct(Reader $reader)
{
parent::__construct($reader);
$this->methodConditionFetcher = new MethodConditionFetcher([Ensure::class, Verify::class, Invariant::class], $reader);
$this->invariantFetcher = new InvariantFetcher([Invariant::class], $reader);
}

/**
* Verifies inherit contracts for the method
*
* @Around("@execution(PhpDeal\Annotation\Inherit)")
* @param MethodInvocation $invocation
*
* @throws ContractViolation
* @return mixed
*/
public function inheritMethodContracts(MethodInvocation $invocation)
{
$object = $invocation->getThis();
$args = $this->fetchMethodArguments($invocation);
$class = $invocation->getMethod()->getDeclaringClass();
if ($class->isCloneable()) {
$args['__old'] = clone $object;
}

$result = $invocation->proceed();
$args['__result'] = $result;
$allContracts = $this->fetchMethodContracts($invocation);

$this->ensureContracts($invocation, $allContracts, $object, $class->name, $args);

return $result;
}

/**
* @Around("@within(PhpDeal\Annotation\Inherit) && execution(public **->*(*))")
* @param MethodInvocation $invocation
* @return mixed
*/
public function inheritClassContracts(MethodInvocation $invocation)
{
$object = $invocation->getThis();
$args = $this->fetchMethodArguments($invocation);
$class = $invocation->getMethod()->getDeclaringClass();
if ($class->isCloneable()) {
$args['__old'] = clone $object;
}

$result = $invocation->proceed();
$args['__result'] = $result;

$allContracts = $this->fetchClassContracts($class);
$this->ensureContracts($invocation, $allContracts, $object, $class->name, $args);

return $result;
}

/**
* @param MethodInvocation $invocation
* @return array
*/
private function fetchMethodContracts(MethodInvocation $invocation)
{
$allContracts = $this->fetchParentsMethodContracts($invocation);

foreach ($invocation->getMethod()->getAnnotations() as $annotation) {
$annotationClass = \get_class($annotation);

if (\in_array($annotationClass, [Ensure::class, Verify::class, Invariant::class], true)) {
$allContracts[] = $annotation;
}
}

return array_unique($allContracts);
}

/**
* @param MethodInvocation $invocation
* @return array
*/
private function fetchParentsMethodContracts(MethodInvocation $invocation)
{
return $this->methodConditionFetcher->getConditions(
$invocation->getMethod()->getDeclaringClass(),
$invocation->getMethod()->name
);
}

/**
* @param ReflectionClass $class
* @return array
*/
private function fetchClassContracts(ReflectionClass $class)
{
$allContracts = $this->invariantFetcher->getConditions($class);
foreach ($this->reader->getClassAnnotations($class) as $annotation) {
if ($annotation instanceof Invariant) {
$allContracts[] = $annotation;
}
}

return array_unique($allContracts);
}
}
2 changes: 1 addition & 1 deletion src/Aspect/InvariantCheckerAspect.php
Expand Up @@ -29,7 +29,7 @@ class InvariantCheckerAspect extends AbstractContractAspect implements Aspect
public function __construct(Reader $reader)
{
parent::__construct($reader);
$this->invariantFetcher = new InvariantFetcher(Invariant::class, $reader);
$this->invariantFetcher = new InvariantFetcher([Invariant::class], $reader);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Aspect/PostconditionCheckerAspect.php
Expand Up @@ -28,7 +28,7 @@ class PostconditionCheckerAspect extends AbstractContractAspect implements Aspec
public function __construct(Reader $reader)
{
parent::__construct($reader);
$this->methodConditionFetcher = new MethodConditionFetcher(Ensure::class, $reader);
$this->methodConditionFetcher = new MethodConditionFetcher([Ensure::class], $reader);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Aspect/PreconditionCheckerAspect.php
Expand Up @@ -28,7 +28,7 @@ class PreconditionCheckerAspect extends AbstractContractAspect implements Aspect
public function __construct(Reader $reader)
{
parent::__construct($reader);
$this->methodConditionFetcher = new MethodConditionWithInheritDocFetcher(Verify::class, $reader);
$this->methodConditionFetcher = new MethodConditionWithInheritDocFetcher([Verify::class], $reader);
}

/**
Expand Down

0 comments on commit 078ca37

Please sign in to comment.