diff --git a/README.md b/README.md index ae79956..bd3db9b 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Ananke -A flexible PHP service container that supports conditional service instantiation. This package allows you to register services with conditions that must be met before the service can be instantiated. +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. ## Features -- **Service Registration**: Register classes with their dependencies and constructor parameters -- **Conditional Creation**: Define conditions that must be met before services can be instantiated -- **Flexible Conditions**: Use any callable that returns a boolean as a condition -- **Runtime Validation**: Services are only created when their conditions are satisfied -- **Type Safety**: Full PHP 8.0+ type hints and return type declarations +- Register services with their class names and constructor parameters +- Define conditions as callable functions +- Associate multiple conditions with services +- Dynamic service instantiation based on condition evaluation +- Clear error handling with specific exceptions ## Installation @@ -26,137 +26,106 @@ $factory = new ServiceFactory(); // Register a service with constructor parameters $factory->register('logger', Logger::class, ['debug']); -// Register a condition +// Register conditions $factory->registerCondition('is-development', fn() => getenv('APP_ENV') === 'development'); +$factory->registerCondition('has-permissions', fn() => is_writable('/var/log')); -// Associate condition with service +// Associate multiple conditions with service $factory->associateCondition('logger', 'is-development'); +$factory->associateCondition('logger', 'has-permissions'); -// Create service (only works in development) +// Create service (only works if ALL conditions are met) if ($factory->has('logger')) { $logger = $factory->create('logger'); } ``` +## Multiple Conditions + +Services can have multiple conditions that must ALL be satisfied before instantiation: + +```php +// Premium feature example +$factory->register('premium.feature', PremiumFeature::class); + +// Register all required conditions +$factory->registerCondition('is-premium-user', fn() => $user->hasPremiumSubscription()); +$factory->registerCondition('feature-enabled', fn() => $featureFlags->isEnabled('new-feature')); +$factory->registerCondition('has-valid-license', fn() => $license->isValid()); + +// Associate ALL conditions with the service +$factory->associateCondition('premium.feature', 'is-premium-user'); +$factory->associateCondition('premium.feature', 'feature-enabled'); +$factory->associateCondition('premium.feature', 'has-valid-license'); + +// Service will only be created if ALL conditions are met +if ($factory->has('premium.feature')) { + $feature = $factory->create('premium.feature'); +} +``` + ## Real-World Use Cases ### 1. Environment-Specific Services -Control which services are available based on the application environment: +Control debug tools based on environment: ```php -// Register services -$factory->register('debugbar', DebugBar::class); -$factory->register('profiler', Profiler::class); -$factory->register('logger', VerboseLogger::class); - -// Register environment condition -$factory->registerCondition('is-development', function() { - return getenv('APP_ENV') === 'development'; -}); - -// Only allow debug tools in development -$factory->associateCondition('debugbar', 'is-development'); -$factory->associateCondition('profiler', 'is-development'); -$factory->associateCondition('logger', 'is-development'); - -// In production, these won't be created -$debugbar = $factory->create('debugbar'); // Throws exception in production +$factory->register('debugger', Debugger::class); +$factory->registerCondition('is-development', fn() => getenv('APP_ENV') === 'development'); +$factory->registerCondition('debug-enabled', fn() => getenv('APP_DEBUG') === 'true'); +$factory->associateCondition('debugger', 'is-development'); +$factory->associateCondition('debugger', 'debug-enabled'); ``` ### 2. Feature Flags and A/B Testing -Implement feature flags or A/B testing by conditionally creating different service implementations: +Implement feature toggles with multiple conditions: ```php -// Register different UI implementations -$factory->register('checkout.old', OldCheckoutProcess::class); -$factory->register('checkout.new', NewCheckoutProcess::class); - -// Register feature flag condition -$factory->registerCondition('new-checkout-enabled', function() { - return FeatureFlags::isEnabled('new-checkout') || - ABTest::userInGroup('new-checkout'); -}); - -// Use new checkout only when feature is enabled -$factory->associateCondition('checkout.new', 'new-checkout-enabled'); - -// Get appropriate checkout implementation -$checkout = $factory->has('checkout.new') - ? $factory->create('checkout.new') - : $factory->create('checkout.old'); +$factory->register('new.ui', NewUIComponent::class); +$factory->registerCondition('feature-enabled', fn() => $featureFlags->isEnabled('new-ui')); +$factory->registerCondition('in-test-group', fn() => $abTest->isInGroup('new-ui-test')); +$factory->registerCondition('supported-browser', fn() => $browser->supportsFeature('grid-layout')); +$factory->associateCondition('new.ui', 'feature-enabled'); +$factory->associateCondition('new.ui', 'in-test-group'); +$factory->associateCondition('new.ui', 'supported-browser'); ``` ### 3. Database Connection Management -Ensure database-dependent services are only created when a connection is available: +Safe handling of database-dependent services: ```php -// Register database-dependent services $factory->register('user.repository', UserRepository::class); -$factory->register('order.repository', OrderRepository::class); -$factory->register('cache.database', DatabaseCache::class); - -// Register connection checker -$factory->registerCondition('db-connected', function() { - try { - return Database::getInstance()->isConnected(); - } catch (ConnectionException $e) { - return false; - } -}); - -// Ensure repositories only work with database connection +$factory->registerCondition('db-connected', fn() => $database->isConnected()); +$factory->registerCondition('db-migrated', fn() => $database->isMigrated()); +$factory->registerCondition('has-permissions', fn() => $database->hasPermissions('users')); $factory->associateCondition('user.repository', 'db-connected'); -$factory->associateCondition('order.repository', 'db-connected'); -$factory->associateCondition('cache.database', 'db-connected'); - -// Safely create repository -if ($factory->has('user.repository')) { - $users = $factory->create('user.repository'); -} else { - // Fall back to offline mode or throw exception -} +$factory->associateCondition('user.repository', 'db-migrated'); +$factory->associateCondition('user.repository', 'has-permissions'); ``` ### 4. License-Based Feature Access -Control access to premium features based on user licenses: +Control access to premium features: ```php -// Register feature implementations -$factory->register('export.basic', BasicExporter::class); -$factory->register('export.advanced', AdvancedExporter::class); -$factory->register('report.generator', ReportGenerator::class); -$factory->register('ai.assistant', AIAssistant::class); - -// Register license checker -$factory->registerCondition('has-premium', function() { - return License::getCurrentPlan()->isPremium(); -}); - -$factory->registerCondition('has-enterprise', function() { - return License::getCurrentPlan()->isEnterprise(); -}); - -// Associate features with license levels -$factory->associateCondition('export.advanced', 'has-premium'); -$factory->associateCondition('report.generator', 'has-premium'); -$factory->associateCondition('ai.assistant', 'has-enterprise'); - -// Create appropriate exporter based on license -$exporter = $factory->has('export.advanced') - ? $factory->create('export.advanced') - : $factory->create('export.basic'); +$factory->register('premium.api', PremiumAPIClient::class); +$factory->registerCondition('has-license', fn() => $license->isValid()); +$factory->registerCondition('within-quota', fn() => $usage->isWithinQuota()); +$factory->registerCondition('api-available', fn() => $api->isAvailable()); +$factory->associateCondition('premium.api', 'has-license'); +$factory->associateCondition('premium.api', 'within-quota'); +$factory->associateCondition('premium.api', 'api-available'); ``` ## Error Handling -The factory throws different exceptions based on the error: +The service container throws specific exceptions: -- `ServiceNotFoundException`: When trying to create a non-existent service -- `ClassNotFoundException`: When the service class doesn't exist +- `ServiceNotFoundException`: When trying to create a non-registered service +- `ClassNotFoundException`: When registering a service with a non-existent class - `InvalidArgumentException`: When a condition is not met or invalid ## Testing @@ -164,7 +133,22 @@ The factory throws different exceptions based on the error: Run the test suite: ```bash -vendor/bin/phpunit +composer test +``` + +The tests provide detailed output showing the state of conditions and service creation: + +``` +๐Ÿงช Test: Multiple Conditions + โœ… Registered premium feature service + โœ… Registered all conditions + + ๐Ÿ“Š Current State: + โ€ข Premium Status: โœ… + โ€ข Feature Flag: โœ… + โ€ข Valid License: โŒ + โ„น๏ธ Testing with incomplete conditions + โœ… Verified feature is not available ``` ## Contributing @@ -173,4 +157,4 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## License -This package is open-sourced software licensed under the MIT license. \ No newline at end of file +This project is licensed under the GPL-3.0-or-later License - see the LICENSE file for details. \ No newline at end of file diff --git a/src/ServiceFactory.php b/src/ServiceFactory.php index 4f251f2..e453ef5 100644 --- a/src/ServiceFactory.php +++ b/src/ServiceFactory.php @@ -13,7 +13,7 @@ class ServiceFactory /** @var array Condition name to validation function mapping */ private array $conditions = []; - /** @var array Service name to condition name mapping */ + /** @var array> Service name to condition names mapping */ private array $serviceConditions = []; /** @var array Service name to constructor parameters mapping */ @@ -53,11 +53,35 @@ public function associateCondition(string $serviceName, string $conditionName): throw new \InvalidArgumentException("Condition not found: $conditionName"); } - $this->serviceConditions[$serviceName] = $conditionName; + $this->serviceConditions[$serviceName][] = $conditionName; } /** - * Create an instance of a registered service if its condition (if any) is met + * Evaluates all conditions for a service + * + * @throws \InvalidArgumentException When a condition is not met + */ + private function evaluateConditions(string $serviceName): \Generator + { + if (!isset($this->serviceConditions[$serviceName])) { + yield true; + return; + } + + foreach ($this->serviceConditions[$serviceName] as $conditionName) { + $validator = $this->conditions[$conditionName]; + $result = $validator(); + + yield match ($result) { + true => true, + false => throw new \InvalidArgumentException("Condition '$conditionName' not met for service: $serviceName"), + default => throw new \InvalidArgumentException("Invalid result for condition '$conditionName' on service: $serviceName") + }; + } + } + + /** + * Create an instance of a registered service if all its conditions are met * * @throws ServiceNotFoundException * @throws ClassNotFoundException @@ -69,17 +93,10 @@ public function create(string $serviceName): object throw new ServiceNotFoundException("Service not found: $serviceName"); } - // Check if service has an associated condition - if (isset($this->serviceConditions[$serviceName])) { - $conditionName = $this->serviceConditions[$serviceName]; - $validator = $this->conditions[$conditionName]; - - // Evaluate the condition - $result = match ($validator()) { - true => true, - false => throw new \InvalidArgumentException("Condition not met for service: $serviceName"), - default => throw new \InvalidArgumentException("Invalid condition result for service: $serviceName") - }; + // Evaluate all conditions + foreach ($this->evaluateConditions($serviceName) as $result) { + // Just iterate through all conditions + // Any failed condition will throw an exception } $className = $this->services[$serviceName]; @@ -87,7 +104,7 @@ public function create(string $serviceName): object } /** - * Check if a service exists and its condition (if any) is met + * Check if a service exists and all its conditions are met */ public function has(string $serviceName): bool { @@ -95,14 +112,16 @@ public function has(string $serviceName): bool return false; } - // If service has a condition, check if it's met - if (isset($this->serviceConditions[$serviceName])) { - $conditionName = $this->serviceConditions[$serviceName]; - $validator = $this->conditions[$conditionName]; - - return (bool) $validator(); + // Check all conditions + try { + foreach ($this->evaluateConditions($serviceName) as $result) { + if (!$result) { + return false; + } + } + return true; + } catch (\InvalidArgumentException $e) { + return false; } - - return true; } } diff --git a/tests/ServiceFactoryTest.php b/tests/ServiceFactoryTest.php index 0d9cbf1..9a2bda3 100644 --- a/tests/ServiceFactoryTest.php +++ b/tests/ServiceFactoryTest.php @@ -7,147 +7,186 @@ use Ananke\Exceptions\ClassNotFoundException; use PHPUnit\Framework\TestCase; -class Logger { - private string $level; +class PremiumFeature { + private bool $enabled; - public function __construct(string $level = 'info') { - $this->level = $level; - printf("\n Created Logger with level: %s", $level); + public function __construct(bool $enabled = true) { + $this->enabled = $enabled; + printf("\n Created PremiumFeature (enabled: %s)", $enabled ? 'yes' : 'no'); } - public function getLevel(): string { - return $this->level; - } -} - -class Database { - private bool $isConnected = false; - - public function __construct() { - printf("\n Created Database instance"); - } - - public function connect(): void { - $this->isConnected = true; - } - - public function isConnected(): bool { - return $this->isConnected; + public function isEnabled(): bool { + return $this->enabled; } } class ServiceFactoryTest extends TestCase { private ServiceFactory $factory; - private bool $isDevelopment = true; - private bool $isDbConnected = false; + private bool $isPremium = false; + private bool $isFeatureEnabled = false; + private bool $hasValidLicense = false; protected function setUp(): void { $this->factory = new ServiceFactory(); - printf("\n\nSetting up new ServiceFactory instance"); + printf("\n\n๐Ÿญ Setting up new test case"); + } + + private function printConditionState(string $message): void + { + printf("\n ๐Ÿ“Š Current State:"); + printf("\n โ€ข Premium Status: %s", $this->isPremium ? 'โœ…' : 'โŒ'); + printf("\n โ€ข Feature Flag: %s", $this->isFeatureEnabled ? 'โœ…' : 'โŒ'); + printf("\n โ€ข Valid License: %s", $this->hasValidLicense ? 'โœ…' : 'โŒ'); + if ($message) { + printf("\n โ„น๏ธ %s", $message); + } } /** * @test */ - public function itShouldRegisterAndCreateServices(): void + public function itShouldRegisterAndCreateBasicServices(): void { - printf("\n\nTesting basic service registration"); - - // Register services with different parameters - $this->factory->register('logger.debug', Logger::class, ['debug']); - $this->factory->register('logger.error', Logger::class, ['error']); - printf("\n Registered logger services with different levels"); - - // Create and verify instances - $debugLogger = $this->factory->create('logger.debug'); - $this->assertInstanceOf(Logger::class, $debugLogger); - $this->assertEquals('debug', $debugLogger->getLevel()); - printf("\n Successfully created debug logger"); - - $errorLogger = $this->factory->create('logger.error'); - $this->assertInstanceOf(Logger::class, $errorLogger); - $this->assertEquals('error', $errorLogger->getLevel()); - printf("\n Successfully created error logger"); + printf("\n\n๐Ÿงช Test: Basic Service Registration"); + + // Register a simple service + $this->factory->register('feature', PremiumFeature::class, [true]); + printf("\n โœ… Registered basic feature service"); + + // Create and verify instance + $feature = $this->factory->create('feature'); + $this->assertInstanceOf(PremiumFeature::class, $feature); + $this->assertTrue($feature->isEnabled()); + printf("\n โœจ Successfully created basic feature without conditions"); } /** * @test */ - public function itShouldRegisterAndValidateConditions(): void + public function itShouldHandleMultipleConditions(): void { - printf("\n\nTesting condition registration"); - - // Register conditions - $this->factory->registerCondition('dev-only', fn() => $this->isDevelopment); - $this->factory->registerCondition('db-connected', fn() => $this->isDbConnected); - printf("\n Registered development and database conditions"); - - // Register services - $this->factory->register('logger.debug', Logger::class, ['debug']); - $this->factory->register('database', Database::class); - printf("\n Registered services"); - - // Associate conditions - $this->factory->associateCondition('logger.debug', 'dev-only'); - $this->factory->associateCondition('database', 'db-connected'); - printf("\n Associated conditions with services"); - - // Test dev-only condition (should succeed) - $this->assertTrue($this->factory->has('logger.debug'), 'Debug logger should be available in development'); - $debugLogger = $this->factory->create('logger.debug'); - $this->assertInstanceOf(Logger::class, $debugLogger); - printf("\n Successfully created dev-only logger"); - - // Test db-connected condition (should fail) - $this->assertFalse($this->factory->has('database'), 'Database should not be available when disconnected'); - $this->expectException(\InvalidArgumentException::class); - printf("\n Attempting to create database instance (should fail)..."); - $this->factory->create('database'); + printf("\n\n๐Ÿงช Test: Multiple Conditions"); + + // Register service + $this->factory->register('premium.feature', PremiumFeature::class, [true]); + printf("\n โœ… Registered premium feature service"); + + // Register all conditions + $this->factory->registerCondition('is-premium', fn() => $this->isPremium); + $this->factory->registerCondition('feature-enabled', fn() => $this->isFeatureEnabled); + $this->factory->registerCondition('has-license', fn() => $this->hasValidLicense); + printf("\n โœ… Registered all conditions"); + + // Associate all conditions + $this->factory->associateCondition('premium.feature', 'is-premium'); + $this->factory->associateCondition('premium.feature', 'feature-enabled'); + $this->factory->associateCondition('premium.feature', 'has-license'); + printf("\n โœ… Associated all conditions with premium feature"); + + // Test: No conditions met + $this->printConditionState("Testing with no conditions met"); + $this->assertFalse( + $this->factory->has('premium.feature'), + 'Feature should not be available when no conditions are met' + ); + printf("\n โœ… Verified feature is not available with no conditions met"); + + // Test: Only premium status + $this->isPremium = true; + $this->printConditionState("Testing with only premium status"); + $this->assertFalse( + $this->factory->has('premium.feature'), + 'Feature should not be available with only premium status' + ); + printf("\n โœ… Verified feature is not available with only premium status"); + + // Test: Premium status and feature flag + $this->isFeatureEnabled = true; + $this->printConditionState("Testing with premium status and feature flag"); + $this->assertFalse( + $this->factory->has('premium.feature'), + 'Feature should not be available without license' + ); + printf("\n โœ… Verified feature is not available without license"); + + // Test: All conditions met + $this->hasValidLicense = true; + $this->printConditionState("Testing with all conditions met"); + $this->assertTrue( + $this->factory->has('premium.feature'), + 'Feature should be available when all conditions are met' + ); + printf("\n โœ… Verified feature is available with all conditions met"); + + // Test: Create instance with all conditions met + printf("\n ๐Ÿ”จ Creating feature instance..."); + $feature = $this->factory->create('premium.feature'); + $this->assertInstanceOf(PremiumFeature::class, $feature); + $this->assertTrue($feature->isEnabled()); + printf("\n โœจ Successfully created feature instance"); + + // Test: Failure when one condition becomes false + $this->isFeatureEnabled = false; + $this->printConditionState("Testing after disabling feature flag"); + $this->assertFalse( + $this->factory->has('premium.feature'), + 'Feature should not be available when any condition fails' + ); + printf("\n โœ… Verified feature is not available after condition failure"); + + // Test: Exception when creating with failed condition + printf("\n โš ๏ธ Attempting to create feature with failed condition..."); + try { + $this->factory->create('premium.feature'); + $this->fail('Expected InvalidArgumentException was not thrown'); + } catch (\InvalidArgumentException $e) { + $this->assertEquals( + "Condition 'feature-enabled' not met for service: premium.feature", + $e->getMessage() + ); + printf("\n โœ… Correctly caught exception: %s", $e->getMessage()); + } } /** * @test */ - public function itShouldRespectConditionsForServiceCreation(): void + public function itShouldHandleServiceNotFound(): void { - printf("\n\nTesting condition-based service creation"); - - // Register service and condition - $this->factory->register('database', Database::class); - $this->factory->registerCondition('db-connected', fn() => $this->isDbConnected); - $this->factory->associateCondition('database', 'db-connected'); - printf("\n Registered database service with connection condition"); - - // Try to create when condition is false - $this->assertFalse($this->factory->has('database'), 'Database should not be available when disconnected'); - printf("\n Verified database is not available when disconnected"); - - // Change condition to true - $this->isDbConnected = true; - printf("\n Connected to database"); - - // Try to create when condition is true - $this->assertTrue($this->factory->has('database'), 'Database should be available when connected'); - $db = $this->factory->create('database'); - $this->assertInstanceOf(Database::class, $db); - printf("\n Successfully created database instance after connecting"); + printf("\n\n๐Ÿงช Test: Service Not Found"); + + printf("\n โš ๏ธ Attempting to create non-existent service..."); + $this->expectException(ServiceNotFoundException::class); + $this->factory->create('non.existent'); } /** * @test */ - public function itShouldHandleErrorsGracefully(): void + public function itShouldHandleClassNotFound(): void { - printf("\n\nTesting error handling"); + printf("\n\n๐Ÿงช Test: Class Not Found"); + + printf("\n โš ๏ธ Attempting to register non-existent class..."); + $this->expectException(ClassNotFoundException::class); + $this->factory->register('invalid', 'NonExistentClass'); + } - // Test non-existent service - $this->assertFalse($this->factory->has('non.existent')); - printf("\n Verified non-existent service is not available"); + /** + * @test + */ + public function itShouldHandleInvalidCondition(): void + { + printf("\n\n๐Ÿงช Test: Invalid Condition"); + + // Register service + $this->factory->register('feature', PremiumFeature::class); + printf("\n โœ… Registered feature service"); - $this->expectException(ServiceNotFoundException::class); - printf("\n Attempting to create non-existent service..."); - $this->factory->create('non.existent'); + printf("\n โš ๏ธ Attempting to associate non-existent condition..."); + $this->expectException(\InvalidArgumentException::class); + $this->factory->associateCondition('feature', 'non-existent-condition'); } }