diff --git a/doctrine-mapping.xsd b/doctrine-mapping.xsd index e7a94b702e2..80c65365ac8 100644 --- a/doctrine-mapping.xsd +++ b/doctrine-mapping.xsd @@ -274,6 +274,8 @@ + + @@ -542,6 +544,8 @@ + + diff --git a/lib/Doctrine/ORM/Mapping/Builder/FieldBuilder.php b/lib/Doctrine/ORM/Mapping/Builder/FieldBuilder.php index 8b9e62d548a..743e63a9748 100644 --- a/lib/Doctrine/ORM/Mapping/Builder/FieldBuilder.php +++ b/lib/Doctrine/ORM/Mapping/Builder/FieldBuilder.php @@ -110,6 +110,34 @@ public function precision($p) return $this; } + /** + * Sets insertable. + * + * @param bool $flag + * + * @return $this + */ + public function insertable($flag = true) + { + $this->mapping['insertable'] = (bool) $flag; + + return $this; + } + + /** + * Sets updateable. + * + * @param bool $flag + * + * @return $this + */ + public function updateable($flag = true) + { + $this->mapping['updateable'] = (bool) $flag; + + return $this; + } + /** * Sets scale. * diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php index d736886563c..f7819a3a4c8 100644 --- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php +++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php @@ -413,6 +413,12 @@ class ClassMetadataInfo implements ClassMetadata * - nullable (boolean, optional) * Whether the column is nullable. Defaults to FALSE. * + * - 'insertable' (boolean, optional) + * Whether the column is insertable. Defaults to TRUE. + * + * - 'updateable' (boolean, optional) + * Whether the column is updateable. Defaults to TRUE. + * * - columnDefinition (string, optional, schema-only) * The SQL fragment that is used when generating the DDL for the column. * @@ -433,6 +439,8 @@ class ClassMetadataInfo implements ClassMetadata * length?: int, * id?: bool, * nullable?: bool, + * insertable?: bool, + * updateable?: bool, * columnDefinition?: string, * precision?: int, * scale?: int, @@ -1255,6 +1263,34 @@ public function isNullable($fieldName) return $mapping !== false && isset($mapping['nullable']) && $mapping['nullable']; } + /** + * Checks if the field is insertable. + * + * @param string $fieldName The field name. + * + * @return bool TRUE if the field is not null, FALSE otherwise. + */ + public function isInsertable($fieldName) + { + $mapping = $this->getFieldMapping($fieldName); + + return $mapping !== false && isset($mapping['insertable']) && $mapping['insertable']; + } + + /** + * Checks if the field is updateable. + * + * @param string $fieldName The field name. + * + * @return bool TRUE if the field is not null, FALSE otherwise. + */ + public function isUpdateable($fieldName) + { + $mapping = $this->getFieldMapping($fieldName); + + return $mapping !== false && isset($mapping['updateable']) && $mapping['updateable']; + } + /** * Gets a column name for a field name. * If the column name for the field cannot be found, the given field name @@ -1282,6 +1318,8 @@ public function getColumnName($fieldName) * columnName?: string, * inherited?: class-string, * nullable?: bool, + * insertable?: bool, + * updateable?: bool, * originalClass?: class-string, * originalField?: string, * scale?: int, diff --git a/lib/Doctrine/ORM/Mapping/Column.php b/lib/Doctrine/ORM/Mapping/Column.php index 622b507a0e2..172bc0d2c6b 100644 --- a/lib/Doctrine/ORM/Mapping/Column.php +++ b/lib/Doctrine/ORM/Mapping/Column.php @@ -44,6 +44,12 @@ final class Column implements Annotation /** @var bool */ public $nullable = false; + /** @var bool */ + public $insertable = true; + + /** @var bool */ + public $updateable = true; + /** @var array */ public $options = []; @@ -61,6 +67,8 @@ public function __construct( ?int $scale = null, bool $unique = false, bool $nullable = false, + ?bool $insertable = true, + ?bool $updateable = true, array $options = [], ?string $columnDefinition = null ) { @@ -71,6 +79,8 @@ public function __construct( $this->scale = $scale; $this->unique = $unique; $this->nullable = $nullable; + $this->insertable = $insertable; + $this->updateable = $updateable; $this->options = $options; $this->columnDefinition = $columnDefinition; } diff --git a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php index 086a3baf879..8ef72f92fff 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php @@ -720,6 +720,8 @@ private function joinColumnToArray(Mapping\JoinColumn $joinColumn): array * unique: bool, * nullable: bool, * precision: int, + * insertable?: bool, + * updateble?: bool, * options?: mixed[], * columnName?: string, * columnDefinition?: string @@ -728,15 +730,23 @@ private function joinColumnToArray(Mapping\JoinColumn $joinColumn): array private function columnToArray(string $fieldName, Mapping\Column $column): array { $mapping = [ - 'fieldName' => $fieldName, - 'type' => $column->type, - 'scale' => $column->scale, - 'length' => $column->length, - 'unique' => $column->unique, - 'nullable' => $column->nullable, - 'precision' => $column->precision, + 'fieldName' => $fieldName, + 'type' => $column->type, + 'scale' => $column->scale, + 'length' => $column->length, + 'unique' => $column->unique, + 'nullable' => $column->nullable, + 'precision' => $column->precision, ]; + if ($column->insertable) { + $mapping['insertable'] = $column->insertable; + } + + if ($column->updateable) { + $mapping['updateable'] = $column->updateable; + } + if ($column->options) { $mapping['options'] = $column->options; } diff --git a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php index d6fc9ca8b47..262e88eaca8 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php @@ -800,6 +800,8 @@ private function joinColumnToArray(SimpleXMLElement $joinColumnElement): array * scale?: int, * unique?: bool, * nullable?: bool, + * insertable?: bool, + * updateable?: bool, * version?: bool, * columnDefinition?: string, * options?: array @@ -839,6 +841,14 @@ private function columnToArray(SimpleXMLElement $fieldMapping): array $mapping['nullable'] = $this->evaluateBoolean($fieldMapping['nullable']); } + if (isset($fieldMapping['insertable'])) { + $mapping['insertable'] = $this->evaluateBoolean($fieldMapping['insertable']); + } + + if (isset($fieldMapping['updateable'])) { + $mapping['updateable'] = $this->evaluateBoolean($fieldMapping['updateable']); + } + if (isset($fieldMapping['version']) && $fieldMapping['version']) { $mapping['version'] = $this->evaluateBoolean($fieldMapping['version']); } diff --git a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php index 97c73eae9a6..166e19816aa 100644 --- a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php +++ b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php @@ -786,6 +786,8 @@ private function joinColumnToArray(array $joinColumnElement): array * unique?: mixed, * options?: mixed, * nullable?: mixed, + * insertable?: mixed, + * updateable?: mixed, * version?: mixed, * columnDefinition?: mixed * }|null $column @@ -801,6 +803,8 @@ private function joinColumnToArray(array $joinColumnElement): array * unique?: bool, * options?: mixed, * nullable?: mixed, + * insertable?: mixed, + * updateable?: mixed, * version?: mixed, * columnDefinition?: mixed * } @@ -848,6 +852,14 @@ private function columnToArray(string $fieldName, ?array $column): array $mapping['nullable'] = $column['nullable']; } + if (isset($column['insertable'])) { + $mapping['insertable'] = $column['insertable']; + } + + if (isset($column['updateable'])) { + $mapping['updateable'] = $column['updateable']; + } + if (isset($column['version']) && $column['version']) { $mapping['version'] = $column['version']; } diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php index 8ce4c03ce11..a46c97a439a 100644 --- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php +++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php @@ -22,6 +22,7 @@ use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Persisters\Exception\CantUseInOperatorOnCompositeKeys; use Doctrine\ORM\Persisters\Exception\InvalidOrientation; +use Doctrine\ORM\Persisters\Exception\NonUpdateableField; use Doctrine\ORM\Persisters\Exception\UnrecognizedField; use Doctrine\ORM\Persisters\SqlExpressionVisitor; use Doctrine\ORM\Persisters\SqlValueVisitor; @@ -627,6 +628,19 @@ protected function prepareUpdateData($entity) $fieldMapping = $this->class->fieldMappings[$field]; $columnName = $fieldMapping['columnName']; + $isInsert = ! empty($this->queuedInserts[spl_object_id($entity)]); + if (! $isInsert && ! $this->class->isUpdateable($field)) { + if ($change[0] !== $change[1]) { + throw NonUpdateableField::byName($field); + } + + continue; + } + + if ($isInsert && ! $this->class->isInsertable($field)) { + continue; + } + $this->columnTypes[$columnName] = $fieldMapping['type']; $result[$this->getOwningTable($field)][$columnName] = $newVal; @@ -1442,6 +1456,10 @@ protected function getInsertColumnList() } if (! $this->class->isIdGeneratorIdentity() || $this->class->identifier[0] !== $name) { + if (! $this->class->isInsertable($name)) { + continue; + } + $columns[] = $this->quoteStrategy->getColumnName($name, $this->class, $this->platform); $this->columnTypes[$name] = $this->class->fieldMappings[$name]['type']; } diff --git a/lib/Doctrine/ORM/Persisters/Exception/NonUpdateableField.php b/lib/Doctrine/ORM/Persisters/Exception/NonUpdateableField.php new file mode 100644 index 00000000000..c4208fd3849 --- /dev/null +++ b/lib/Doctrine/ORM/Persisters/Exception/NonUpdateableField.php @@ -0,0 +1,17 @@ +addAttribute('nullable', $field['nullable'] ? 'true' : 'false'); } + + if (isset($field['insertable'])) { + $fieldXml->addAttribute('insertable', $field['insertable'] ? 'true' : 'false'); + } + + if (isset($field['updateable'])) { + $fieldXml->addAttribute('updateable', $field['updateable'] ? 'true' : 'false'); + } } } diff --git a/tests/Doctrine/Tests/Models/Upsertable/Insertable.php b/tests/Doctrine/Tests/Models/Upsertable/Insertable.php new file mode 100644 index 00000000000..18f9138c642 --- /dev/null +++ b/tests/Doctrine/Tests/Models/Upsertable/Insertable.php @@ -0,0 +1,44 @@ +entityManager = $this->getTestEntityManager(); + } + + public function testInsertSQLUsesInsertableColumns(): void + { + $persister = new BasicEntityPersister($this->entityManager, $this->entityManager->getClassMetadata(Insertable::class)); + $method = new ReflectionMethod($persister, 'getInsertSQL'); + $method->setAccessible(true); + + self::assertEquals('INSERT INTO insertable_column (insertableContent, insertableContentDefault) VALUES (?, ?)', $method->invoke($persister)); + } + + public function testUpdateSQLUsesUpdateableColumns(): void + { + $persister = new BasicEntityPersister($this->entityManager, $this->entityManager->getClassMetadata(Updateable::class)); + + $entity = new Updateable(); + $entity->id = 1; + $entity->nonUpdateableContent = 'non-persistable'; + $entity->updateableContent = 'persistable'; + $entity->updateableContentDefault = 'persistable'; + + $this->entityManager->getUnitOfWork()->registerManaged($entity, ['id' => 1], ['nonUpdateableContent' => 'default', 'updateableContent' => 'default', 'updateableContentDefault' => 'default']); + + $this->entityManager->getUnitOfWork()->propertyChanged($entity, 'nonUpdateableContent', 'non-persistable', 'non-persistable'); + $this->entityManager->getUnitOfWork()->propertyChanged($entity, 'updateableContent', 'default', 'persistable'); + $this->entityManager->getUnitOfWork()->propertyChanged($entity, 'updateableContentDefault', 'default', 'persistable'); + + $persister->update($entity); + + $executeStatements = $this->entityManager->getConnection()->getExecuteStatements(); + + self::assertEquals('UPDATE updateable_column SET updateableContent = ?, updateableContentDefault = ? WHERE id = ?', $executeStatements[0]['sql']); + } + + public function testExceptionIsThrownWhenTryingToChangeNonUpdateableColumn(): void + { + $this->expectException(NonUpdateableField::class); + + $persister = new BasicEntityPersister($this->entityManager, $this->entityManager->getClassMetadata(Updateable::class)); + + $entity = new Updateable(); + $entity->nonUpdateableContent = 'non-persistable'; + $entity->updateableContent = 'persistable'; + $entity->updateableContentDefault = 'persistable'; + + $this->entityManager->getUnitOfWork()->registerManaged($entity, ['id' => 1], ['nonUpdateableContent' => 'default', 'updateableContent' => 'default', 'updateableContentDefault' => 'default']); + + $this->entityManager->getUnitOfWork()->propertyChanged($entity, 'nonUpdateableContent', 'default', 'non-persistable'); + $this->entityManager->getUnitOfWork()->propertyChanged($entity, 'updateableContent', 'default', 'persistable'); + $this->entityManager->getUnitOfWork()->propertyChanged($entity, 'updateableContentDefault', 'default', 'persistable'); + + $persister->update($entity); + } +}