diff --git a/UPGRADE.md b/UPGRADE.md index ae8e12eded4..4eeba152169 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,10 @@ # Upgrade to 2.5 +## BC BREAK: NamingStrategy has a new method ``embeddedFieldToColumnName($propertyName, $embeddedColumnName)`` + +This method generates the column name for fields of embedded objects. If you implement your custom NamingStrategy, you +now also need to implement this new method. + ## Updates on entities scheduled for deletion are no longer processed In Doctrine 2.4, if you modified properties of an entity scheduled for deletion, UnitOfWork would diff --git a/docs/en/index.rst b/docs/en/index.rst index 2df33766bd7..4cb5225b12f 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -90,6 +90,7 @@ Tutorials * :doc:`Ordered associations ` * :doc:`Pagination ` * :doc:`Override Field/Association Mappings In Subclasses ` +* :doc:`Embeddables ` Cookbook -------- diff --git a/docs/en/toc.rst b/docs/en/toc.rst index 9d5553704d3..2393104e5e2 100644 --- a/docs/en/toc.rst +++ b/docs/en/toc.rst @@ -16,6 +16,7 @@ Tutorials tutorials/ordered-associations tutorials/override-field-association-mappings-in-subclasses tutorials/pagination.rst + tutorials/embeddables.rst Reference Guide --------------- diff --git a/docs/en/tutorials/embeddables.rst b/docs/en/tutorials/embeddables.rst new file mode 100644 index 00000000000..5ec207fa6a0 --- /dev/null +++ b/docs/en/tutorials/embeddables.rst @@ -0,0 +1,83 @@ +Separating Concerns using Embeddables +------------------------------------- + +Embeddables are classes which are not entities themself, but are embedded +in entities and can also be queried in DQL. You'll mostly want to use them +to reduce duplication or separating concerns. + +For the purposes of this tutorial, we will assume that you have a ``User`` +class in your application and you would like to store an address in +the ``User`` class. We will model the ``Address`` class as an embeddable +instead of simply adding the respective columns to the ``User`` class. + +.. configuration-block:: + + .. code-block:: php + + + + + + + + + + + + + + + .. code-block:: yaml + + User: + type: entity + embedded: + address: + class: Address + + Address: + type: embeddable + fields: + street: { type: string } + postalCode: { type: string } + city: { type: string } + country: { type: string } + +In terms of your database schema, Doctrine will automatically inline all +columns from the ``Address`` class into the table of the ``User`` class, +just as if you had declared them directly there. + +You can also use mapped fields of embedded classes in DQL queries, just +as if they were declared in the ``User`` class: + +.. code-block:: sql + + SELECT u FROM User u WHERE u.address.city = :myCity + diff --git a/doctrine-mapping.xsd b/doctrine-mapping.xsd index 75f6b8c00b1..b6728f0de7d 100644 --- a/doctrine-mapping.xsd +++ b/doctrine-mapping.xsd @@ -17,6 +17,7 @@ + @@ -180,6 +181,7 @@ + @@ -226,6 +228,16 @@ + + + + + + + + + + @@ -288,6 +300,12 @@ + + + + + + diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php index 3df3f2ea973..f8c3f7484ee 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataFactory.php @@ -96,6 +96,7 @@ protected function doLoadMetadata($class, $parent, $rootEntityFound, array $nonS $class->setIdGeneratorType($parent->generatorType); $this->addInheritedFields($class, $parent); $this->addInheritedRelations($class, $parent); + $this->addInheritedEmbeddedClasses($class, $parent); $class->setIdentifier($parent->identifier); $class->setVersioned($parent->isVersioned); $class->setVersionField($parent->versionField); @@ -140,6 +141,15 @@ protected function doLoadMetadata($class, $parent, $rootEntityFound, array $nonS $this->completeIdGeneratorMapping($class); } + foreach ($class->embeddedClasses as $property => $embeddableClass) { + if (isset($embeddableClass['inherited'])) { + continue; + } + + $embeddableMetadata = $this->getMetadataFor($embeddableClass['class']); + $class->inlineEmbeddable($property, $embeddableMetadata); + } + if ($parent && $parent->isInheritanceTypeSingleTable()) { $class->setPrimaryTable($parent->table); } @@ -342,6 +352,20 @@ private function addInheritedRelations(ClassMetadata $subClass, ClassMetadata $p } } + private function addInheritedEmbeddedClasses(ClassMetadata $subClass, ClassMetadata $parentClass) + { + foreach ($parentClass->embeddedClasses as $field => $embeddedClass) { + if ( ! isset($embeddedClass['inherited']) && ! $parentClass->isMappedSuperclass) { + $embeddedClass['inherited'] = $parentClass->name; + } + if ( ! isset($embeddedClass['declared'])) { + $embeddedClass['declared'] = $parentClass->name; + } + + $subClass->embeddedClasses[$field] = $embeddedClass; + } + } + /** * Adds inherited named queries to the subclass mapping. * diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php index 8a1a6587fd0..19958b69fd2 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php @@ -260,6 +260,13 @@ class ClassMetadataInfo implements ClassMetadata */ public $isMappedSuperclass = false; + /** + * READ-ONLY: Whether this class describes the mapping of an embeddable class. + * + * @var boolean + */ + public $isEmbeddedClass = false; + /** * READ-ONLY: The names of the parent classes (ancestors). * @@ -274,6 +281,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. * @@ -799,6 +813,7 @@ public function __sleep() 'columnNames', //TODO: Not really needed. Can use fieldMappings[$fieldName]['columnName'] 'fieldMappings', 'fieldNames', + 'embeddedClasses', 'identifier', 'isIdentifierComposite', // TODO: REMOVE 'name', @@ -907,6 +922,18 @@ public function wakeupReflection($reflService) $this->reflClass = $reflService->getClass($this->name); foreach ($this->fieldMappings as $field => $mapping) { + if (isset($mapping['declaredField'])) { + $declaringClass = isset($this->embeddedClasses[$mapping['declaredField']]['declared']) + ? $this->embeddedClasses[$mapping['declaredField']]['declared'] : $this->name; + + $this->reflFields[$field] = new ReflectionEmbeddedProperty( + $reflService->getAccessibleProperty($declaringClass, $mapping['declaredField']), + $reflService->getAccessibleProperty($this->embeddedClasses[$mapping['declaredField']]['class'], $mapping['originalField']), + $this->embeddedClasses[$mapping['declaredField']]['class'] + ); + continue; + } + $this->reflFields[$field] = isset($mapping['declared']) ? $reflService->getAccessibleProperty($mapping['declared'], $field) : $reflService->getAccessibleProperty($this->name, $field); @@ -948,8 +975,12 @@ public function initializeReflection($reflService) */ 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); } @@ -2150,6 +2181,11 @@ public function isInheritedAssociation($fieldName) return isset($this->associationMappings[$fieldName]['inherited']); } + public function isInheritedEmbeddedClass($fieldName) + { + return isset($this->embeddedClasses[$fieldName]['inherited']); + } + /** * Sets the name of the primary table the class is mapped to. * @@ -2229,9 +2265,8 @@ private function _isInheritanceType($type) 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; } @@ -2479,9 +2514,7 @@ protected function _storeAssociationMapping(array $assocMapping) { $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; } @@ -3116,4 +3149,55 @@ public function getMetadataValue($name) { return null; } + + /** + * Map Embedded Class + * + * @array $mapping + * @return void + */ + public function mapEmbedded(array $mapping) + { + $this->assertFieldNotMapped($mapping['fieldName']); + + $this->embeddedClasses[$mapping['fieldName']] = array( + 'class' => $this->fullyQualifiedClassName($mapping['class']), + 'columnPrefix' => $mapping['columnPrefix'], + ); + } + + /** + * 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']; + + $fieldMapping['columnName'] = ! empty($this->embeddedClasses[$property]['columnPrefix']) + ? $this->embeddedClasses[$property]['columnPrefix'] . $fieldMapping['columnName'] + : $this->namingStrategy->embeddedFieldToColumnName($property, $fieldMapping['columnName'], $this->reflClass->name, $embeddable->reflClass->name); + + $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/DefaultNamingStrategy.php b/lib/Doctrine/ORM/Mapping/DefaultNamingStrategy.php index 2433b4afa8a..06bc593be23 100644 --- a/lib/Doctrine/ORM/Mapping/DefaultNamingStrategy.php +++ b/lib/Doctrine/ORM/Mapping/DefaultNamingStrategy.php @@ -50,6 +50,14 @@ public function propertyToColumnName($propertyName, $className = null) return $propertyName; } + /** + * {@inheritdoc} + */ + public function embeddedFieldToColumnName($propertyName, $embeddedColumnName, $className = null, $embeddedClassName = null) + { + return $propertyName.'_'.$embeddedColumnName; + } + /** * {@inheritdoc} */ diff --git a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php index a0f99ad9bc7..6a178603fa8 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php @@ -85,6 +85,8 @@ public function loadMetadataForClass($className, ClassMetadata $metadata) $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); } @@ -251,7 +253,9 @@ public function loadMetadataForClass($className, ClassMetadata $metadata) || $metadata->isInheritedField($property->name) || - $metadata->isInheritedAssociation($property->name)) { + $metadata->isInheritedAssociation($property->name) + || + $metadata->isInheritedEmbeddedClass($property->name)) { continue; } @@ -375,6 +379,10 @@ public function loadMetadataForClass($className, ClassMetadata $metadata) } $metadata->mapManyToMany($mapping); + } else if ($embeddedAnnot = $this->reader->getPropertyAnnotation($property, 'Doctrine\ORM\Mapping\Embedded')) { + $mapping['class'] = $embeddedAnnot->class; + $mapping['columnPrefix'] = $embeddedAnnot->columnPrefix; + $metadata->mapEmbedded($mapping); } // Evaluate @Cache annotation diff --git a/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php b/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php index 7032dc66a47..8f4a34c0ef4 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'; diff --git a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php index 2631c1084e5..3d867e1c5f8 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php @@ -69,6 +69,8 @@ public function loadMetadataForClass($className, ClassMetadata $metadata) isset($xmlRoot['repository-class']) ? (string)$xmlRoot['repository-class'] : null ); $metadata->isMappedSuperclass = true; + } else if ($xmlRoot->getName() == 'embeddable') { + $metadata->isEmbeddedClass = true; } else { throw MappingException::classIsNotAValidEntityOrMappedSuperClass($className); } @@ -246,6 +248,17 @@ public function loadMetadataForClass($className, ClassMetadata $metadata) } } + if (isset($xmlRoot->embedded)) { + foreach ($xmlRoot->embedded as $embeddedMapping) { + $mapping = array( + 'fieldName' => (string) $embeddedMapping['name'], + 'class' => (string) $embeddedMapping['class'], + 'columnPrefix' => isset($embeddedMapping['column-prefix']) ? (string) $embeddedMapping['column-prefix'] : null, + ); + $metadata->mapEmbedded($mapping); + } + } + foreach ($mappings as $mapping) { if (isset($mapping['version'])) { $metadata->setVersionMapping($mapping); @@ -796,6 +809,11 @@ protected function loadMappingFile($file) $className = (string)$mappedSuperClass['name']; $result[$className] = $mappedSuperClass; } + } else if (isset($xmlElement->embeddable)) { + foreach ($xmlElement->embeddable as $embeddableElement) { + $embeddableName = (string) $embeddableElement['name']; + $result[$embeddableName] = $embeddableElement; + } } return $result; diff --git a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php index 5549acae9ae..48aa7021b1f 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php @@ -66,6 +66,8 @@ public function loadMetadataForClass($className, ClassMetadata $metadata) isset($element['repositoryClass']) ? $element['repositoryClass'] : null ); $metadata->isMappedSuperclass = true; + } else if ($element['type'] == 'embeddable') { + $metadata->isEmbeddedClass = true; } else { throw MappingException::classIsNotAValidEntityOrMappedSuperClass($className); } @@ -318,6 +320,16 @@ public function loadMetadataForClass($className, ClassMetadata $metadata) } } + if (isset($element['embedded'])) { + foreach ($element['embedded'] as $name => $embeddedMapping) { + $mapping = array( + 'fieldName' => $name, + 'class' => $embeddedMapping['class'], + 'columnPrefix' => isset($embeddedMapping['columnPrefix']) ? $embeddedMapping['columnPrefix'] : null, + ); + $metadata->mapEmbedded($mapping); + } + } // Evaluate oneToOne relationships if (isset($element['oneToOne'])) { diff --git a/lib/Doctrine/ORM/Mapping/Embeddable.php b/lib/Doctrine/ORM/Mapping/Embeddable.php new file mode 100644 index 00000000000..f14bfac82a6 --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/Embeddable.php @@ -0,0 +1,28 @@ +. + */ + +namespace Doctrine\ORM\Mapping; + +/** + * @Annotation + * @Target("CLASS") + */ +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 00000000000..b44bced841b --- /dev/null +++ b/lib/Doctrine/ORM/Mapping/Embedded.php @@ -0,0 +1,38 @@ +. + */ + +namespace Doctrine\ORM\Mapping; + +/** + * @Annotation + * @Target("PROPERTY") + */ +final class Embedded implements Annotation +{ + /** + * @Required + * @var string + */ + public $class; + + /** + * @var string + */ + public $columnPrefix; +} diff --git a/lib/Doctrine/ORM/Mapping/NamingStrategy.php b/lib/Doctrine/ORM/Mapping/NamingStrategy.php index fc66905c508..94938ccf272 100644 --- a/lib/Doctrine/ORM/Mapping/NamingStrategy.php +++ b/lib/Doctrine/ORM/Mapping/NamingStrategy.php @@ -49,6 +49,16 @@ function classToTableName($className); */ function propertyToColumnName($propertyName, $className = null); + /** + * Returns a column name for an embedded property. + * + * @param string $propertyName + * @param string $embeddedColumnName + * + * @return string + */ + function embeddedFieldToColumnName($propertyName, $embeddedColumnName, $className = null, $embeddedClassName = null); + /** * Returns the default reference column name. * diff --git a/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php b/lib/Doctrine/ORM/Mapping/ReflectionEmbeddedProperty.php new file mode 100644 index 00000000000..662c006900e --- /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 = unserialize(sprintf('O:%d:"%s":0:{}', strlen($this->class), $this->class)); + $this->parentProperty->setValue($object, $embeddedObject); + } + + $this->childProperty->setValue($embeddedObject, $value); + } +} diff --git a/lib/Doctrine/ORM/Mapping/UnderscoreNamingStrategy.php b/lib/Doctrine/ORM/Mapping/UnderscoreNamingStrategy.php index 5231aaafcd9..ec74373a8b5 100644 --- a/lib/Doctrine/ORM/Mapping/UnderscoreNamingStrategy.php +++ b/lib/Doctrine/ORM/Mapping/UnderscoreNamingStrategy.php @@ -87,6 +87,14 @@ public function propertyToColumnName($propertyName, $className = null) return $this->underscore($propertyName); } + /** + * {@inheritdoc} + */ + public function embeddedFieldToColumnName($propertyName, $embeddedColumnName, $className = null, $embeddedClassName = null) + { + return $this->underscore($propertyName).'_'.$embeddedColumnName; + } + /** * {@inheritdoc} */ diff --git a/lib/Doctrine/ORM/Query/Parser.php b/lib/Doctrine/ORM/Query/Parser.php index 8ee9fed92bd..9ac52a805f7 100644 --- a/lib/Doctrine/ORM/Query/Parser.php +++ b/lib/Doctrine/ORM/Query/Parser.php @@ -1049,7 +1049,7 @@ public function JoinAssociationPathExpression() * Parses an arbitrary path expression and defers semantical validation * based on expected types. * - * PathExpression ::= IdentificationVariable "." identifier + * PathExpression ::= IdentificationVariable "." identifier [ ("." identifier)* ] * * @param integer $expectedTypes * @@ -1065,6 +1065,12 @@ public function PathExpression($expectedTypes) $this->match(Lexer::T_IDENTIFIER); $field = $this->lexer->token['value']; + + while ($this->lexer->isNextToken(Lexer::T_DOT)) { + $this->match(Lexer::T_DOT); + $this->match(Lexer::T_IDENTIFIER); + $field .= '.'.$this->lexer->token['value']; + } } // Creating AST node diff --git a/lib/Doctrine/ORM/Tools/SchemaTool.php b/lib/Doctrine/ORM/Tools/SchemaTool.php index 21140f7a026..577fc88ff81 100644 --- a/lib/Doctrine/ORM/Tools/SchemaTool.php +++ b/lib/Doctrine/ORM/Tools/SchemaTool.php @@ -126,6 +126,7 @@ private function processingNotRequired($class, array $processedClasses) return ( isset($processedClasses[$class->name]) || $class->isMappedSuperclass || + $class->isEmbeddedClass || ($class->isInheritanceTypeSingleTable() && $class->name != $class->rootEntityName) ); } diff --git a/tests/Doctrine/Tests/Models/ValueObjects/Name.php b/tests/Doctrine/Tests/Models/ValueObjects/Name.php new file mode 100644 index 00000000000..1c836032417 --- /dev/null +++ b/tests/Doctrine/Tests/Models/ValueObjects/Name.php @@ -0,0 +1,9 @@ +_schemaTool->createSchema(array( + $this->_em->getClassMetadata(__NAMESPACE__ . '\DDC93Person'), + $this->_em->getClassMetadata(__NAMESPACE__ . '\DDC93Address'), + $this->_em->getClassMetadata(__NAMESPACE__ . '\DDC93Vehicle'), + $this->_em->getClassMetadata(__NAMESPACE__ . '\DDC93Car'), + )); + } 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']); + } + } + + /** + * @group dql + */ + public function testDqlOnEmbeddedObjectsField() + { + if ($this->isSecondLevelCacheEnabled) { + $this->markTestSkipped('SLC does not work with UPDATE/DELETE queries through EM.'); + } + + $person = new DDC93Person('Johannes', new DDC93Address('Moo', '12345', 'Karlsruhe')); + $this->_em->persist($person); + $this->_em->flush($person); + + // SELECT + $selectDql = "SELECT p FROM " . __NAMESPACE__ ."\\DDC93Person p WHERE p.address.city = :city"; + $loadedPerson = $this->_em->createQuery($selectDql) + ->setParameter('city', 'Karlsruhe') + ->getSingleResult(); + $this->assertEquals($person, $loadedPerson); + + $this->assertNull($this->_em->createQuery($selectDql)->setParameter('city', 'asdf')->getOneOrNullResult()); + + // UPDATE + $updateDql = "UPDATE " . __NAMESPACE__ . "\\DDC93Person p SET p.address.street = :street WHERE p.address.city = :city"; + $this->_em->createQuery($updateDql) + ->setParameter('street', 'Boo') + ->setParameter('city', 'Karlsruhe') + ->execute(); + + $this->_em->refresh($person); + $this->assertEquals('Boo', $person->address->street); + + // DELETE + $this->_em->createQuery("DELETE " . __NAMESPACE__ . "\\DDC93Person p WHERE p.address.city = :city") + ->setParameter('city', 'Karlsruhe') + ->execute(); + + $this->_em->clear(); + $this->assertNull($this->_em->find(__NAMESPACE__.'\\DDC93Person', $person->id)); + } + + public function testDqlWithNonExistentEmbeddableField() + { + $this->setExpectedException('Doctrine\ORM\Query\QueryException', 'no field or association named address.asdfasdf'); + + $this->_em->createQuery("SELECT p FROM " . __NAMESPACE__ . "\\DDC93Person p WHERE p.address.asdfasdf IS NULL") + ->execute(); + } + + public function testEmbeddableWithInheritance() + { + $car = new DDC93Car(new DDC93Address('Foo', '12345', 'Asdf')); + $this->_em->persist($car); + $this->_em->flush($car); + + $reloadedCar = $this->_em->find(__NAMESPACE__.'\\DDC93Car', $car->id); + $this->assertEquals($car, $reloadedCar); + } +} + +/** + * @Entity + */ +class DDC93Person +{ + const CLASSNAME = __CLASS__; + + /** @Id @GeneratedValue @Column(type="integer") */ + public $id; + + /** @Column(type="string") */ + public $name; + + /** @Embedded(class="DDC93Address") */ + public $address; + + /** @Embedded(class = "DDC93Timestamps") */ + public $timestamps; + + public function __construct($name = null, DDC93Address $address = null) + { + $this->name = $name; + $this->address = $address; + $this->timestamps = new DDC93Timestamps(new \DateTime); + } +} + +/** + * @Embeddable + */ +class DDC93Timestamps +{ + /** @Column(type = "datetime") */ + public $createdAt; + + public function __construct(\DateTime $createdAt) + { + $this->createdAt = $createdAt; + } +} + +/** + * @Entity + * + * @InheritanceType("SINGLE_TABLE") + * @DiscriminatorColumn(name = "t", type = "string", length = 10) + * @DiscriminatorMap({ + * "v" = "Doctrine\Tests\ORM\Functional\DDC93Car", + * }) + */ +abstract class DDC93Vehicle +{ + /** @Id @GeneratedValue(strategy = "AUTO") @Column(type = "integer") */ + public $id; + + /** @Embedded(class = "DDC93Address") */ + public $address; + + public function __construct(DDC93Address $address) + { + $this->address = $address; + } +} + +/** + * @Entity + */ +class DDC93Car extends DDC93Vehicle +{ +} + +/** + * @Embeddable + */ +class DDC93Address +{ + const CLASSNAME = __CLASS__; + + /** + * @Column(type="string") + */ + public $street; + /** + * @Column(type="string") + */ + public $zip; + /** + * @Column(type="string") + */ + public $city; + + public function __construct($street = null, $zip = null, $city = null) + { + $this->street = $street; + $this->zip = $zip; + $this->city = $city; + } +} + diff --git a/tests/Doctrine/Tests/ORM/Mapping/XmlMappingDriverTest.php b/tests/Doctrine/Tests/ORM/Mapping/XmlMappingDriverTest.php index 42b871f1e3a..6d3ed77d167 100644 --- a/tests/Doctrine/Tests/ORM/Mapping/XmlMappingDriverTest.php +++ b/tests/Doctrine/Tests/ORM/Mapping/XmlMappingDriverTest.php @@ -3,6 +3,7 @@ namespace Doctrine\Tests\ORM\Mapping; use Doctrine\ORM\Mapping\ClassMetadata, + Doctrine\ORM\Mapping\ClassMetadataFactory, Doctrine\ORM\Mapping\Driver\XmlDriver, Doctrine\ORM\Mapping\Driver\YamlDriver; @@ -38,7 +39,7 @@ public function testIdentifierWithAssociationKey() { $driver = $this->_loadDriver(); $em = $this->_getTestEntityManager(); - $factory = new \Doctrine\ORM\Mapping\ClassMetadataFactory(); + $factory = new ClassMetadataFactory(); $em->getConfiguration()->setMetadataDriverImpl($driver); $factory->setEntityManager($em); @@ -52,6 +53,28 @@ public function testIdentifierWithAssociationKey() $this->assertTrue($class->associationMappings['article']['id']); } + public function testEmbeddableMapping() + { + $class = $this->createClassMetadata('Doctrine\Tests\Models\ValueObjects\Name'); + + $this->assertEquals(true, $class->isEmbeddedClass); + } + + public function testEmbeddedMapping() + { + $class = $this->createClassMetadata('Doctrine\Tests\Models\ValueObjects\Person'); + + $this->assertEquals( + array( + 'name' => array( + 'class' => 'Doctrine\Tests\Models\ValueObjects\Name', + 'columnPrefix' => 'nm_' + ) + ), + $class->embeddedClasses + ); + } + /** * @group DDC-1468 * diff --git a/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.ValueObjects.Name.dcm.xml b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.ValueObjects.Name.dcm.xml new file mode 100644 index 00000000000..97bb6a90eec --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.ValueObjects.Name.dcm.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.ValueObjects.Person.dcm.xml b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.ValueObjects.Person.dcm.xml new file mode 100644 index 00000000000..c2480bca7d2 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.ValueObjects.Person.dcm.xml @@ -0,0 +1,12 @@ + + + + + + + + +