diff --git a/packages/Dbal/tests/Fixture/ClosureInAttribute/DedupPolicy.php b/packages/Dbal/tests/Fixture/ClosureInAttribute/DedupPolicy.php new file mode 100644 index 000000000..cf5b6cbf8 --- /dev/null +++ b/packages/Dbal/tests/Fixture/ClosureInAttribute/DedupPolicy.php @@ -0,0 +1,18 @@ +scope === 'customer' ? $customerId : $orderId; + })] + #[CommandHandler('policyDedup.perCustomer', endpointId: 'policyDedupPerCustomerEndpoint')] + public function handlePerCustomer(#[Header('orderId')] string $orderId): void + { + $this->handledPerCustomer[] = $orderId; + } + + #[DedupPolicy(scope: 'order')] + #[Deduplicated(expression: static function (DedupPolicy $policy, #[Header('customerId')] string $customerId, #[Header('orderId')] string $orderId): string { + return $policy->scope === 'customer' ? $customerId : $orderId; + })] + #[CommandHandler('policyDedup.perOrder', endpointId: 'policyDedupPerOrderEndpoint')] + public function handlePerOrder(#[Header('orderId')] string $orderId): void + { + $this->handledPerOrder[] = $orderId; + } + + #[QueryHandler('policyDedup.handledPerCustomer')] + public function handledPerCustomer(): array + { + return $this->handledPerCustomer; + } + + #[QueryHandler('policyDedup.handledPerOrder')] + public function handledPerOrder(): array + { + return $this->handledPerOrder; + } +} diff --git a/packages/Dbal/tests/Integration/ClosureInAttribute/ClosureExpressionDbalTest.php b/packages/Dbal/tests/Integration/ClosureInAttribute/ClosureExpressionDbalTest.php index 1ecd7501b..3b271ab78 100644 --- a/packages/Dbal/tests/Integration/ClosureInAttribute/ClosureExpressionDbalTest.php +++ b/packages/Dbal/tests/Integration/ClosureInAttribute/ClosureExpressionDbalTest.php @@ -24,6 +24,7 @@ use Test\Ecotone\Dbal\DbalMessagingTestCase; use Test\Ecotone\Dbal\Fixture\ClosureInAttribute\ClosureDeduplicatedHandler; use Test\Ecotone\Dbal\Fixture\ClosureInAttribute\PersonClosureParameterApi; +use Test\Ecotone\Dbal\Fixture\ClosureInAttribute\PolicyDrivenDeduplicatedHandler; use Test\Ecotone\Dbal\Fixture\ClosureInAttribute\TenantClosurePoller; use Test\Ecotone\Dbal\Fixture\MultiTenant\FakeConnectionFactory; @@ -53,6 +54,36 @@ classesToResolve: [ClosureDeduplicatedHandler::class], $this->assertEquals(2, $ecotoneLite->sendQueryWithRouting('closureDedup.getCallCount')); } + public function test_deduplication_closure_expression_receives_attribute_declared_on_handler(): void + { + $handler = new PolicyDrivenDeduplicatedHandler(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [PolicyDrivenDeduplicatedHandler::class], + containerOrAvailableServices: [$handler, DbalConnectionFactory::class => $this->getConnectionFactory(true)], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $ecotoneLite->sendCommandWithRoutingKey('policyDedup.perCustomer', 'test', metadata: ['customerId' => 'customer-1', 'orderId' => 'order-1']); + $ecotoneLite->sendCommandWithRoutingKey('policyDedup.perCustomer', 'test', metadata: ['customerId' => 'customer-1', 'orderId' => 'order-2']); + + $this->assertSame( + ['order-1'], + $ecotoneLite->sendQueryWithRouting('policyDedup.handledPerCustomer'), + 'DedupPolicy with customer scope was injected into closure, so both orders of same customer deduplicate to one' + ); + + $ecotoneLite->sendCommandWithRoutingKey('policyDedup.perOrder', 'test', metadata: ['customerId' => 'customer-2', 'orderId' => 'order-3']); + $ecotoneLite->sendCommandWithRoutingKey('policyDedup.perOrder', 'test', metadata: ['customerId' => 'customer-2', 'orderId' => 'order-4']); + + $this->assertSame( + ['order-3', 'order-4'], + $ecotoneLite->sendQueryWithRouting('policyDedup.handledPerOrder'), + 'Same closure with order scoped DedupPolicy deduplicates per order, proving the injected attribute drives the key' + ); + } + public function test_deduplication_closure_expression_throws_licensing_exception_on_bootstrap_without_enterprise_licence(): void { $this->expectException(LicensingException::class); diff --git a/packages/Dbal/tests/Integration/MultiTenant/ConsoleCommandTenantPropagationTest.php b/packages/Dbal/tests/Integration/MultiTenant/ConsoleCommandTenantPropagationTest.php new file mode 100644 index 000000000..251d20ce7 --- /dev/null +++ b/packages/Dbal/tests/Integration/MultiTenant/ConsoleCommandTenantPropagationTest.php @@ -0,0 +1,139 @@ + ['tenant:tenant_a']])`). + */ +/** + * licence Apache-2.0 + * @internal + */ +final class ConsoleCommandTenantPropagationTest extends DbalMessagingTestCase +{ + public const MARKER_TABLE = 'tenant_marker'; + + public function setUp(): void + { + parent::setUp(); + + foreach ([$this->connectionForTenantA(), $this->connectionForTenantB()] as $connectionFactory) { + $connection = $connectionFactory->createContext()->getDbalConnection(); + $connection->executeStatement('DROP TABLE IF EXISTS ' . self::MARKER_TABLE); + $connection->executeStatement('CREATE TABLE ' . self::MARKER_TABLE . ' (marker INTEGER)'); + } + } + + public function test_console_command_without_tenant_header_throws_when_no_default_connection(): void + { + $ecotoneLite = $this->bootstrapEcotone(); + + $this->expectException(MethodInvocationException::class); + $this->expectExceptionMessage('Lack of context about tenant in Message Headers'); + + $ecotoneLite->runConsoleCommand('multi_tenant:record_marker', []); + } + + public function test_console_command_tenant_header_routes_to_correct_tenant_connection(): void + { + $ecotoneLite = $this->bootstrapEcotone(); + + $ecotoneLite->runConsoleCommand('multi_tenant:record_marker', ['header' => ['tenant:tenant_a']]); + + $this->assertSame(1, $this->countMarkerRows($this->connectionForTenantA()), 'tenant_a should have received the marker'); + $this->assertSame(0, $this->countMarkerRows($this->connectionForTenantB()), 'tenant_b must be untouched - console command routed to tenant_a only'); + + $ecotoneLite->runConsoleCommand('multi_tenant:record_marker', ['header' => ['tenant:tenant_b']]); + + $this->assertSame(1, $this->countMarkerRows($this->connectionForTenantA())); + $this->assertSame(1, $this->countMarkerRows($this->connectionForTenantB())); + } + + public function test_tenant_header_from_console_command_propagates_to_command_bus_sub_flow(): void + { + $ecotoneLite = $this->bootstrapEcotone(); + + $ecotoneLite->runConsoleCommand('multi_tenant:record_marker_via_command_bus', ['header' => ['tenant:tenant_b']]); + + $this->assertSame(0, $this->countMarkerRows($this->connectionForTenantA())); + $this->assertSame(1, $this->countMarkerRows($this->connectionForTenantB()), 'tenant header must propagate from console command into the Command Bus sub-flow'); + } + + private function countMarkerRows(object $connectionFactory): int + { + return (int) $connectionFactory->createContext()->getDbalConnection() + ->executeQuery('SELECT COUNT(*) FROM ' . self::MARKER_TABLE) + ->fetchOne(); + } + + private function newTenantMarkerRecorder(): object + { + return new class () { + #[ConsoleCommand('multi_tenant:record_marker')] + public function record(#[MultiTenantConnection] Connection $connection): void + { + $connection->executeStatement('INSERT INTO ' . ConsoleCommandTenantPropagationTest::MARKER_TABLE . ' (marker) VALUES (1)'); + } + + #[ConsoleCommand('multi_tenant:record_marker_via_command_bus')] + public function recordViaCommandBus(#[Reference] CommandBus $commandBus): void + { + $commandBus->sendWithRouting('multi_tenant.record_marker'); + } + + #[CommandHandler('multi_tenant.record_marker')] + public function recordMarker(#[MultiTenantConnection] Connection $connection): void + { + $connection->executeStatement('INSERT INTO ' . ConsoleCommandTenantPropagationTest::MARKER_TABLE . ' (marker) VALUES (1)'); + } + }; + } + + private function bootstrapEcotone(): FlowTestSupport + { + $recorder = $this->newTenantMarkerRecorder(); + + return EcotoneLite::bootstrapFlowTesting( + [$recorder::class], + [ + $recorder, + 'tenant_a_connection' => $this->connectionForTenantA(), + 'tenant_b_connection' => $this->connectionForTenantB(), + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + MultiTenantConfiguration::create( + tenantHeaderName: 'tenant', + tenantToConnectionMapping: [ + 'tenant_a' => 'tenant_a_connection', + 'tenant_b' => 'tenant_b_connection', + ], + ), + DbalConfiguration::createWithDefaults() + ->withDeduplication(false), + ]), + ); + } +} diff --git a/packages/Ecotone/src/Messaging/Handler/ClosureExpression/AttributeExpressionExecutorCompiler.php b/packages/Ecotone/src/Messaging/Handler/ClosureExpression/AttributeExpressionExecutorCompiler.php index ec21e9ce5..421c759c4 100644 --- a/packages/Ecotone/src/Messaging/Handler/ClosureExpression/AttributeExpressionExecutorCompiler.php +++ b/packages/Ecotone/src/Messaging/Handler/ClosureExpression/AttributeExpressionExecutorCompiler.php @@ -16,13 +16,16 @@ use Ecotone\Messaging\Handler\InterfaceParameter; use Ecotone\Messaging\Handler\InterfaceToCall; use Ecotone\Messaging\Handler\ParameterConverterBuilder; +use Ecotone\Messaging\Handler\Processor\MethodInvoker\Converter\AttributeBuilder; use Ecotone\Messaging\Handler\Processor\MethodInvoker\Converter\MessageConverterBuilder; use Ecotone\Messaging\Handler\Processor\MethodInvoker\Converter\PayloadBuilder; use Ecotone\Messaging\Handler\Processor\MethodInvoker\Converter\ReferenceBuilder; use Ecotone\Messaging\Handler\Processor\MethodInvoker\Converter\ValueBuilder; use Ecotone\Messaging\Handler\Type; use Ecotone\Messaging\Support\Assert; +use ReflectionClass; use ReflectionFunction; +use ReflectionMethod; use ReflectionParameter; /** @@ -156,6 +159,8 @@ private static function executorDefinition(AttributeDefinition $attributeArgumen $closureParameterResolvers = self::parameterResolverDefinitions( $reflectionParameters, self::closureInterfaceToCall($ownerClassName, $ownerMethodName, $reflectionParameters), + $ownerClassName, + $ownerMethodName, ); } @@ -179,7 +184,7 @@ private static function matchesAttributeClass(string $annotationClassName, strin * @param ReflectionParameter[] $reflectionParameters * @return Definition[] */ - private static function parameterResolverDefinitions(array $reflectionParameters, InterfaceToCall $interfaceToCall): array + private static function parameterResolverDefinitions(array $reflectionParameters, InterfaceToCall $interfaceToCall, string $ownerClassName, ?string $ownerMethodName): array { $parameterResolvers = []; foreach ($reflectionParameters as $index => $reflectionParameter) { @@ -188,6 +193,12 @@ private static function parameterResolverDefinitions(array $reflectionParameters $converterBuilder = ParameterConverterAnnotationFactory::getConverterFor($interfaceParameter, $interfaceToCall); $resolvesFromAdditionalContext = $converterBuilder === null || $converterBuilder instanceof MessageConverterBuilder; + if ($converterBuilder === null) { + $converterBuilder = self::declaredAttributeConverterBuilderFor($interfaceParameter, $ownerClassName, $ownerMethodName); + if ($converterBuilder !== null) { + $resolvesFromAdditionalContext = false; + } + } if ($converterBuilder === null) { $converterBuilder = self::defaultConverterBuilderFor($interfaceParameter, $index === 0); } @@ -249,6 +260,35 @@ private static function ensureNoNestedClosureExpression(InterfaceParameter $inte } } + /** + * Resolves closure parameter type hinted with an Attribute declared on the owning method or class, + * so expression may adapt its behaviour to configuration declared next to the endpoint. + */ + private static function declaredAttributeConverterBuilderFor(InterfaceParameter $interfaceParameter, string $ownerClassName, ?string $ownerMethodName): ?ParameterConverterBuilder + { + if (! $interfaceParameter->isAnnotation()) { + return null; + } + + $parameterType = $interfaceParameter->getTypeDescriptor()->withoutNull(); + + if ($ownerMethodName !== null) { + foreach ((new ReflectionMethod($ownerClassName, $ownerMethodName))->getAttributes() as $attribute) { + if (Type::object($attribute->getName())->equals($parameterType)) { + return new AttributeBuilder($interfaceParameter->getName(), $attribute->newInstance(), $ownerClassName, $ownerMethodName); + } + } + } + + foreach ((new ReflectionClass($ownerClassName))->getAttributes() as $attribute) { + if (Type::object($attribute->getName())->equals($parameterType)) { + return new AttributeBuilder($interfaceParameter->getName(), $attribute->newInstance(), $ownerClassName, null); + } + } + + return null; + } + private static function defaultConverterBuilderFor(InterfaceParameter $interfaceParameter, bool $isFirstParameter): ?ParameterConverterBuilder { if ($isFirstParameter) { diff --git a/packages/Ecotone/tests/Messaging/Fixture/Handler/ClosureInAttribute/PolicyDrivenTokenService.php b/packages/Ecotone/tests/Messaging/Fixture/Handler/ClosureInAttribute/PolicyDrivenTokenService.php new file mode 100644 index 000000000..6126e921b --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/Handler/ClosureInAttribute/PolicyDrivenTokenService.php @@ -0,0 +1,33 @@ +casing === 'upper' ? strtoupper($token) : strtolower($token); + })] string $token, + ): void { + $this->tokens[] = $token; + } + + #[QueryHandler('policyToken.getTokens')] + public function getTokens(): array + { + return $this->tokens; + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/Handler/ClosureInAttribute/TokenPolicy.php b/packages/Ecotone/tests/Messaging/Fixture/Handler/ClosureInAttribute/TokenPolicy.php new file mode 100644 index 000000000..ba3eba5ab --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/Handler/ClosureInAttribute/TokenPolicy.php @@ -0,0 +1,18 @@ +assertSame(['order-lock'], $lockingInterceptor->getLockedResources()); } + public function test_closure_expression_receiving_attribute_declared_on_handler_using_dumped_container(): void + { + $cacheDirectory = sys_get_temp_dir() . '/ecotone_policy_driven_closure/' . uniqid('', true); + $configuration = ServiceConfiguration::createWithDefaults() + ->withCacheDirectoryPath($cacheDirectory) + ->withSkippedModulePackageNames(ModulePackageList::allPackages()); + $tokenService = new PolicyDrivenTokenService(); + $availableServices = [PolicyDrivenTokenService::class => $tokenService]; + + $messagingSystem = EcotoneLite::bootstrap( + [PolicyDrivenTokenService::class], + $availableServices, + $configuration, + useCachedVersion: true, + licenceKey: LicenceTesting::VALID_LICENCE, + ); + $messagingSystem->getCommandBus()->sendWithRouting('policyToken.store', metadata: ['token' => 'coffee']); + + $warmBootedMessagingSystem = EcotoneLite::bootstrap( + [PolicyDrivenTokenService::class], + $availableServices, + $configuration, + useCachedVersion: true, + licenceKey: LicenceTesting::VALID_LICENCE, + ); + $warmBootedMessagingSystem->getCommandBus()->sendWithRouting('policyToken.store', metadata: ['token' => 'tea']); + + $this->assertSame( + ['COFFEE', 'TEA'], + $tokenService->getTokens(), + 'Attribute declared on handler must be injected into closure expression on both cold and warm dumped container' + ); + } + public function test_intercepting_handler_with_attribute_containing_closure_using_dumped_container(): void { $cacheDirectory = sys_get_temp_dir() . '/ecotone_closure_in_attribute/' . uniqid('', true);