Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
124 changes: 124 additions & 0 deletions tests/Fixtures/project/bin/doctrine-associations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);

/**
* Boots the fixture project with the AOP kernel and runs two associated woven entities (bidirectional
* one-to-many / many-to-one, one column mapped in a trait) through Doctrine ORM against an in-memory SQLite
* database: schema, persist, a DQL join, lazy loading of the many-to-one side and of the collection. Prints
* what Doctrine sees and the SQL it executed as JSON (issue #327).
*/

use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Logging\Middleware;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
use Doctrine\ORM\Tools\SchemaTool;
use Go\Aop\Proxy;
use Go\Bridge\Doctrine\MetadataLoadInterceptor;
use Go\Bridge\Doctrine\WovenEntityClassLocator;
use Go\Tests\TestProject\Entity\Sensor;
use Go\Tests\TestProject\Entity\SensorValue;
use Psr\Log\AbstractLogger;

include __DIR__ . '/../web/index.php';

$logger = new class extends AbstractLogger {
/** @var list<string> 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);
9 changes: 9 additions & 0 deletions tests/Fixtures/project/src/Aspect/EntityFieldAspect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() . ';';
}
}
62 changes: 62 additions & 0 deletions tests/Fixtures/project/src/Entity/Sensor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);

namespace Go\Tests\TestProject\Entity;

use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
* Inverse side of a bidirectional one-to-many between two woven entities, with a column mapped in a trait (issue #327)
*/
#[ORM\Entity]
#[ORM\Table(name: 'sensor')]
class Sensor
{
use TimestampableTrait;

#[ORM\Id]
#[ORM\Column(type: 'integer')]
#[ORM\GeneratedValue]
private ?int $id = null;

/** @var Collection<int, SensorValue> */
#[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<int, SensorValue>
*/
public function getValues(): Collection
{
return $this->values;
}

public function addValue(int $value): SensorValue
{
$sensorValue = new SensorValue($this, $value);
$this->values->add($sensorValue);

return $sensorValue;
}
}
42 changes: 42 additions & 0 deletions tests/Fixtures/project/src/Entity/SensorValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);

namespace Go\Tests\TestProject\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* Owning side of a bidirectional many-to-one between two woven entities (issue #327)
*/
#[ORM\Entity]
#[ORM\Table(name: 'sensor_value')]
class SensorValue
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
#[ORM\GeneratedValue]
private ?int $id = null;

public function __construct(
#[ORM\ManyToOne(targetEntity: Sensor::class, inversedBy: 'values')]
#[ORM\JoinColumn(nullable: false)]
private Sensor $sensor,
#[ORM\Column(type: 'integer')]
private int $value,
) {}

public function getId(): ?int
{
return $this->id;
}

public function getSensor(): Sensor
{
return $this->sensor;
}

public function getValue(): int
{
return $this->value;
}
}
21 changes: 21 additions & 0 deletions tests/Fixtures/project/src/Entity/TimestampableTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);

namespace Go\Tests\TestProject\Entity;

use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

/**
* Mapped properties imported from a trait, like Gedmo's Timestampable (issue #327)
*/
trait TimestampableTrait
{
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;

public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
Loading
Loading