diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php index 63c36474a..701aee551 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php @@ -140,6 +140,11 @@ class ClassMetadataFactory extends AbstractClassMetadataFactory $this->completeIdGeneratorMapping($class); } + foreach ($class->embeddedClasses as $property => $embeddableClass) { + $embeddableMetadata = $this->getMetadataFor($embeddableClass); + $class->inlineEmbeddable($property, $embeddableMetadata); + } + if ($parent && $parent->isInheritanceTypeSingleTable()) { $class->setPrimaryTable($parent->table); } diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php index 9b3b29492..510d46672 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php @@ -246,6 +246,13 @@ class ClassMetadataInfo implements ClassMetadata */ public $isMappedSuperclass = false; + /** + * READ-ONLY: Wheather this class describes the mapping of an embeddable class. + * + * @var boolean + */ + public $isEmbeddedClass = false; + /** * READ-ONLY: The names of the parent classes (ancestors). * @@ -260,6 +267,13 @@ class ClassMetadataInfo implements ClassMetadata */ public $subClasses = array(); + /** + * READ-ONLY: The names of all embedded classes based on properties. + * + * @var array + */ + public $embeddedClasses = array(); + /** * READ-ONLY: The named queries allowed to be called directly from Repository. * @@ -884,6 +898,15 @@ class ClassMetadataInfo implements ClassMetadata $this->reflClass = $reflService->getClass($this->name); foreach ($this->fieldMappings as $field => $mapping) { + if (isset($mapping['declaredField'])) { + $this->reflFields[$field] = new ReflectionEmbeddedProperty( + $reflService->getAccessibleProperty($this->name, $mapping['declaredField']), + $reflService->getAccessibleProperty($this->embeddedClasses[$mapping['declaredField']], $mapping['originalField']), + $this->embeddedClasses[$mapping['declaredField']] + ); + continue; + } + $this->reflFields[$field] = isset($mapping['declared']) ? $reflService->getAccessibleProperty($mapping['declared'], $field) : $reflService->getAccessibleProperty($this->name, $field); @@ -925,8 +948,12 @@ class ClassMetadataInfo implements ClassMetadata */ public function validateIdentifier() { + if ($this->isMappedSuperclass || $this->isEmbeddedClass) { + return; + } + // Verify & complete identifier mapping - if ( ! $this->identifier && ! $this->isMappedSuperclass) { + if ( ! $this->identifier) { throw MappingException::identifierRequired($this->name); } @@ -2162,9 +2189,8 @@ class ClassMetadataInfo implements ClassMetadata public function mapField(array $mapping) { $this->_validateAndCompleteFieldMapping($mapping); - if (isset($this->fieldMappings[$mapping['fieldName']]) || isset($this->associationMappings[$mapping['fieldName']])) { - throw MappingException::duplicateFieldMapping($this->name, $mapping['fieldName']); - } + $this->assertFieldNotMapped($mapping['fieldName']); + $this->fieldMappings[$mapping['fieldName']] = $mapping; } @@ -2412,9 +2438,7 @@ class ClassMetadataInfo implements ClassMetadata { $sourceFieldName = $assocMapping['fieldName']; - if (isset($this->fieldMappings[$sourceFieldName]) || isset($this->associationMappings[$sourceFieldName])) { - throw MappingException::duplicateFieldMapping($this->name, $sourceFieldName); - } + $this->assertFieldNotMapped($sourceFieldName); $this->associationMappings[$sourceFieldName] = $assocMapping; } @@ -3044,4 +3068,49 @@ class ClassMetadataInfo implements ClassMetadata return null; } + + /** + * Map Embedded Class + * + * @array $mapping + * @return void + */ + public function mapEmbedded(array $mapping) + { + $this->assertFieldNotMapped($mapping['fieldName']); + + $this->embeddedClasses[$mapping['fieldName']] = $this->fullyQualifiedClassName($mapping['class']); + } + + /** + * Inline the embeddable class + * + * @param string $property + * @param ClassMetadataInfo $embeddable + */ + public function inlineEmbeddable($property, ClassMetadataInfo $embeddable) + { + foreach ($embeddable->fieldMappings as $fieldMapping) { + $fieldMapping['declaredField'] = $property; + $fieldMapping['originalField'] = $fieldMapping['fieldName']; + $fieldMapping['fieldName'] = $property . "." . $fieldMapping['fieldName']; // TODO: Change DQL parser to accept this dot notation + $fieldMapping['columnName'] = $property . "_" . $fieldMapping['columnName']; // TODO: Use naming strategy + + $this->mapField($fieldMapping); + } + } + + /** + * @param string $fieldName + * @throws MappingException + */ + private function assertFieldNotMapped($fieldName) + { + if (isset($this->fieldMappings[$fieldName]) || + isset($this->associationMappings[$fieldName]) || + isset($this->embeddedClasses[$fieldName])) { + + throw MappingException::duplicateFieldMapping($this->name, $fieldName); + } + } } diff --git a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php index f9aaddba7..b2437de23 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php @@ -85,6 +85,8 @@ class AnnotationDriver extends AbstractAnnotationDriver $mappedSuperclassAnnot = $classAnnotations['Doctrine\ORM\Mapping\MappedSuperclass']; $metadata->setCustomRepositoryClass($mappedSuperclassAnnot->repositoryClass); $metadata->isMappedSuperclass = true; + } else if (isset($classAnnotations['Doctrine\ORM\Mapping\Embeddable'])) { + $metadata->isEmbeddedClass = true; } else { throw MappingException::classIsNotAValidEntityOrMappedSuperClass($className); } @@ -364,6 +366,9 @@ class AnnotationDriver extends AbstractAnnotationDriver } $metadata->mapManyToMany($mapping); + } else if ($embeddedAnnot = $this->reader->getPropertyAnnotation($property, 'Doctrine\ORM\Mapping\Embedded')) { + $mapping['class'] = $embeddedAnnot->class; + $metadata->mapEmbedded($mapping); } } diff --git a/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php b/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php index 14abadb9e..3ba65f772 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php +++ b/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php @@ -19,6 +19,8 @@ require_once __DIR__.'/../Annotation.php'; require_once __DIR__.'/../Entity.php'; +require_once __DIR__.'/../Embeddable.php'; +require_once __DIR__.'/../Embedded.php'; require_once __DIR__.'/../MappedSuperclass.php'; require_once __DIR__.'/../InheritanceType.php'; require_once __DIR__.'/../DiscriminatorColumn.php'; @@ -64,4 +66,4 @@ require_once __DIR__.'/../AssociationOverride.php'; require_once __DIR__.'/../AssociationOverrides.php'; require_once __DIR__.'/../AttributeOverride.php'; require_once __DIR__.'/../AttributeOverrides.php'; -require_once __DIR__.'/../EntityListeners.php'; \ No newline at end of file +require_once __DIR__.'/../EntityListeners.php'; diff --git a/lib/Doctrine/ORM/Mapping/Embeddable.php b/lib/Doctrine/ORM/Mapping/Embeddable.php new file mode 100644 index 000000000..34cdcd1f1 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/Embeddable.php @@ -0,0 +1,28 @@ +. + */ + +namespace Doctrine\ORM\Mapping; + +/** + * @Annotation + * @Target("PROPERTY") + */ +final class Embeddable implements Annotation +{ +} diff --git a/lib/Doctrine/ORM/Mapping/Embedded.php b/lib/Doctrine/ORM/Mapping/Embedded.php new file mode 100644 index 000000000..c3dcb0837 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/Embedded.php @@ -0,0 +1,32 @@ +. + */ + +namespace Doctrine\ORM\Mapping; + +/** + * @Annotation + * @Target("CLASS") + */ +final class Embedded implements Annotation +{ + /** + * @var string + */ + public $class; +} diff --git a/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php b/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php new file mode 100644 index 000000000..b68d7d818 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php @@ -0,0 +1,66 @@ +. + */ + +namespace Doctrine\ORM\Mapping; + +/** + * Acts as a proxy to a nested Property structure, making it look like + * just a single scalar property. + * + * This way value objects "just work" without UnitOfWork, Persisters or Hydrators + * needing any changes. + * + * TODO: Move this class into Common\Reflection + */ +class ReflectionEmbeddedProperty +{ + private $parentProperty; + private $childProperty; + private $class; + + public function __construct($parentProperty, $childProperty, $class) + { + $this->parentProperty = $parentProperty; + $this->childProperty = $childProperty; + $this->class = $class; + } + + public function getValue($object) + { + $embeddedObject = $this->parentProperty->getValue($object); + + if ($embeddedObject === null) { + return null; + } + + return $this->childProperty->getValue($embeddedObject); + } + + public function setValue($object, $value) + { + $embeddedObject = $this->parentProperty->getValue($object); + + if ($embeddedObject === null) { + $embeddedObject = new $this->class; // TODO + $this->parentProperty->setValue($object, $embeddedObject); + } + + $this->childProperty->setValue($embeddedObject, $value); + } +} diff --git a/lib/Doctrine/ORM/Tools/SchemaTool.php b/lib/Doctrine/ORM/Tools/SchemaTool.php index 21140f7a0..577fc88ff 100644 --- a/lib/Doctrine/ORM/Tools/SchemaTool.php +++ b/lib/Doctrine/ORM/Tools/SchemaTool.php @@ -126,6 +126,7 @@ class SchemaTool return ( isset($processedClasses[$class->name]) || $class->isMappedSuperclass || + $class->isEmbeddedClass || ($class->isInheritanceTypeSingleTable() && $class->name != $class->rootEntityName) ); } diff --git a/tests/Doctrine/Tests/ORM/Functional/ValueObjectsTest.php b/tests/Doctrine/Tests/ORM/Functional/ValueObjectsTest.php new file mode 100644 index 000000000..89a51a406 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/ValueObjectsTest.php @@ -0,0 +1,143 @@ +_schemaTool->createSchema(array( + $this->_em->getClassMetadata(__NAMESPACE__ . '\DDC93Person'), + $this->_em->getClassMetadata(__NAMESPACE__ . '\DDC93Address'), + )); + } catch(\Exception $e) { + } + } + + public function testCRUD() + { + $person = new DDC93Person(); + $person->name = "Tara"; + $person->address = new DDC93Address(); + $person->address->street = "United States of Tara Street"; + $person->address->zip = "12345"; + $person->address->city = "funkytown"; + + // 1. check saving value objects works + $this->_em->persist($person); + $this->_em->flush(); + + $this->_em->clear(); + + // 2. check loading value objects works + $person = $this->_em->find(DDC93Person::CLASSNAME, $person->id); + + $this->assertInstanceOf(DDC93Address::CLASSNAME, $person->address); + $this->assertEquals('United States of Tara Street', $person->address->street); + $this->assertEquals('12345', $person->address->zip); + $this->assertEquals('funkytown', $person->address->city); + + // 3. check changing value objects works + $person->address->street = "Street"; + $person->address->zip = "54321"; + $person->address->city = "another town"; + $this->_em->flush(); + + $this->_em->clear(); + + $person = $this->_em->find(DDC93Person::CLASSNAME, $person->id); + + $this->assertEquals('Street', $person->address->street); + $this->assertEquals('54321', $person->address->zip); + $this->assertEquals('another town', $person->address->city); + + // 4. check deleting works + $personId = $person->id;; + $this->_em->remove($person); + $this->_em->flush(); + + $this->assertNull($this->_em->find(DDC93Person::CLASSNAME, $personId)); + } + + public function testLoadDql() + { + for ($i = 0; $i < 3; $i++) { + $person = new DDC93Person(); + $person->name = "Donkey Kong$i"; + $person->address = new DDC93Address(); + $person->address->street = "Tree"; + $person->address->zip = "12345"; + $person->address->city = "funkytown"; + + $this->_em->persist($person); + } + + $this->_em->flush(); + $this->_em->clear(); + + $dql = "SELECT p FROM " . __NAMESPACE__ . "\DDC93Person p"; + $persons = $this->_em->createQuery($dql)->getResult(); + + $this->assertCount(3, $persons); + foreach ($persons as $person) { + $this->assertInstanceOf(DDC93Address::CLASSNAME, $person->address); + $this->assertEquals('Tree', $person->address->street); + $this->assertEquals('12345', $person->address->zip); + $this->assertEquals('funkytown', $person->address->city); + } + + $dql = "SELECT p FROM " . __NAMESPACE__ . "\DDC93Person p"; + $persons = $this->_em->createQuery($dql)->getArrayResult(); + + foreach ($persons as $person) { + $this->assertEquals('Tree', $person['address.street']); + $this->assertEquals('12345', $person['address.zip']); + $this->assertEquals('funkytown', $person['address.city']); + } + } +} + +/** + * @Entity + */ +class DDC93Person +{ + const CLASSNAME = __CLASS__; + + /** @Id @GeneratedValue @Column(type="integer") */ + public $id; + + /** @Column(type="string") */ + public $name; + + /** @Embedded(class="DDC93Address") */ + public $address; +} + +/** + * @Embeddable + */ +class DDC93Address +{ + const CLASSNAME = __CLASS__; + + /** + * @Column(type="string") + */ + public $street; + /** + * @Column(type="string") + */ + public $zip; + /** + * @Column(type="string") + */ + public $city; +} +