diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 39f51773..ecb12747 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -254,11 +254,6 @@
-
- getRole() !== null && $entity !== null]]>
-
- getRole() !== null && $entity !== null && isset($parent))]]>
-
diff --git a/src/Configurator.php b/src/Configurator.php
index dc29b8a1..f7c54c2f 100644
--- a/src/Configurator.php
+++ b/src/Configurator.php
@@ -9,6 +9,7 @@
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\ForeignKey;
use Cycle\Annotated\Annotation\GeneratedValue;
+use Cycle\Annotated\Annotation\Inheritance;
use Cycle\Annotated\Annotation\Relation as RelationAnnotation;
use Cycle\Annotated\Exception\AnnotationException;
use Cycle\Annotated\Exception\AnnotationRequiredArgumentsException;
@@ -124,14 +125,26 @@ public function initFields(EntitySchema $entity, \ReflectionClass $class, string
}
$field = $this->initField($property->getName(), $column, $class, $columnPrefix);
- $field->setEntityClass($property->getDeclaringClass()->getName());
+ $field->setEntityClass($this->findOwningEntity($class, $property->getDeclaringClass())->getName());
$entity->getFields()->set($property->getName(), $field);
}
}
public function initRelations(EntitySchema $entity, \ReflectionClass $class): void
{
+ // Only STI/JTI children must skip relations declared by parent entities — for them, the parent table already
+ // owns those relations. Entities that merely extend another entity physically (separate table) must keep them.
+ $isInheritanceChild = $this->reader->firstClassMetadata($class, Inheritance::class) !== null;
+
foreach ($class->getProperties() as $property) {
+ // ignore properties declared by parent entities
+ // otherwise all the relation columns declared in parent would be duplicated across all child tables in JTI
+ if ($isInheritanceChild
+ && $this->findOwningEntity($class, $property->getDeclaringClass())->getName() !== $class->getName()
+ ) {
+ continue;
+ }
+
$metadata = $this->getPropertyMetadata($property, RelationAnnotation\RelationInterface::class);
foreach ($metadata as $meta) {
@@ -425,4 +438,37 @@ private function isOnInsertGeneratedField(Field $field): bool
default => $field->isPrimary(),
};
}
+
+ /**
+ * Function to find an owning entity class in the inheritance hierarchy.
+ *
+ * Entity classes may extend a base class and this function is needed route the properties from declaring class to the entity class.
+ * The function stops only when the declaring class is truly found, it does not naively stop on first entity.
+ * This behaviour makes it also functional in cases of Joined Table Inheritance on theoretically any number of nesting levels.
+ */
+ private function findOwningEntity(\ReflectionClass $currentClass, \ReflectionClass $declaringClass): \ReflectionClass
+ {
+ // latest found entityClass before declaringClass
+ $latestEntityClass = $currentClass;
+
+ do {
+ // we found declaringClass in the hierarchy
+ // in most cases the execution will stop here in first loop
+ if ($currentClass->getName() === $declaringClass->getName()) {
+ return $latestEntityClass;
+ }
+
+ $currentClass = $currentClass->getParentClass();
+
+ // not possible to happen for logical reasons, but defensively check anyway
+ if (!$currentClass instanceof \ReflectionClass) {
+ return $latestEntityClass;
+ }
+
+ // if a currentClass in hierarchy is an entity on its own, the property belongs to that entity
+ if (\count($currentClass->getAttributes(Entity::class)) > 0) {
+ $latestEntityClass = $currentClass;
+ }
+ } while (true); // the inheritance hierarchy cannot be infinite
+ }
}
diff --git a/src/TableInheritance.php b/src/TableInheritance.php
index 0c6f48e5..178f1152 100644
--- a/src/TableInheritance.php
+++ b/src/TableInheritance.php
@@ -66,7 +66,11 @@ public function run(Registry $registry): Registry
// All child should be presented in a schema as separated entity
// Every child will be handled according its table inheritance type
- \assert($child->getRole() !== null && $entity !== null && isset($parent));
+ // todo should $parent be not null?
+ // \assert(isset($parent));
+
+ \assert($child->getRole() !== null);
+
if (!$registry->hasEntity($child->getRole())) {
$registry->register($child);
diff --git a/tests/Annotated/Fixtures/Fixtures16/Executive.php b/tests/Annotated/Fixtures/Fixtures16/Executive.php
index 4e9f4f97..cbbd1804 100644
--- a/tests/Annotated/Fixtures/Fixtures16/Executive.php
+++ b/tests/Annotated/Fixtures/Fixtures16/Executive.php
@@ -7,6 +7,7 @@
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Inheritance\JoinedTable as InheritanceJoinedTable;
+use Cycle\Annotated\Annotation\Relation\HasOne;
/**
* @Entity
@@ -21,4 +22,12 @@ class Executive extends ExecutiveProxy
/** @Column(type="int") */
#[Column(type: 'int')]
public int $bonus;
+
+ /** @Column(type="int", nullable=true, typecast="int") */
+ #[Column(type: 'int', nullable: true, typecast: 'int')]
+ public ?int $added_tool_id;
+
+ /** @HasOne(target=Tool::class, innerKey="added_tool_id", outerKey="added_tool_id", nullable=true, fkCreate=false) */
+ #[HasOne(target: Tool::class, innerKey: 'added_tool_id', outerKey: 'added_tool_id', nullable: true, fkCreate: false)]
+ public Tool $addedTool;
}
diff --git a/tests/Annotated/Fixtures/Fixtures16/Executive2.php b/tests/Annotated/Fixtures/Fixtures16/Executive2.php
new file mode 100644
index 00000000..6512ef6a
--- /dev/null
+++ b/tests/Annotated/Fixtures/Fixtures16/Executive2.php
@@ -0,0 +1,16 @@
+assertSame('secret', $loadedExecutive->hidden);
$this->assertSame(15000, $loadedExecutive->bonus);
$this->assertSame('executive', $loadedExecutive->getType());
- $this->assertNull($loadedExecutive->proxyFieldWithAnnotation);
+ $this->assertSame('value', $loadedExecutive->proxyFieldWithAnnotation);
}
#[DataProvider('allReadersProvider')]
diff --git a/tests/Annotated/Functional/Driver/Common/InheritanceTestCase.php b/tests/Annotated/Functional/Driver/Common/InheritanceTestCase.php
index 8c257471..27889034 100644
--- a/tests/Annotated/Functional/Driver/Common/InheritanceTestCase.php
+++ b/tests/Annotated/Functional/Driver/Common/InheritanceTestCase.php
@@ -16,6 +16,24 @@
use Cycle\Annotated\Tests\Fixtures\Fixtures16\Employee;
use Cycle\Annotated\Tests\Fixtures\Fixtures16\Executive;
use Cycle\Annotated\Tests\Fixtures\Fixtures16\Person;
+use Cycle\Annotated\Tests\Fixtures\Fixtures16\Tool;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\BtJoined;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\BtParent;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\BtTarget;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\JgLeaf;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\JgMid;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\JgParent;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\JgTarget;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\SoParent;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\SoSeparate;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\SoSti;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\SoTarget;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\StChild;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\StParent;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\StTarget;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\TrJti;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\TrParent;
+use Cycle\Annotated\Tests\Fixtures\Fixtures27\TrTarget;
use Cycle\ORM\SchemaInterface;
use Cycle\Schema\Compiler;
use Cycle\Schema\Generator\GenerateRelations;
@@ -69,6 +87,9 @@ public function testTableInheritance(ReaderInterface $reader): void
// Ceo - Single table inheritance {value: ceo}
// Beaver - Separate table
+ // Tool
+ $this->assertArrayHasKey('tool', $schema);
+
// Person
$this->assertCount(3, $schema['person'][SchemaInterface::CHILDREN]);
$this->assertEquals([
@@ -86,38 +107,56 @@ public function testTableInheritance(ReaderInterface $reader): void
// 'bonus' => 'bonus', // JTI
'preferences' => 'preferences',
'stocks' => 'stocks',
+ 'tool_id' => 'tool_id',
// 'teethAmount' => 'teeth_amount', // Not child
], $schema['person'][SchemaInterface::COLUMNS]);
$this->assertEmpty($schema['person'][SchemaInterface::PARENT] ?? null);
$this->assertEmpty($schema['person'][SchemaInterface::PARENT_KEY] ?? null);
$this->assertSame('people', $schema['person'][SchemaInterface::TABLE]);
+ $this->assertCount(1, $schema['person'][SchemaInterface::RELATIONS]);
// Employee
$this->assertArrayHasKey('employee', $schema);
$this->assertCount(1, $schema['employee']);
$this->assertSame(Employee::class, $schema['employee'][SchemaInterface::ENTITY]);
$this->assertNull($schema['employee'][SchemaInterface::TABLE] ?? null);
+ $this->assertCount(0, $schema['employee'][SchemaInterface::RELATIONS] ?? []);
// Customer
$this->assertArrayHasKey('customer', $schema);
$this->assertCount(1, $schema['customer']);
$this->assertSame(Customer::class, $schema['customer'][SchemaInterface::ENTITY]);
$this->assertNull($schema['customer'][SchemaInterface::TABLE] ?? null);
+ $this->assertCount(0, $schema['customer'][SchemaInterface::RELATIONS] ?? []);
// Executive
$this->assertSame('employee', $schema['executive'][SchemaInterface::PARENT]);
$this->assertSame('foo_id', $schema['executive'][SchemaInterface::PARENT_KEY]);
$this->assertSame('executives', $schema['executive'][SchemaInterface::TABLE]);
$this->assertEquals(
- ['bonus' => 'bonus', 'foo_id' => 'id', 'hidden' => 'hidden'],
+ [
+ 'bonus' => 'bonus',
+ 'proxyFieldWithAnnotation' => 'proxy',
+ 'foo_id' => 'id',
+ 'hidden' => 'hidden',
+ 'added_tool_id' => 'added_tool_id',
+ ],
$schema['executive'][SchemaInterface::COLUMNS],
);
+ $this->assertCount(1, $schema['executive'][SchemaInterface::RELATIONS]);
+
+ // Executive2
+ $this->assertSame('executive', $schema['executive2'][SchemaInterface::PARENT]);
+ $this->assertSame('foo_id', $schema['executive2'][SchemaInterface::PARENT_KEY]);
+ $this->assertEquals(['foo_id' => 'id'], $schema['executive2'][SchemaInterface::COLUMNS]);
+ $this->assertCount(0, $schema['executive2'][SchemaInterface::RELATIONS]);
// Ceo
$this->assertArrayHasKey('ceo', $schema);
$this->assertCount(1, $schema['ceo']);
$this->assertSame(Ceo::class, $schema['ceo'][SchemaInterface::ENTITY]);
$this->assertNull($schema['ceo'][SchemaInterface::TABLE] ?? null);
+ $this->assertCount(0, $schema['ceo'][SchemaInterface::RELATIONS] ?? []);
// Beaver
$this->assertEmpty($schema['beaver'][SchemaInterface::DISCRIMINATOR] ?? null);
@@ -131,7 +170,9 @@ public function testTableInheritance(ReaderInterface $reader): void
'name' => 'name',
'type' => 'type',
'hidden' => 'hidden',
+ 'tool_id' => 'tool_id',
], $schema['beaver'][SchemaInterface::COLUMNS]);
+ $this->assertCount(1, $schema['beaver'][SchemaInterface::RELATIONS] ?? []);
}
public function testTableInheritanceWithIncorrectClassesOrder(): void
@@ -145,6 +186,7 @@ public function testTableInheritanceWithIncorrectClassesOrder(): void
new \ReflectionClass(Employee::class),
new \ReflectionClass(Executive::class),
new \ReflectionClass(Person::class),
+ new \ReflectionClass(Tool::class),
]);
$schema = (new Compiler())->compile($r, [
@@ -165,4 +207,102 @@ public function testTableInheritanceWithIncorrectClassesOrder(): void
$this->assertNull($schema['employee'][SchemaInterface::TABLE] ?? null);
$this->assertSame('people', $schema['person'][SchemaInterface::TABLE]);
}
+
+ public function testJtiChildDoesNotInheritBelongsToColumn(): void
+ {
+ $schema = $this->compileWithClasses([BtTarget::class, BtParent::class, BtJoined::class]);
+
+ // BtParent owns the BelongsTo + its FK column
+ $this->assertArrayHasKey('target_id', $schema['btParent'][SchemaInterface::COLUMNS]);
+ $this->assertCount(1, $schema['btParent'][SchemaInterface::RELATIONS]);
+
+ // JTI child must not duplicate the BelongsTo FK column nor inherit the relation
+ $this->assertArrayNotHasKey('target_id', $schema['btJoined'][SchemaInterface::COLUMNS]);
+ $this->assertCount(0, $schema['btJoined'][SchemaInterface::RELATIONS] ?? []);
+ }
+
+ public function testStiChildOwnRelationMergesIntoParent(): void
+ {
+ $schema = $this->compileWithClasses([StTarget::class, StParent::class, StChild::class]);
+
+ // Relation declared on the STI child must end up on the parent after merge
+ $this->assertArrayHasKey('target_id', $schema['stParent'][SchemaInterface::COLUMNS]);
+ $this->assertCount(1, $schema['stParent'][SchemaInterface::RELATIONS]);
+ $this->assertArrayHasKey('target', $schema['stParent'][SchemaInterface::RELATIONS]);
+ }
+
+ public function testSeparateEntityExtendingStiChildKeepsInheritedRelation(): void
+ {
+ $schema = $this->compileWithClasses([
+ SoTarget::class,
+ SoParent::class,
+ SoSti::class,
+ SoSeparate::class,
+ ]);
+
+ // Separate entity (no Inheritance attr) extending an STI child must keep ancestor relations
+ $this->assertEmpty($schema['soSeparate'][SchemaInterface::PARENT] ?? null);
+ $this->assertNotNull($schema['soSeparate'][SchemaInterface::TABLE] ?? null);
+ $this->assertArrayHasKey('target_id', $schema['soSeparate'][SchemaInterface::COLUMNS]);
+ $this->assertArrayHasKey('extra', $schema['soSeparate'][SchemaInterface::COLUMNS]);
+ $this->assertCount(1, $schema['soSeparate'][SchemaInterface::RELATIONS] ?? []);
+ $this->assertArrayHasKey('target', $schema['soSeparate'][SchemaInterface::RELATIONS]);
+ }
+
+ public function testJtiChildDoesNotInheritRelationFromTrait(): void
+ {
+ $schema = $this->compileWithClasses([TrTarget::class, TrParent::class, TrJti::class]);
+
+ // Trait-declared relation belongs to the entity that uses the trait
+ $this->assertArrayHasKey('target_id', $schema['trParent'][SchemaInterface::COLUMNS]);
+ $this->assertCount(1, $schema['trParent'][SchemaInterface::RELATIONS]);
+
+ // JTI child must not duplicate the trait's relation columns
+ $this->assertArrayNotHasKey('target_id', $schema['trJti'][SchemaInterface::COLUMNS]);
+ $this->assertCount(0, $schema['trJti'][SchemaInterface::RELATIONS] ?? []);
+ }
+
+ public function testJtiGrandchildDoesNotInheritMidLevelRelation(): void
+ {
+ $schema = $this->compileWithClasses([
+ JgTarget::class,
+ JgParent::class,
+ JgMid::class,
+ JgLeaf::class,
+ ]);
+
+ // Relation declared on the JTI middle entity stays on the middle table
+ $this->assertArrayHasKey('target_id', $schema['jgMid'][SchemaInterface::COLUMNS]);
+ $this->assertCount(1, $schema['jgMid'][SchemaInterface::RELATIONS]);
+
+ // JTI grandchild must not duplicate the middle entity's relation column
+ $this->assertArrayNotHasKey('target_id', $schema['jgLeaf'][SchemaInterface::COLUMNS]);
+ $this->assertCount(0, $schema['jgLeaf'][SchemaInterface::RELATIONS] ?? []);
+ }
+
+ /**
+ * @param list $classes
+ */
+ private function compileWithClasses(array $classes): array
+ {
+ $reader = new AttributeReader();
+ $locator = $this->createMock(ClassesInterface::class);
+ $locator->method('getClasses')->willReturn(
+ \array_map(static fn(string $c) => new \ReflectionClass($c), $classes),
+ );
+
+ return (new Compiler())->compile(new Registry($this->dbal), [
+ new Embeddings(new TokenizerEmbeddingLocator($locator, $reader), $reader),
+ new Entities(new TokenizerEntityLocator($locator, $reader), $reader),
+ new TableInheritance($reader),
+ new ResetTables(),
+ new MergeColumns($reader),
+ new GenerateRelations(),
+ new RenderTables(),
+ new RenderRelations(),
+ new MergeIndexes($reader),
+ new SyncTables(),
+ new GenerateTypecast(),
+ ]);
+ }
}