diff --git a/src/Core/AGENTS.md b/src/Core/AGENTS.md index 81ccc296..969c4a64 100644 --- a/src/Core/AGENTS.md +++ b/src/Core/AGENTS.md @@ -24,4 +24,4 @@ src/Bridge/Doctrine (ORM 3.6+, persistence 4.1+ ClassLocator; no metadata surgery needed under the trait engine): - WovenEntityClassLocator — ClassLocator for mapping drivers: parses class names (parser-reflection ReflectionFile, never includes files) and loads them via class_exists() so the autoloader serves the woven class. Doctrine's FileClassLocator require_once's sources: bypasses weaving / misses already-loaded woven entities. - MetadataLoadInterceptor — loadClassMetadata guard: woven entity (implements Go\Aop\Proxy) with a hooked (intercepted) mapped property while native lazy objects are off → InvalidConfigurationException pointing to enableNativeLazyObjects(true). -- Functional coverage: tests/Functional/DoctrineBridgeTest.php (fixture script tests/Fixtures/project/bin/doctrine-metadata.php, no DB connection needed). +- Functional coverage: tests/Functional/DoctrineBridgeTest.php (fixture scripts tests/Fixtures/project/bin/doctrine-*.php; the association one needs pdo_sqlite). diff --git a/tests/Fixtures/project/bin/doctrine-associations.php b/tests/Fixtures/project/bin/doctrine-associations.php new file mode 100644 index 00000000..34f81eb5 --- /dev/null +++ b/tests/Fixtures/project/bin/doctrine-associations.php @@ -0,0 +1,124 @@ + Executed SQL statements */ + public array $queries = []; + + public function log($level, string|\Stringable $message, array $context = []): void + { + if (is_string($context['sql'] ?? null)) { + $this->queries[] = $context['sql']; + } + } +}; + +$configuration = new Configuration(); +$configuration->setMetadataDriverImpl( + new AttributeDriver(WovenEntityClassLocator::createFromDirectories([__DIR__ . '/../src/Entity'])), +); +$configuration->enableNativeLazyObjects(true); +$configuration->setMiddlewares([new Middleware($logger)]); + +$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $configuration); +$entityManager = new EntityManager($connection, $configuration); +$entityManager->getEventManager()->addEventListener(Events::loadClassMetadata, new MetadataLoadInterceptor()); + +$sensorMetadata = $entityManager->getClassMetadata(Sensor::class); +$valueMetadata = $entityManager->getClassMetadata(SensorValue::class); +new SchemaTool($entityManager)->createSchema([$sensorMetadata, $valueMetadata]); + +ob_start(); +$sensor = new Sensor('thermometer'); +$sensor->addValue(20); +$sensor->addValue(25); +$entityManager->persist($sensor); +$entityManager->flush(); +$sensorId = $sensor->getId(); +$valueIds = array_map(static fn(SensorValue $value): ?int => $value->getId(), $sensor->getValues()->toArray()); +$entityManager->clear(); +$persistOutput = ob_get_clean(); + +// DQL join over the association, both sides hydrated at once +ob_start(); +$joined = $entityManager->createQuery( + 'SELECT s, v FROM ' . Sensor::class . ' s JOIN s.values v WHERE s.id = :id ORDER BY v.value ASC', +)->setParameter('id', $sensorId)->getResult(); +assert(is_array($joined) && count($joined) === 1 && $joined[0] instanceof Sensor); +$joinedSensor = $joined[0]; +$joinedValues = array_map(static fn(SensorValue $value): int => $value->getValue(), $joinedSensor->getValues()->toArray()); +$entityManager->clear(); +$joinOutput = ob_get_clean(); + +// Lazy loading of the many-to-one side: a native lazy ghost of the woven class +ob_start(); +$value = $entityManager->find(SensorValue::class, $valueIds[0]); +assert($value instanceof SensorValue); +$lazySensor = $value->getSensor(); +$lazySensorClass = $lazySensor::class; +$lazySensorUninitialized = $entityManager->isUninitializedObject($lazySensor); +$lazySensorName = $lazySensor->getName(); +$lazySensorCreatedAt = $lazySensor->getCreatedAt()->format('Y-m-d H:i:s'); +$lazySensorInitialized = !$entityManager->isUninitializedObject($lazySensor); +$entityManager->clear(); +$lazyOutput = ob_get_clean(); + +// Lazy loading of the collection side +$queriesBeforeCollection = count($logger->queries); +ob_start(); +$foundSensor = $entityManager->find(Sensor::class, $sensorId); +assert($foundSensor instanceof Sensor); +$collectionCount = $foundSensor->getValues()->count(); +$collectionOutput = ob_get_clean(); +$collectionLoadQueries = array_slice($logger->queries, $queriesBeforeCollection); + +echo json_encode([ + 'woven' => [ + Sensor::class => is_a(Sensor::class, Proxy::class, true), + SensorValue::class => is_a(SensorValue::class, Proxy::class, true), + ], + 'tables' => [$sensorMetadata->getTableName(), $valueMetadata->getTableName()], + 'targets' => [ + 'Sensor.values' => $sensorMetadata->getAssociationTargetClass('values'), + 'SensorValue.sensor' => $valueMetadata->getAssociationTargetClass('sensor'), + ], + 'mappedSuperclass' => [$sensorMetadata->isMappedSuperclass, $valueMetadata->isMappedSuperclass], + 'persistOutput' => $persistOutput, + 'joinedValues' => $joinedValues, + 'joinOutput' => $joinOutput, + 'lazySensor' => [ + 'class' => $lazySensorClass, + 'uninitialized' => $lazySensorUninitialized, + 'name' => $lazySensorName, + 'createdAt' => $lazySensorCreatedAt, + 'initialized' => $lazySensorInitialized, + ], + 'lazyOutput' => $lazyOutput, + 'collectionCount' => $collectionCount, + 'collectionOutput' => $collectionOutput, + 'collectionLoadQueries' => $collectionLoadQueries, + 'queries' => $logger->queries, +], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); diff --git a/tests/Fixtures/project/src/Aspect/EntityFieldAspect.php b/tests/Fixtures/project/src/Aspect/EntityFieldAspect.php index ee19d33c..d2935deb 100644 --- a/tests/Fixtures/project/src/Aspect/EntityFieldAspect.php +++ b/tests/Fixtures/project/src/Aspect/EntityFieldAspect.php @@ -24,4 +24,13 @@ public function beforePersistCallback(MethodInvocation $invocation): void { echo 'persist;'; } + + /** + * Every public method of the two associated entities, as in the pointcut reported in issue #327 + */ + #[Pointcut\Before("execution(public Go\Tests\TestProject\Entity\Sensor->*(*)) || execution(public Go\Tests\TestProject\Entity\SensorValue->*(*))")] + public function beforeSensorMethod(MethodInvocation $invocation): void + { + echo $invocation->getMethod()->getName() . ';'; + } } diff --git a/tests/Fixtures/project/src/Entity/Sensor.php b/tests/Fixtures/project/src/Entity/Sensor.php new file mode 100644 index 00000000..b25d463b --- /dev/null +++ b/tests/Fixtures/project/src/Entity/Sensor.php @@ -0,0 +1,62 @@ + */ + #[ORM\OneToMany(targetEntity: SensorValue::class, mappedBy: 'sensor', cascade: ['persist'])] + private Collection $values; + + public function __construct( + #[ORM\Column(type: 'string', length: 64)] + private string $name, + ) { + $this->values = new ArrayCollection(); + $this->createdAt = new DateTimeImmutable('2017-07-07 21:33:35'); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return Collection + */ + public function getValues(): Collection + { + return $this->values; + } + + public function addValue(int $value): SensorValue + { + $sensorValue = new SensorValue($this, $value); + $this->values->add($sensorValue); + + return $sensorValue; + } +} diff --git a/tests/Fixtures/project/src/Entity/SensorValue.php b/tests/Fixtures/project/src/Entity/SensorValue.php new file mode 100644 index 00000000..975186b4 --- /dev/null +++ b/tests/Fixtures/project/src/Entity/SensorValue.php @@ -0,0 +1,42 @@ +id; + } + + public function getSensor(): Sensor + { + return $this->sensor; + } + + public function getValue(): int + { + return $this->value; + } +} diff --git a/tests/Fixtures/project/src/Entity/TimestampableTrait.php b/tests/Fixtures/project/src/Entity/TimestampableTrait.php new file mode 100644 index 00000000..441c28cc --- /dev/null +++ b/tests/Fixtures/project/src/Entity/TimestampableTrait.php @@ -0,0 +1,21 @@ +createdAt; + } +} diff --git a/tests/Functional/DoctrineBridgeTest.php b/tests/Functional/DoctrineBridgeTest.php index 11c49ba5..8193e8cd 100644 --- a/tests/Functional/DoctrineBridgeTest.php +++ b/tests/Functional/DoctrineBridgeTest.php @@ -12,13 +12,15 @@ namespace Go\Functional; +use Go\Tests\TestProject\Entity\Sensor; +use Go\Tests\TestProject\Entity\SensorValue; use Go\Tests\TestProject\Entity\WovenEntity; use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; /** * Doctrine ORM sees a woven entity exactly once, with every mapping, when entities are discovered - * through WovenEntityClassLocator (issue #671) + * through WovenEntityClassLocator (issue #671), and maps associations between woven entities (issue #327) */ class DoctrineBridgeTest extends BaseFunctionalTestCase { @@ -31,29 +33,21 @@ public function testDoctrineMapsWovenEntity(): void 'Go\\Tests\\TestProject\\Aspect\\EntityFieldAspect->beforeNameAccess', ); - $phpExecutable = (new PhpExecutableFinder())->find(); - $this->assertIsString($phpExecutable); - $process = new Process( - [$phpExecutable, __DIR__ . '/../Fixtures/project/bin/doctrine-metadata.php'], - null, - ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], - ); - $process->run(); - $this->assertTrue($process->isSuccessful(), $process->getOutput() . $process->getErrorOutput()); - - $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); - $this->assertIsArray($result); + $result = $this->runFixtureScript('doctrine-metadata.php'); - // Discovered through the autoloader, so the woven class is mapped (not the original source) - $this->assertSame([WovenEntity::class], $result['entities']); + // Discovered through the autoloader, so the woven classes are mapped (not the original sources) + $this->assertEqualsCanonicalizing([Sensor::class, SensorValue::class, WovenEntity::class], $result['entities']); $this->assertTrue($result['woven']); $this->assertEqualsCanonicalizing(['id', 'name', 'counter'], $result['fields']); $this->assertSame(['prePersist' => ['beforePersist']], $result['callbacks']); $this->assertSame('woven_entity', $result['table']); $this->assertIsArray($result['schema']); - $this->assertCount(1, $result['schema']); - $createTable = $result['schema'][0]; - $this->assertIsString($createTable); + $createTables = array_values(array_filter( + $result['schema'], + static fn(mixed $sql): bool => is_string($sql) && str_starts_with($sql, 'CREATE TABLE woven_entity '), + )); + $this->assertCount(1, $createTables); + $createTable = $createTables[0]; $this->assertStringContainsString('name VARCHAR(64) NOT NULL', $createTable); $this->assertStringContainsString('counter INTEGER NOT NULL', $createTable); @@ -64,6 +58,103 @@ public function testDoctrineMapsWovenEntity(): void $this->assertSame(3, $result['counter']); } + /** + * Under the 3.x extend-based engine the mapping attributes ended up on the renamed parent class + * (`Entity__AopProxied`): the table name got the suffix, every association pointed at the parent, + * joins were built with mismatched aliases and lazy loading was broken (issue #327). The trait-based + * engine keeps the mapping on the woven class itself, so a bidirectional association between two + * woven entities works as it does without weaving. Columns mapped in a trait the entity uses (the + * Gedmo Timestampable case the 3.x metadata listener special-cased) are mapped on the woven class too. + */ + public function testDoctrineMapsAssociationsBetweenWovenEntities(): void + { + if (!extension_loaded('pdo_sqlite')) { + $this->markTestSkipped('The pdo_sqlite extension is required to run queries against SQLite'); + } + $this->assertClassIsWoven(Sensor::class); + $this->assertClassIsWoven(SensorValue::class); + + $result = $this->runFixtureScript('doctrine-associations.php'); + + $this->assertSame([Sensor::class => true, SensorValue::class => true], $result['woven']); + $this->assertSame(['sensor', 'sensor_value'], $result['tables']); + $this->assertSame( + ['Sensor.values' => SensorValue::class, 'SensorValue.sensor' => Sensor::class], + $result['targets'], + ); + $this->assertSame([false, false], $result['mappedSuperclass']); + + // Entity methods called by user code are advised, including the constructors + $this->assertSame('__construct;addValue;__construct;addValue;__construct;getId;getValues;getId;getId;', $result['persistOutput']); + + // The DQL join hydrates both sides without running advices; reading the result does + $this->assertSame([20, 25], $result['joinedValues']); + $this->assertSame('getValues;getValue;getValue;', $result['joinOutput']); + + // The many-to-one side is a native lazy object of the woven class, loaded on first access; + // the column mapped in TimestampableTrait is hydrated and its accessor advised like the others + $this->assertSame( + [ + 'class' => Sensor::class, + 'uninitialized' => true, + 'name' => 'thermometer', + 'createdAt' => '2017-07-07 21:33:35', + 'initialized' => true, + ], + $result['lazySensor'], + ); + $this->assertSame('getSensor;getName;getCreatedAt;', $result['lazyOutput']); + + // The collection is loaded lazily with one query over the join column + $this->assertSame(2, $result['collectionCount']); + $this->assertSame('getValues;', $result['collectionOutput']); + $this->assertIsArray($result['collectionLoadQueries']); + $this->assertCount(2, $result['collectionLoadQueries']); + $this->assertSame( + 'SELECT t0.id AS id_1, t0.value AS value_2, t0.sensor_id AS sensor_id_3 FROM sensor_value t0 WHERE t0.sensor_id = ?', + $result['collectionLoadQueries'][1], + ); + + // Every SQL statement references the tables of the woven classes with consistent aliases + $this->assertIsArray($result['queries']); + $joinQuery = array_values(array_filter($result['queries'], static fn(mixed $sql): bool => is_string($sql) && str_contains($sql, 'INNER JOIN'))); + $this->assertCount(1, $joinQuery); + $this->assertSame( + 'SELECT s0_.id AS id_0, s0_.name AS name_1, s0_.createdAt AS createdAt_2,' + . ' s1_.id AS id_3, s1_.value AS value_4, s1_.sensor_id AS sensor_id_5' + . ' FROM sensor s0_ INNER JOIN sensor_value s1_ ON s0_.id = s1_.sensor_id WHERE s0_.id = ? ORDER BY s1_.value ASC', + $joinQuery[0], + ); + foreach ($result['queries'] as $sql) { + $this->assertIsString($sql); + $this->assertStringNotContainsString('aop', strtolower($sql)); + } + } + + /** + * Runs a script of the fixture project in a subprocess and decodes its JSON output + * + * @return array + */ + private function runFixtureScript(string $scriptName): array + { + $phpExecutable = (new PhpExecutableFinder())->find(); + $this->assertIsString($phpExecutable); + $process = new Process( + [$phpExecutable, __DIR__ . '/../Fixtures/project/bin/' . $scriptName], + null, + ['GO_AOP_CONFIGURATION' => $this->getConfigurationName()], + ); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput() . $process->getErrorOutput()); + + $result = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + $this->assertIsArray($result); + + /** @var array $result */ + return $result; + } + protected function getConfigurationName(): string { return 'default';