From b3de475d3c457c10cd5b7135cde5e0761d847a6d Mon Sep 17 00:00:00 2001 From: Carlos MAtos Date: Mon, 23 Dec 2024 02:53:31 +0000 Subject: [PATCH] Condition interface, abstract and decorators. --- README.md | 126 ++++++++++- composer.json | 20 +- src/Conditions/AbstractCondition.php | 24 +++ src/Conditions/AndCondition.php | 32 +++ src/Conditions/CachedCondition.php | 53 +++++ src/Conditions/CallableCondition.php | 32 +++ src/Conditions/ConditionInterface.php | 26 +++ src/Conditions/NotCondition.php | 25 +++ src/Conditions/OrCondition.php | 32 +++ src/Exceptions/ClassNotFoundException.php | 6 + src/Exceptions/ServiceNotFoundException.php | 6 + src/ServiceFactory.php | 67 ++++-- tests/Conditions/AdvancedConditionsTest.php | 225 ++++++++++++++++++++ tests/Conditions/ConditionsTest.php | 107 ++++++++++ 14 files changed, 762 insertions(+), 19 deletions(-) create mode 100644 src/Conditions/AbstractCondition.php create mode 100644 src/Conditions/AndCondition.php create mode 100644 src/Conditions/CachedCondition.php create mode 100644 src/Conditions/CallableCondition.php create mode 100644 src/Conditions/ConditionInterface.php create mode 100644 src/Conditions/NotCondition.php create mode 100644 src/Conditions/OrCondition.php create mode 100644 tests/Conditions/AdvancedConditionsTest.php create mode 100644 tests/Conditions/ConditionsTest.php diff --git a/README.md b/README.md index d92f601..4325130 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # Ananke -[![PHP Lint](https://github.com/cmatosbc/ananke/actions/workflows/lint.yml/badge.svg)](https://github.com/cmatosbc/ananke/actions/workflows/lint.yml) [![PHPUnit Tests](https://github.com/cmatosbc/ananke/actions/workflows/phpunit.yml/badge.svg)](https://github.com/cmatosbc/ananke/actions/workflows/phpunit.yml) [![PHP Composer](https://github.com/cmatosbc/ananke/actions/workflows/composer.yml/badge.svg)](https://github.com/cmatosbc/ananke/actions/workflows/composer.yml) +[![PHP Lint](https://github.com/cmatosbc/ananke/actions/workflows/lint.yml/badge.svg)](https://github.com/cmatosbc/ananke/actions/workflows/lint.yml) [![PHPUnit Tests](https://github.com/cmatosbc/ananke/actions/workflows/phpunit.yml/badge.svg)](https://github.com/cmatosbc/ananke/actions/workflows/phpunit.yml) [![PHP Composer](https://github.com/cmatosbc/ananke/actions/workflows/composer.yml/badge.svg)](https://github.com/cmatosbc/ananke/actions/workflows/composer.yml) [![Latest Stable Version](http://poser.pugx.org/cmatosbc/ananke/v)](https://packagist.org/packages/cmatosbc/ananke) [![License](http://poser.pugx.org/cmatosbc/ananke/license)](https://packagist.org/packages/cmatosbc/ananke) A flexible PHP service container that supports conditional service instantiation. This package allows you to register services with multiple conditions that must be met before the service can be instantiated. +## Requirements + +- PHP 8.0 or higher + ## Features - Register services with their class names and constructor parameters @@ -66,6 +70,126 @@ if ($factory->has('premium.feature')) { } ``` +## Condition Decorators + +Ananke provides a powerful set of condition decorators that allow you to compose complex condition logic: + +### Not Condition + +Negate any condition: + +```php +use Ananke\Conditions\{NotCondition, CallableCondition}; + +// Basic condition +$factory->registerCondition('is-maintenance', + new CallableCondition('is-maintenance', fn() => $maintenance->isActive())); + +// Negate it +$factory->registerCondition('not-maintenance', + new NotCondition($factory->getCondition('is-maintenance'))); + +// Use in service +$factory->register('api', APIService::class); +$factory->associateCondition('api', 'not-maintenance'); +``` + +### Cached Condition + +Cache expensive condition evaluations: + +```php +use Ananke\Conditions\CachedCondition; + +// Cache an expensive API check for 1 hour +$factory->registerCondition('api-status', + new CachedCondition( + new CallableCondition('api-check', fn() => $api->checkStatus()), + 3600 // Cache for 1 hour + )); +``` + +### AND/OR Conditions + +Combine multiple conditions with logical operators: + +```php +use Ananke\Conditions\{AndCondition, OrCondition}; + +// Premium access: User must be premium OR have a trial subscription +$factory->registerCondition('can-access-premium', + new OrCondition([ + new CallableCondition('is-premium', fn() => $user->isPremium()), + new CallableCondition('has-trial', fn() => $user->hasTrial()) + ])); + +// Database write: Need both connection AND proper permissions +$factory->registerCondition('can-write-db', + new AndCondition([ + new CallableCondition('is-connected', fn() => $db->isConnected()), + new CallableCondition('has-permissions', fn() => $user->canWrite()) + ])); +``` + +### Complex Condition Compositions + +Combine decorators for complex logic: + +```php +// ((isPremium OR hasTrial) AND notMaintenance) AND (hasQuota OR isUnlimited) +$factory->registerCondition('can-use-service', + new AndCondition([ + // Premium access check + new OrCondition([ + new CallableCondition('premium', fn() => $user->isPremium()), + new CallableCondition('trial', fn() => $user->hasTrial()) + ]), + // Not in maintenance + new NotCondition( + new CallableCondition('maintenance', fn() => $maintenance->isActive()) + ), + // Resource availability + new OrCondition([ + new CallableCondition('has-quota', fn() => $user->hasQuota()), + new CallableCondition('unlimited', fn() => $user->isUnlimited()) + ]) + ]) +); + +// Cache the entire complex condition +$factory->registerCondition('cached-access-check', + new CachedCondition( + $factory->getCondition('can-use-service'), + 300 // Cache for 5 minutes + ) +); +``` + +### Best Practices + +1. **Caching**: Use `CachedCondition` for: + - External API calls + - Database queries + - File system checks + - Any expensive operations + +2. **Composition**: Build complex conditions gradually: + - Start with simple conditions + - Combine them using AND/OR + - Add negation where needed + - Cache at appropriate levels + +3. **Naming**: Use clear, descriptive names: + - Negated: prefix with 'not-' + - Cached: prefix with 'cached-' + - Combined: use descriptive action names + +4. **Testing**: Test complex conditions thoroughly: + - Verify each sub-condition + - Test boundary cases + - Ensure proper short-circuit evaluation + - Validate cache behavior + ## Real-World Use Cases ### 1. Environment-Specific Services diff --git a/composer.json b/composer.json index 126fb8f..e0a2618 100644 --- a/composer.json +++ b/composer.json @@ -9,13 +9,29 @@ "email": "carlosarturmatos1977@gmail.com" } ], - "minimum-stability": "dev", + "require": { + "php": ">=8.0" + }, "require-dev": { - "phpunit/phpunit": "11.5.x-dev" + "phpunit/phpunit": "^9.0", + "squizlabs/php_codesniffer": "^3.0" }, "autoload": { "psr-4": { "Ananke\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Ananke\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "cs-check": "phpcs", + "cs-fix": "phpcbf" + }, + "config": { + "sort-packages": true } } diff --git a/src/Conditions/AbstractCondition.php b/src/Conditions/AbstractCondition.php new file mode 100644 index 0000000..327223e --- /dev/null +++ b/src/Conditions/AbstractCondition.php @@ -0,0 +1,24 @@ +name = $name; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Conditions/AndCondition.php b/src/Conditions/AndCondition.php new file mode 100644 index 0000000..d2e2192 --- /dev/null +++ b/src/Conditions/AndCondition.php @@ -0,0 +1,32 @@ + $c->getName(), $conditions); + parent::__construct('and_' . implode('_', $names)); + $this->conditions = $conditions; + } + + public function evaluate(): bool + { + foreach ($this->conditions as $condition) { + if (!$condition->evaluate()) { + return false; + } + } + return true; + } +} diff --git a/src/Conditions/CachedCondition.php b/src/Conditions/CachedCondition.php new file mode 100644 index 0000000..96b8a46 --- /dev/null +++ b/src/Conditions/CachedCondition.php @@ -0,0 +1,53 @@ +getName()}"); + $this->condition = $condition; + $this->ttl = $ttl; + } + + public function evaluate(): bool + { + $now = time(); + + // If we have a cached result and it hasn't expired + if ($this->cachedResult !== null && + $this->cachedAt !== null && + $now - $this->cachedAt < $this->ttl + ) { + return $this->cachedResult; + } + + // Evaluate and cache the result + $this->cachedResult = $this->condition->evaluate(); + $this->cachedAt = $now; + + return $this->cachedResult; + } + + /** + * Clear the cached result. + */ + public function clearCache(): void + { + $this->cachedResult = null; + $this->cachedAt = null; + } +} diff --git a/src/Conditions/CallableCondition.php b/src/Conditions/CallableCondition.php new file mode 100644 index 0000000..e237471 --- /dev/null +++ b/src/Conditions/CallableCondition.php @@ -0,0 +1,32 @@ +name = $name; + $this->validator = \Closure::fromCallable($validator); + } + + public function evaluate(): bool + { + return ($this->validator)(); + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Conditions/ConditionInterface.php b/src/Conditions/ConditionInterface.php new file mode 100644 index 0000000..c5239ff --- /dev/null +++ b/src/Conditions/ConditionInterface.php @@ -0,0 +1,26 @@ +getName()}"); + $this->condition = $condition; + } + + public function evaluate(): bool + { + return !$this->condition->evaluate(); + } +} diff --git a/src/Conditions/OrCondition.php b/src/Conditions/OrCondition.php new file mode 100644 index 0000000..012c348 --- /dev/null +++ b/src/Conditions/OrCondition.php @@ -0,0 +1,32 @@ + $c->getName(), $conditions); + parent::__construct('or_' . implode('_', $names)); + $this->conditions = $conditions; + } + + public function evaluate(): bool + { + foreach ($this->conditions as $condition) { + if ($condition->evaluate()) { + return true; + } + } + return false; + } +} diff --git a/src/Exceptions/ClassNotFoundException.php b/src/Exceptions/ClassNotFoundException.php index 5505752..3761962 100644 --- a/src/Exceptions/ClassNotFoundException.php +++ b/src/Exceptions/ClassNotFoundException.php @@ -2,6 +2,12 @@ namespace Ananke\Exceptions; +/** + * Exception thrown when attempting to register a service with a non-existent class. + * + * This exception is thrown by the ServiceFactory when register() is called with a + * class name that does not exist or cannot be autoloaded. + */ class ClassNotFoundException extends \Exception { } diff --git a/src/Exceptions/ServiceNotFoundException.php b/src/Exceptions/ServiceNotFoundException.php index 5c3c2ee..e1ffb2c 100644 --- a/src/Exceptions/ServiceNotFoundException.php +++ b/src/Exceptions/ServiceNotFoundException.php @@ -2,6 +2,12 @@ namespace Ananke\Exceptions; +/** + * Exception thrown when attempting to create a service that has not been registered. + * + * This exception is thrown by the ServiceFactory when create() is called with a + * service name that was not previously registered using register(). + */ class ServiceNotFoundException extends \Exception { } diff --git a/src/ServiceFactory.php b/src/ServiceFactory.php index a9754e7..a9c58db 100644 --- a/src/ServiceFactory.php +++ b/src/ServiceFactory.php @@ -4,13 +4,22 @@ use Ananke\Exceptions\ServiceNotFoundException; use Ananke\Exceptions\ClassNotFoundException; - +use Ananke\Conditions\ConditionInterface; +use Ananke\Conditions\CallableCondition; + +/** + * A flexible service container that supports conditional service instantiation. + * + * This container allows registering services with multiple conditions that must be met + * before the service can be instantiated. Each service can have zero or more conditions, + * and all conditions must evaluate to true for the service to be created. + */ class ServiceFactory { /** @var array Service name to class name mapping */ private array $services = []; - /** @var array Condition name to validation function mapping */ + /** @var array Condition name to condition object mapping */ private array $conditions = []; /** @var array> Service name to condition names mapping */ @@ -20,7 +29,12 @@ class ServiceFactory private array $parameters = []; /** - * Register a service with its class name and optional parameters + * Register a service with its class name and optional parameters. + * + * @param string $serviceName Unique identifier for the service + * @param string $className Fully qualified class name that exists + * @param array $parameters Optional constructor parameters for the service + * @throws ClassNotFoundException When the class does not exist */ public function register(string $serviceName, string $className, array $parameters = []): void { @@ -33,15 +47,30 @@ public function register(string $serviceName, string $className, array $paramete } /** - * Register a condition with a validation function + * Register a condition with a validation function or condition object. + * + * @param string $conditionName Unique identifier for the condition + * @param callable|ConditionInterface $validator Function that returns bool when condition is evaluated */ - public function registerCondition(string $conditionName, callable $validator): void + public function registerCondition(string $conditionName, callable|ConditionInterface $validator): void { - $this->conditions[$conditionName] = $validator; + if ($validator instanceof ConditionInterface) { + $this->conditions[$conditionName] = $validator; + } else { + $this->conditions[$conditionName] = new CallableCondition($conditionName, $validator); + } } /** - * Associate a condition with a service + * Associate a condition with a service. + * + * Multiple conditions can be associated with the same service. All conditions + * must evaluate to true for the service to be created. + * + * @param string $serviceName Name of a registered service + * @param string $conditionName Name of a registered condition + * @throws ServiceNotFoundException When service is not registered + * @throws \InvalidArgumentException When condition is not registered */ public function associateCondition(string $serviceName, string $conditionName): void { @@ -57,9 +86,11 @@ public function associateCondition(string $serviceName, string $conditionName): } /** - * Evaluates all conditions for a service + * Evaluates all conditions for a service. * - * @throws \InvalidArgumentException When a condition is not met + * @param string $serviceName Name of the service to evaluate conditions for + * @throws \InvalidArgumentException When a condition is not met or returns invalid result + * @return \Generator Yields true for each satisfied condition */ private function evaluateConditions(string $serviceName): \Generator { @@ -69,8 +100,8 @@ private function evaluateConditions(string $serviceName): \Generator } foreach ($this->serviceConditions[$serviceName] as $conditionName) { - $validator = $this->conditions[$conditionName]; - $result = $validator(); + $condition = $this->conditions[$conditionName]; + $result = $condition->evaluate(); yield match ($result) { true => true, @@ -85,11 +116,12 @@ private function evaluateConditions(string $serviceName): \Generator } /** - * Create an instance of a registered service if all its conditions are met + * Create an instance of a registered service if all its conditions are met. * - * @throws ServiceNotFoundException - * @throws ClassNotFoundException - * @throws \InvalidArgumentException + * @param string $serviceName Name of the service to create + * @throws ServiceNotFoundException When service is not registered + * @throws \InvalidArgumentException When any condition is not met + * @return object Instance of the requested service */ public function create(string $serviceName): object { @@ -108,7 +140,10 @@ public function create(string $serviceName): object } /** - * Check if a service exists and all its conditions are met + * Check if a service exists and all its conditions are met. + * + * @param string $serviceName Name of the service to check + * @return bool True if service exists and all conditions are met */ public function has(string $serviceName): bool { diff --git a/tests/Conditions/AdvancedConditionsTest.php b/tests/Conditions/AdvancedConditionsTest.php new file mode 100644 index 0000000..1164dc4 --- /dev/null +++ b/tests/Conditions/AdvancedConditionsTest.php @@ -0,0 +1,225 @@ + $result); + } + + public function testNestedConditions(): void + { + // Create a deeply nested condition structure + // ((A AND B) OR (NOT C)) AND (D OR (NOT E)) + $condA = $this->createTestCondition('A', true); + $condB = $this->createTestCondition('B', true); + $condC = $this->createTestCondition('C', false); + $condD = $this->createTestCondition('D', false); + $condE = $this->createTestCondition('E', true); + + $ab = new AndCondition([$condA, $condB]); + $notC = new NotCondition($condC); + $leftSide = new OrCondition([$ab, $notC]); + + $notE = new NotCondition($condE); + $rightSide = new OrCondition([$condD, $notE]); + + $final = new AndCondition([$leftSide, $rightSide]); + + $this->assertFalse($final->evaluate()); + $this->assertStringContainsString('and_or_and_A_B_not_C_or_D_not_E', $final->getName()); + } + + public function testCacheWithChangingConditions(): void + { + $results = [true, false, true]; + $index = 0; + + $condition = new class($results, $index) implements \Ananke\Conditions\ConditionInterface { + private array $results; + private int $index; + + public function __construct(array &$results, int &$index) + { + $this->results = &$results; + $this->index = &$index; + } + + public function evaluate(): bool + { + return $this->results[$this->index++ % count($this->results)]; + } + + public function getName(): string + { + return 'changing'; + } + }; + + $cached = new CachedCondition($condition, 1); + + // First evaluation should be true + $this->assertTrue($cached->evaluate()); + + // Should still be true (cached) even though underlying condition would return false + $this->assertTrue($cached->evaluate()); + + // Wait for cache to expire + sleep(2); + + // Should now be false (second evaluation) + $this->assertFalse($cached->evaluate()); + } + + public function testDoubleNegation(): void + { + $base = $this->createTestCondition('base', true); + $not = new NotCondition($base); + $doubleNot = new NotCondition($not); + + $this->assertTrue($doubleNot->evaluate()); + $this->assertEquals('not_not_base', $doubleNot->getName()); + } + + public function testEmptyComposites(): void + { + $emptyAnd = new AndCondition([]); + $emptyOr = new OrCondition([]); + + // Empty AND should return true (all conditions are met) + $this->assertTrue($emptyAnd->evaluate()); + // Empty OR should return false (no conditions are met) + $this->assertFalse($emptyOr->evaluate()); + } + + public function testCacheClearBehavior(): void + { + $count = 0; + $condition = new class($count) implements \Ananke\Conditions\ConditionInterface { + private int $count; + + public function __construct(int &$count) + { + $this->count = &$count; + } + + public function evaluate(): bool + { + $this->count++; + return true; + } + + public function getName(): string + { + return 'counter'; + } + }; + + $cached = new CachedCondition($condition, 10); + + // First evaluation + $this->assertTrue($cached->evaluate()); + $this->assertEquals(1, $count); + + // Cached evaluation + $this->assertTrue($cached->evaluate()); + $this->assertEquals(1, $count); + + // Clear cache and evaluate again + $cached->clearCache(); + $this->assertTrue($cached->evaluate()); + $this->assertEquals(2, $count); + } + + public function testComplexCaching(): void + { + $base1 = $this->createTestCondition('base1', true); + $base2 = $this->createTestCondition('base2', false); + + $not = new NotCondition($base2); + $and = new AndCondition([$base1, $not]); + + // Cache the complex condition + $cached = new CachedCondition($and, 1); + + $this->assertTrue($cached->evaluate()); + $this->assertTrue($cached->evaluate()); // Should use cache + + sleep(2); // Wait for cache to expire + + $this->assertTrue($cached->evaluate()); // Should re-evaluate + } + + public function testShortCircuitEvaluation(): void + { + $evaluated = []; + + $conditions = [ + new class($evaluated) implements \Ananke\Conditions\ConditionInterface { + private array $evaluated; + + public function __construct(array &$evaluated) + { + $this->evaluated = &$evaluated; + } + + public function evaluate(): bool + { + $this->evaluated[] = 'first'; + return false; + } + + public function getName(): string + { + return 'first'; + } + }, + new class($evaluated) implements \Ananke\Conditions\ConditionInterface { + private array $evaluated; + + public function __construct(array &$evaluated) + { + $this->evaluated = &$evaluated; + } + + public function evaluate(): bool + { + $this->evaluated[] = 'second'; + return true; + } + + public function getName(): string + { + return 'second'; + } + } + ]; + + // Test AND short-circuit + $and = new AndCondition($conditions); + $and->evaluate(); + $this->assertEquals(['first'], $evaluated, 'AND should stop after first false'); + + // Reset evaluated array + $evaluated = []; + + // Test OR short-circuit + $or = new OrCondition($conditions); + $or->evaluate(); + $this->assertEquals(['first', 'second'], $evaluated, 'OR should continue until true'); + } +} diff --git a/tests/Conditions/ConditionsTest.php b/tests/Conditions/ConditionsTest.php new file mode 100644 index 0000000..0298230 --- /dev/null +++ b/tests/Conditions/ConditionsTest.php @@ -0,0 +1,107 @@ + true); + $this->assertTrue($condition->evaluate()); + $this->assertEquals('test', $condition->getName()); + + $condition = new CallableCondition('test', fn() => false); + $this->assertFalse($condition->evaluate()); + } + + public function testNotCondition(): void + { + $base = new CallableCondition('base', fn() => true); + $not = new NotCondition($base); + + $this->assertFalse($not->evaluate()); + $this->assertEquals('not_base', $not->getName()); + } + + public function testCachedCondition(): void + { + $count = 0; + $base = new CallableCondition('base', function() use (&$count) { + $count++; + return true; + }); + + $cached = new CachedCondition($base, 1); + + // First call should evaluate + $this->assertTrue($cached->evaluate()); + $this->assertEquals(1, $count); + + // Second call should use cache + $this->assertTrue($cached->evaluate()); + $this->assertEquals(1, $count); + + // Wait for cache to expire + sleep(2); + + // Should evaluate again + $this->assertTrue($cached->evaluate()); + $this->assertEquals(2, $count); + } + + public function testAndCondition(): void + { + $conditions = [ + new CallableCondition('true1', fn() => true), + new CallableCondition('true2', fn() => true) + ]; + $and = new AndCondition($conditions); + $this->assertTrue($and->evaluate()); + + $conditions[] = new CallableCondition('false', fn() => false); + $and = new AndCondition($conditions); + $this->assertFalse($and->evaluate()); + } + + public function testOrCondition(): void + { + $conditions = [ + new CallableCondition('false1', fn() => false), + new CallableCondition('true', fn() => true), + new CallableCondition('false2', fn() => false) + ]; + $or = new OrCondition($conditions); + $this->assertTrue($or->evaluate()); + + $conditions = [ + new CallableCondition('false1', fn() => false), + new CallableCondition('false2', fn() => false) + ]; + $or = new OrCondition($conditions); + $this->assertFalse($or->evaluate()); + } + + public function testComplexConditions(): void + { + // Create a complex condition: (A AND B) OR (NOT C) + $a = new CallableCondition('A', fn() => true); + $b = new CallableCondition('B', fn() => true); + $c = new CallableCondition('C', fn() => false); + + $ab = new AndCondition([$a, $b]); + $notC = new NotCondition($c); + $complex = new OrCondition([$ab, $notC]); + + $this->assertTrue($complex->evaluate()); + $this->assertEquals('or_and_A_B_not_C', $complex->getName()); + } +}