diff --git a/docs/en/reference/annotations-reference.rst b/docs/en/reference/annotations-reference.rst
index 226842d71a4..24ce6a5e87c 100644
--- a/docs/en/reference/annotations-reference.rst
+++ b/docs/en/reference/annotations-reference.rst
@@ -123,6 +123,18 @@ Optional attributes:
- **nullable**: Determines if NULL values allowed for this column. If not specified, default value is false.
+- **insertable**: Boolean value to determine if the column should be
+ included when inserting a new row into the underlying entities table.
+ If not specified, default value is true.
+
+- **updatable**: Boolean value to determine if the column should be
+ included when updating the row of the underlying entities table.
+ If not specified, default value is true.
+
+- **generated**: An enum with the possible values ALWAYS, INSERT, NEVER. Is
+ used after an INSERT or UPDATE statement to determine if the database
+ generated this value and it needs to be fetched using a SELECT statement.
+
- **options**: Array of additional options:
- ``default``: The default value to set for the column if no value
@@ -193,6 +205,13 @@ Examples:
*/
protected $loginCount;
+ /**
+ * Generated column
+ * @Column(type="string", name="user_fullname", insertable=false, updatable=false)
+ * MySQL example: full_name char(41) GENERATED ALWAYS AS (concat(firstname,' ',lastname)),
+ */
+ protected $fullname;
+
.. _annref_column_result:
@ColumnResult
diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst
index 89b1f8cad33..ce781220aff 100644
--- a/docs/en/reference/attributes-reference.rst
+++ b/docs/en/reference/attributes-reference.rst
@@ -178,6 +178,18 @@ Optional parameters:
- **nullable**: Determines if NULL values allowed for this column.
If not specified, default value is ``false``.
+- **insertable**: Boolean value to determine if the column should be
+ included when inserting a new row into the underlying entities table.
+ If not specified, default value is true.
+
+- **updatable**: Boolean value to determine if the column should be
+ included when updating the row of the underlying entities table.
+ If not specified, default value is true.
+
+- **generated**: An enum with the possible values ALWAYS, INSERT, NEVER. Is
+ used after an INSERT or UPDATE statement to determine if the database
+ generated this value and it needs to be fetched using a SELECT statement.
+
- **options**: Array of additional options:
- ``default``: The default value to set for the column if no value
@@ -248,6 +260,15 @@ Examples:
)]
protected $loginCount;
+ // MySQL example: full_name char(41) GENERATED ALWAYS AS (concat(firstname,' ',lastname)),
+ #[Column(
+ type: "string",
+ name: "user_fullname",
+ insertable: false,
+ updatable: false
+ )]
+ protected $fullname;
+
.. _attrref_cache:
#[Cache]
diff --git a/docs/en/reference/basic-mapping.rst b/docs/en/reference/basic-mapping.rst
index b613f9d6386..b17a968e3d3 100644
--- a/docs/en/reference/basic-mapping.rst
+++ b/docs/en/reference/basic-mapping.rst
@@ -199,6 +199,10 @@ list:
unique key.
- ``nullable``: (optional, default FALSE) Whether the database
column is nullable.
+- ``insertable``: (optional, default TRUE) Whether the database
+ column should be inserted.
+- ``updatable``: (optional, default TRUE) Whether the database
+ column should be updated.
- ``enumType``: (optional, requires PHP 8.1 and ORM 2.11) The PHP enum type
name to convert the database value into.
- ``precision``: (optional, default 0) The precision for a decimal
diff --git a/docs/en/reference/xml-mapping.rst b/docs/en/reference/xml-mapping.rst
index 35856e6fd56..39e637a945c 100644
--- a/docs/en/reference/xml-mapping.rst
+++ b/docs/en/reference/xml-mapping.rst
@@ -256,6 +256,11 @@ Optional attributes:
table? Defaults to false.
- nullable - Should this field allow NULL as a value? Defaults to
false.
+- insertable - Should this field be inserted? Defaults to true.
+- updatable - Should this field be updated? Defaults to true.
+- generated - Enum of the values ALWAYS, INSERT, NEVER that determines if
+ generated value must be fetched from database after INSERT or UPDATE.
+ Defaults to "NEVER".
- version - Should this field be used for optimistic locking? Only
works on fields with type integer or datetime.
- scale - Scale of a decimal type.
diff --git a/doctrine-mapping.xsd b/doctrine-mapping.xsd
index 142f204ae35..db7f4311134 100644
--- a/doctrine-mapping.xsd
+++ b/doctrine-mapping.xsd
@@ -288,6 +288,14 @@
+
+
+
+
+
+
+
+
@@ -299,6 +307,9 @@
+
+
+
@@ -623,6 +634,8 @@
+
+
diff --git a/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php b/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php
index aaba02d067f..384664fa5e6 100644
--- a/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php
+++ b/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php
@@ -55,8 +55,16 @@ public function buildCacheEntry(ClassMetadata $metadata, EntityCacheKey $key, $e
$data = $this->uow->getOriginalEntityData($entity);
$data = array_merge($data, $metadata->getIdentifierValues($entity)); // why update has no identifier values ?
- if ($metadata->isVersioned) {
- $data[$metadata->versionField] = $metadata->getFieldValue($entity, $metadata->versionField);
+ if ($metadata->requiresFetchAfterChange) {
+ if ($metadata->isVersioned) {
+ $data[$metadata->versionField] = $metadata->getFieldValue($entity, $metadata->versionField);
+ }
+
+ foreach ($metadata->fieldMappings as $name => $fieldMapping) {
+ if (isset($fieldMapping['generated'])) {
+ $data[$name] = $metadata->getFieldValue($entity, $name);
+ }
+ }
}
foreach ($metadata->associationMappings as $name => $assoc) {
diff --git a/lib/Doctrine/ORM/Mapping/Builder/FieldBuilder.php b/lib/Doctrine/ORM/Mapping/Builder/FieldBuilder.php
index 8b9e62d548a..f6d2db6c70e 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.
+ *
+ * @return $this
+ */
+ public function insertable(bool $flag = true): self
+ {
+ if (! $flag) {
+ $this->mapping['notInsertable'] = true;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Sets updatable.
+ *
+ * @return $this
+ */
+ public function updatable(bool $flag = true): self
+ {
+ if (! $flag) {
+ $this->mapping['notUpdatable'] = true;
+ }
+
+ return $this;
+ }
+
/**
* Sets scale.
*
diff --git a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php
index c95461030bd..ab4b97cfe86 100644
--- a/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php
+++ b/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php
@@ -80,6 +80,9 @@
* length?: int,
* id?: bool,
* nullable?: bool,
+ * notInsertable?: bool,
+ * notUpdatable?: bool,
+ * generated?: string,
* enumType?: class-string,
* columnDefinition?: string,
* precision?: int,
@@ -258,6 +261,21 @@ class ClassMetadataInfo implements ClassMetadata
*/
public const CACHE_USAGE_READ_WRITE = 3;
+ /**
+ * The value of this column is never generated by the database.
+ */
+ public const GENERATED_NEVER = 0;
+
+ /**
+ * The value of this column is generated by the database on INSERT, but not on UPDATE.
+ */
+ public const GENERATED_INSERT = 1;
+
+ /**
+ * The value of this column is generated by the database on both INSERT and UDPATE statements.
+ */
+ public const GENERATED_ALWAYS = 2;
+
/**
* READ-ONLY: The name of the entity class.
*
@@ -439,6 +457,12 @@ class ClassMetadataInfo implements ClassMetadata
* - nullable (boolean, optional)
* Whether the column is nullable. Defaults to FALSE.
*
+ * - 'notInsertable' (boolean, optional)
+ * Whether the column is not insertable. Optional. Is only set if value is TRUE.
+ *
+ * - 'notUpdatable' (boolean, optional)
+ * Whether the column is updatable. Optional. Is only set if value is TRUE.
+ *
* - columnDefinition (string, optional, schema-only)
* The SQL fragment that is used when generating the DDL for the column.
*
@@ -659,13 +683,21 @@ class ClassMetadataInfo implements ClassMetadata
*/
public $changeTrackingPolicy = self::CHANGETRACKING_DEFERRED_IMPLICIT;
+ /**
+ * READ-ONLY: A Flag indicating whether one or more columns of this class
+ * have to be reloaded after insert / update operations.
+ *
+ * @var bool
+ */
+ public $requiresFetchAfterChange = false;
+
/**
* READ-ONLY: A flag for whether or not instances of this class are to be versioned
* with optimistic locking.
*
* @var bool
*/
- public $isVersioned;
+ public $isVersioned = false;
/**
* READ-ONLY: The name of the field which is used for versioning in optimistic locking (if any).
@@ -963,6 +995,10 @@ public function __sleep()
$serialized[] = 'cache';
}
+ if ($this->requiresFetchAfterChange) {
+ $serialized[] = 'requiresFetchAfterChange';
+ }
+
return $serialized;
}
@@ -1611,6 +1647,16 @@ protected function validateAndCompleteFieldMapping(array $mapping): array
$mapping['requireSQLConversion'] = true;
}
+ if (isset($mapping['generated'])) {
+ if (! in_array($mapping['generated'], [self::GENERATED_NEVER, self::GENERATED_INSERT, self::GENERATED_ALWAYS])) {
+ throw MappingException::invalidGeneratedMode($mapping['generated']);
+ }
+
+ if ($mapping['generated'] === self::GENERATED_NEVER) {
+ unset($mapping['generated']);
+ }
+ }
+
if (isset($mapping['enumType'])) {
if (PHP_VERSION_ID < 80100) {
throw MappingException::enumsRequirePhp81($this->name, $mapping['fieldName']);
@@ -2675,6 +2721,10 @@ public function mapField(array $mapping)
$mapping = $this->validateAndCompleteFieldMapping($mapping);
$this->assertFieldNotMapped($mapping['fieldName']);
+ if (isset($mapping['generated'])) {
+ $this->requiresFetchAfterChange = true;
+ }
+
$this->fieldMappings[$mapping['fieldName']] = $mapping;
}
@@ -3405,8 +3455,9 @@ public function setSequenceGeneratorDefinition(array $definition)
*/
public function setVersionMapping(array &$mapping)
{
- $this->isVersioned = true;
- $this->versionField = $mapping['fieldName'];
+ $this->isVersioned = true;
+ $this->versionField = $mapping['fieldName'];
+ $this->requiresFetchAfterChange = true;
if (! isset($mapping['default'])) {
if (in_array($mapping['type'], ['integer', 'bigint', 'smallint'], true)) {
@@ -3429,6 +3480,10 @@ public function setVersionMapping(array &$mapping)
public function setVersioned($bool)
{
$this->isVersioned = $bool;
+
+ if ($bool) {
+ $this->requiresFetchAfterChange = true;
+ }
}
/**
diff --git a/lib/Doctrine/ORM/Mapping/Column.php b/lib/Doctrine/ORM/Mapping/Column.php
index b90a80ff941..f799bd9bccd 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 $updatable = true;
+
/** @var class-string<\BackedEnum>|null */
public $enumType = null;
@@ -53,9 +59,17 @@ final class Column implements Annotation
/** @var string|null */
public $columnDefinition;
+ /**
+ * @var string|null
+ * @psalm-var 'NEVER'|'INSERT'|'ALWAYS'|null
+ * @Enum({"NEVER", "INSERT", "ALWAYS"})
+ */
+ public $generated;
+
/**
* @param class-string<\BackedEnum>|null $enumType
* @param array $options
+ * @psalm-param 'NEVER'|'INSERT'|'ALWAYS'|null $generated
*/
public function __construct(
?string $name = null,
@@ -65,9 +79,12 @@ public function __construct(
?int $scale = null,
bool $unique = false,
bool $nullable = false,
+ bool $insertable = true,
+ bool $updatable = true,
?string $enumType = null,
array $options = [],
- ?string $columnDefinition = null
+ ?string $columnDefinition = null,
+ ?string $generated = null
) {
$this->name = $name;
$this->type = $type;
@@ -76,8 +93,11 @@ public function __construct(
$this->scale = $scale;
$this->unique = $unique;
$this->nullable = $nullable;
+ $this->insertable = $insertable;
+ $this->updatable = $updatable;
$this->enumType = $enumType;
$this->options = $options;
$this->columnDefinition = $columnDefinition;
+ $this->generated = $generated;
}
}
diff --git a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php
index 5e6e169051c..bceef45da17 100644
--- a/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php
+++ b/lib/Doctrine/ORM/Mapping/Driver/AnnotationDriver.php
@@ -633,6 +633,22 @@ private function getFetchMode(string $className, string $fetchMode): int
return constant('Doctrine\ORM\Mapping\ClassMetadata::FETCH_' . $fetchMode);
}
+ /**
+ * Attempts to resolve the generated mode.
+ *
+ * @psalm-return ClassMetadataInfo::GENERATED_*
+ *
+ * @throws MappingException If the fetch mode is not valid.
+ */
+ private function getGeneratedMode(string $generatedMode): int
+ {
+ if (! defined('Doctrine\ORM\Mapping\ClassMetadata::GENERATED_' . $generatedMode)) {
+ throw MappingException::invalidGeneratedMode($generatedMode);
+ }
+
+ return constant('Doctrine\ORM\Mapping\ClassMetadata::GENERATED_' . $generatedMode);
+ }
+
/**
* Parses the given method.
*
@@ -718,6 +734,9 @@ private function joinColumnToArray(Mapping\JoinColumn $joinColumn): array
* unique: bool,
* nullable: bool,
* precision: int,
+ * notInsertable?: bool,
+ * notUpdateble?: bool,
+ * generated?: ClassMetadataInfo::GENERATED_*,
* enumType?: class-string,
* options?: mixed[],
* columnName?: string,
@@ -727,15 +746,27 @@ 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['notInsertable'] = true;
+ }
+
+ if (! $column->updatable) {
+ $mapping['notUpdatable'] = true;
+ }
+
+ if ($column->generated) {
+ $mapping['generated'] = $this->getGeneratedMode($column->generated);
+ }
+
if ($column->options) {
$mapping['options'] = $column->options;
}
diff --git a/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php b/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php
index 6e0f3182ac1..3155fa753c9 100644
--- a/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php
+++ b/lib/Doctrine/ORM/Mapping/Driver/AttributeDriver.php
@@ -528,6 +528,20 @@ private function getFetchMode(string $className, string $fetchMode): int
return constant('Doctrine\ORM\Mapping\ClassMetadata::FETCH_' . $fetchMode);
}
+ /**
+ * Attempts to resolve the generated mode.
+ *
+ * @throws MappingException If the fetch mode is not valid.
+ */
+ private function getGeneratedMode(string $generatedMode): int
+ {
+ if (! defined('Doctrine\ORM\Mapping\ClassMetadata::GENERATED_' . $generatedMode)) {
+ throw MappingException::invalidGeneratedMode($generatedMode);
+ }
+
+ return constant('Doctrine\ORM\Mapping\ClassMetadata::GENERATED_' . $generatedMode);
+ }
+
/**
* Parses the given method.
*
@@ -644,6 +658,18 @@ private function columnToArray(string $fieldName, Mapping\Column $column): array
$mapping['columnDefinition'] = $column->columnDefinition;
}
+ if ($column->updatable === false) {
+ $mapping['notUpdatable'] = true;
+ }
+
+ if ($column->insertable === false) {
+ $mapping['notInsertable'] = true;
+ }
+
+ if ($column->generated !== null) {
+ $mapping['generated'] = $this->getGeneratedMode($column->generated);
+ }
+
if ($column->enumType) {
$mapping['enumType'] = $column->enumType;
}
diff --git a/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ORM/Mapping/Driver/XmlDriver.php
index f6eba16a7f9..2d0cccd2efa 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,
+ * notInsertable?: bool,
+ * notUpdatable?: bool,
* enumType?: string,
* version?: bool,
* columnDefinition?: string,
@@ -840,6 +842,18 @@ private function columnToArray(SimpleXMLElement $fieldMapping): array
$mapping['nullable'] = $this->evaluateBoolean($fieldMapping['nullable']);
}
+ if (isset($fieldMapping['insertable']) && ! $this->evaluateBoolean($fieldMapping['insertable'])) {
+ $mapping['notInsertable'] = true;
+ }
+
+ if (isset($fieldMapping['updatable']) && ! $this->evaluateBoolean($fieldMapping['updatable'])) {
+ $mapping['notUpdatable'] = true;
+ }
+
+ if (isset($fieldMapping['generated'])) {
+ $mapping['generated'] = constant('Doctrine\ORM\Mapping\ClassMetadata::GENERATED_' . (string) $fieldMapping['generated']);
+ }
+
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 1eab5e87e4c..ff11dfbb7ca 100644
--- a/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php
+++ b/lib/Doctrine/ORM/Mapping/Driver/YamlDriver.php
@@ -786,6 +786,9 @@ private function joinColumnToArray(array $joinColumnElement): array
* unique?: mixed,
* options?: mixed,
* nullable?: mixed,
+ * insertable?: mixed,
+ * updatable?: mixed,
+ * generated?: mixed,
* enumType?: class-string,
* version?: mixed,
* columnDefinition?: mixed
@@ -802,6 +805,9 @@ private function joinColumnToArray(array $joinColumnElement): array
* unique?: bool,
* options?: mixed,
* nullable?: mixed,
+ * notInsertable?: mixed,
+ * notUpdatable?: mixed,
+ * generated?: mixed,
* enumType?: class-string,
* version?: mixed,
* columnDefinition?: mixed
@@ -850,6 +856,18 @@ private function columnToArray(string $fieldName, ?array $column): array
$mapping['nullable'] = $column['nullable'];
}
+ if (isset($column['insertable']) && ! (bool) $column['insertable']) {
+ $mapping['notInsertable'] = true;
+ }
+
+ if (isset($column['updatable']) && ! (bool) $column['updatable']) {
+ $mapping['notUpdatable'] = true;
+ }
+
+ if (isset($column['generated'])) {
+ $mapping['generated'] = constant('Doctrine\ORM\Mapping\ClassMetadata::GENERATED_' . $column['generated']);
+ }
+
if (isset($column['version']) && $column['version']) {
$mapping['version'] = $column['version'];
}
diff --git a/lib/Doctrine/ORM/Mapping/MappingException.php b/lib/Doctrine/ORM/Mapping/MappingException.php
index 3fdc026a41e..0ba7506dfd1 100644
--- a/lib/Doctrine/ORM/Mapping/MappingException.php
+++ b/lib/Doctrine/ORM/Mapping/MappingException.php
@@ -825,6 +825,11 @@ public static function invalidFetchMode($className, $annotation)
return new self("Entity '" . $className . "' has a mapping with invalid fetch mode '" . $annotation . "'");
}
+ public static function invalidGeneratedMode(string $annotation): MappingException
+ {
+ return new self("Invalid generated mode '" . $annotation . "'");
+ }
+
/**
* @param string $className
*
diff --git a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php
index 4a32448fbc8..d9ac746adc1 100644
--- a/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php
+++ b/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php
@@ -30,8 +30,10 @@
use Doctrine\ORM\UnitOfWork;
use Doctrine\ORM\Utility\IdentifierFlattener;
use Doctrine\ORM\Utility\PersisterHelper;
+use LengthException;
use function array_combine;
+use function array_keys;
use function array_map;
use function array_merge;
use function array_search;
@@ -284,8 +286,8 @@ public function executeInserts()
$id = $this->class->getIdentifierValues($entity);
}
- if ($this->class->isVersioned) {
- $this->assignDefaultVersionValue($entity, $id);
+ if ($this->class->requiresFetchAfterChange) {
+ $this->assignDefaultVersionAndUpsertableValues($entity, $id);
}
}
@@ -297,50 +299,71 @@ public function executeInserts()
/**
* Retrieves the default version value which was created
* by the preceding INSERT statement and assigns it back in to the
- * entities version field.
+ * entities version field if the given entity is versioned.
+ * Also retrieves values of columns marked as 'non insertable' and / or
+ * 'not updatable' and assigns them back to the entities corresponding fields.
*
* @param object $entity
* @param mixed[] $id
*
* @return void
*/
- protected function assignDefaultVersionValue($entity, array $id)
+ protected function assignDefaultVersionAndUpsertableValues($entity, array $id)
{
- $value = $this->fetchVersionValue($this->class, $id);
+ $values = $this->fetchVersionAndNotUpsertableValues($this->class, $id);
- $this->class->setFieldValue($entity, $this->class->versionField, $value);
+ foreach ($values as $field => $value) {
+ $value = Type::getType($this->class->fieldMappings[$field]['type'])->convertToPHPValue($value, $this->platform);
+
+ $this->class->setFieldValue($entity, $field, $value);
+ }
}
/**
- * Fetches the current version value of a versioned entity.
+ * Fetches the current version value of a versioned entity and / or the values of fields
+ * marked as 'not insertable' and / or 'not updatable'.
*
* @param ClassMetadata $versionedClass
* @param mixed[] $id
*
* @return mixed
*/
- protected function fetchVersionValue($versionedClass, array $id)
+ protected function fetchVersionAndNotUpsertableValues($versionedClass, array $id)
{
- $versionField = $versionedClass->versionField;
- $fieldMapping = $versionedClass->fieldMappings[$versionField];
- $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform);
- $identifier = $this->quoteStrategy->getIdentifierColumnNames($versionedClass, $this->platform);
- $columnName = $this->quoteStrategy->getColumnName($versionField, $versionedClass, $this->platform);
+ $columnNames = [];
+ foreach ($this->class->fieldMappings as $key => $column) {
+ if (isset($column['generated']) || ($this->class->isVersioned && $key === $versionedClass->versionField)) {
+ $columnNames[$key] = $this->quoteStrategy->getColumnName($key, $versionedClass, $this->platform);
+ }
+ }
+
+ $tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform);
+ $identifier = $this->quoteStrategy->getIdentifierColumnNames($versionedClass, $this->platform);
// FIXME: Order with composite keys might not be correct
- $sql = 'SELECT ' . $columnName
- . ' FROM ' . $tableName
- . ' WHERE ' . implode(' = ? AND ', $identifier) . ' = ?';
+ $sql = 'SELECT ' . implode(', ', $columnNames)
+ . ' FROM ' . $tableName
+ . ' WHERE ' . implode(' = ? AND ', $identifier) . ' = ?';
$flatId = $this->identifierFlattener->flattenIdentifier($versionedClass, $id);
- $value = $this->conn->fetchOne(
+ $values = $this->conn->fetchNumeric(
$sql,
array_values($flatId),
$this->extractIdentifierTypes($id, $versionedClass)
);
- return Type::getType($fieldMapping['type'])->convertToPHPValue($value, $this->platform);
+ if ($values === false) {
+ throw new LengthException('Unexpected empty result for database query.');
+ }
+
+ $values = array_combine(array_keys($columnNames), $values);
+
+ if (! $values) {
+ throw new LengthException('Unexpected number of database columns.');
+ }
+
+ return $values;
}
/**
@@ -383,10 +406,10 @@ public function update($entity)
$this->updateTable($entity, $quotedTableName, $data, $isVersioned);
- if ($isVersioned) {
+ if ($this->class->requiresFetchAfterChange) {
$id = $this->class->getIdentifierValues($entity);
- $this->assignDefaultVersionValue($entity, $id);
+ $this->assignDefaultVersionAndUpsertableValues($entity, $id);
}
}
@@ -594,12 +617,13 @@ public function delete($entity)
* )
*
*
- * @param object $entity The entity for which to prepare the data.
+ * @param object $entity The entity for which to prepare the data.
+ * @param bool $isInsert Whether the data to be prepared refers to an insert statement.
*
* @return mixed[][] The prepared data.
* @psalm-return array>
*/
- protected function prepareUpdateData($entity)
+ protected function prepareUpdateData($entity, bool $isInsert = false)
{
$versionField = null;
$result = [];
@@ -625,6 +649,14 @@ protected function prepareUpdateData($entity)
$fieldMapping = $this->class->fieldMappings[$field];
$columnName = $fieldMapping['columnName'];
+ if (! $isInsert && isset($fieldMapping['notUpdatable'])) {
+ continue;
+ }
+
+ if ($isInsert && isset($fieldMapping['notInsertable'])) {
+ continue;
+ }
+
$this->columnTypes[$columnName] = $fieldMapping['type'];
$result[$this->getOwningTable($field)][$columnName] = $newVal;
@@ -692,7 +724,7 @@ protected function prepareUpdateData($entity)
*/
protected function prepareInsertData($entity)
{
- return $this->prepareUpdateData($entity);
+ return $this->prepareUpdateData($entity, true);
}
/**
@@ -1440,6 +1472,10 @@ protected function getInsertColumnList()
}
if (! $this->class->isIdGeneratorIdentity() || $this->class->identifier[0] !== $name) {
+ if (isset($this->class->fieldMappings[$name]['notInsertable'])) {
+ 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/Entity/JoinedSubclassPersister.php b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php
index 5c59168eb75..aa195d04aa9 100644
--- a/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php
+++ b/lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php
@@ -6,6 +6,7 @@
use Doctrine\Common\Collections\Criteria;
use Doctrine\DBAL\LockMode;
+use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Internal\SQLResultCasing;
use Doctrine\ORM\Mapping\ClassMetadata;
@@ -168,8 +169,8 @@ public function executeInserts()
$id = $this->em->getUnitOfWork()->getEntityIdentifier($entity);
}
- if ($this->class->isVersioned) {
- $this->assignDefaultVersionValue($entity, $id);
+ if ($this->class->requiresFetchAfterChange) {
+ $this->assignDefaultVersionAndUpsertableValues($entity, $id);
}
// Execute inserts on subtables.
@@ -211,9 +212,6 @@ public function update($entity)
}
$isVersioned = $this->class->isVersioned;
- if ($isVersioned === false) {
- return;
- }
$versionedClass = $this->getVersionedClassMetadata();
$versionedTable = $versionedClass->getTableName();
@@ -225,10 +223,10 @@ public function update($entity)
$this->updateTable($entity, $tableName, $data, $versioned);
}
- // Make sure the table with the version column is updated even if no columns on that
- // table were affected.
- if ($isVersioned) {
- if (! isset($updateData[$versionedTable])) {
+ if ($this->class->requiresFetchAfterChange) {
+ // Make sure the table with the version column is updated even if no columns on that
+ // table were affected.
+ if ($isVersioned && ! isset($updateData[$versionedTable])) {
$tableName = $this->quoteStrategy->getTableName($versionedClass, $this->platform);
$this->updateTable($entity, $tableName, [], true);
@@ -236,7 +234,7 @@ public function update($entity)
$identifiers = $this->em->getUnitOfWork()->getEntityIdentifier($entity);
- $this->assignDefaultVersionValue($entity, $identifiers);
+ $this->assignDefaultVersionAndUpsertableValues($entity, $identifiers);
}
}
@@ -549,10 +547,15 @@ protected function getInsertColumnList()
/**
* {@inheritdoc}
*/
- protected function assignDefaultVersionValue($entity, array $id)
+ protected function assignDefaultVersionAndUpsertableValues($entity, array $id)
{
- $value = $this->fetchVersionValue($this->getVersionedClassMetadata(), $id);
- $this->class->setFieldValue($entity, $this->class->versionField, $value);
+ $values = $this->fetchVersionAndNotUpsertableValues($this->getVersionedClassMetadata(), $id);
+
+ foreach ($values as $field => $value) {
+ $value = Type::getType($this->class->fieldMappings[$field]['type'])->convertToPHPValue($value, $this->platform);
+
+ $this->class->setFieldValue($entity, $field, $value);
+ }
}
private function getJoinSql(string $baseTableAlias): string
diff --git a/lib/Doctrine/ORM/Tools/Export/Driver/XmlExporter.php b/lib/Doctrine/ORM/Tools/Export/Driver/XmlExporter.php
index 8c9ce6d7b92..11ef880ff7b 100644
--- a/lib/Doctrine/ORM/Tools/Export/Driver/XmlExporter.php
+++ b/lib/Doctrine/ORM/Tools/Export/Driver/XmlExporter.php
@@ -215,6 +215,14 @@ public function exportClassMetadata(ClassMetadataInfo $metadata)
if (isset($field['nullable'])) {
$fieldXml->addAttribute('nullable', $field['nullable'] ? 'true' : 'false');
}
+
+ if (isset($field['notInsertable'])) {
+ $fieldXml->addAttribute('insertable', 'false');
+ }
+
+ if (isset($field['notUpdatable'])) {
+ $fieldXml->addAttribute('updatable', 'false');
+ }
}
}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index c4f54cca953..17ecb7083a1 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -760,16 +760,6 @@ parameters:
count: 1
path: lib/Doctrine/ORM/Persisters/Entity/CachedPersisterContext.php
- -
- message: "#^If condition is always true\\.$#"
- count: 1
- path: lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php
-
- -
- message: "#^Left side of && is always true\\.$#"
- count: 1
- path: lib/Doctrine/ORM/Persisters/Entity/JoinedSubclassPersister.php
-
-
message: "#^Parameter \\#1 \\$em of method Doctrine\\\\ORM\\\\Id\\\\AbstractIdGenerator\\:\\:generate\\(\\) expects Doctrine\\\\ORM\\\\EntityManager, Doctrine\\\\ORM\\\\EntityManagerInterface given\\.$#"
count: 1
@@ -1741,7 +1731,7 @@ parameters:
path: lib/Doctrine/ORM/Tools/Export/Driver/XmlExporter.php
-
- message: "#^Offset 'version' on array\\{type\\: string, fieldName\\: string, columnName\\: string, length\\?\\: int, id\\?\\: bool, nullable\\?\\: bool, enumType\\?\\: class\\-string\\, columnDefinition\\?\\: string, \\.\\.\\.\\} in isset\\(\\) does not exist\\.$#"
+ message: "#^Offset 'version' on array\\{type\\: string, fieldName\\: string, columnName\\: string, length\\?\\: int, id\\?\\: bool, nullable\\?\\: bool, notInsertable\\?\\: bool, notUpdatable\\?\\: bool, \\.\\.\\.\\} in isset\\(\\) does not exist\\.$#"
count: 1
path: lib/Doctrine/ORM/Tools/Export/Driver/XmlExporter.php
diff --git a/tests/Doctrine/Tests/Models/Upsertable/Insertable.php b/tests/Doctrine/Tests/Models/Upsertable/Insertable.php
new file mode 100644
index 00000000000..3ebfc1a76ff
--- /dev/null
+++ b/tests/Doctrine/Tests/Models/Upsertable/Insertable.php
@@ -0,0 +1,74 @@
+ '1234'], generated: 'INSERT')]
+ public $nonInsertableContent;
+
+ /**
+ * @var string
+ * @Column(type="string", insertable=true)
+ */
+ #[Column(type: 'string', insertable: true)]
+ public $insertableContent;
+
+ public static function loadMetadata(ClassMetadata $metadata): ClassMetadata
+ {
+ $metadata->setPrimaryTable(
+ ['name' => 'insertable_column']
+ );
+
+ $metadata->mapField(
+ [
+ 'id' => true,
+ 'fieldName' => 'id',
+ ]
+ );
+ $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO);
+
+ $metadata->mapField(
+ [
+ 'fieldName' => 'nonInsertableContent',
+ 'notInsertable' => true,
+ 'options' => ['default' => '1234'],
+ 'generated' => ClassMetadataInfo::GENERATED_INSERT,
+ ]
+ );
+ $metadata->mapField(
+ ['fieldName' => 'insertableContent']
+ );
+
+ return $metadata;
+ }
+}
diff --git a/tests/Doctrine/Tests/Models/Upsertable/Updatable.php b/tests/Doctrine/Tests/Models/Upsertable/Updatable.php
new file mode 100644
index 00000000000..bd793773512
--- /dev/null
+++ b/tests/Doctrine/Tests/Models/Upsertable/Updatable.php
@@ -0,0 +1,72 @@
+setPrimaryTable(
+ ['name' => 'updatable_column']
+ );
+
+ $metadata->mapField(
+ [
+ 'id' => true,
+ 'fieldName' => 'id',
+ ]
+ );
+ $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_AUTO);
+
+ $metadata->mapField(
+ [
+ 'fieldName' => 'nonUpdatableContent',
+ 'notUpdatable' => true,
+ 'generated' => ClassMetadataInfo::GENERATED_ALWAYS,
+ ]
+ );
+ $metadata->mapField(
+ ['fieldName' => 'updatableContent']
+ );
+
+ return $metadata;
+ }
+}
diff --git a/tests/Doctrine/Tests/ORM/Functional/InsertableUpdateableTest.php b/tests/Doctrine/Tests/ORM/Functional/InsertableUpdateableTest.php
new file mode 100644
index 00000000000..3c9d20d40b1
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Functional/InsertableUpdateableTest.php
@@ -0,0 +1,74 @@
+_schemaTool->createSchema(
+ [
+ $this->_em->getClassMetadata(Updatable::class),
+ $this->_em->getClassMetadata(Insertable::class),
+ ]
+ );
+ } catch (ToolsException $e) {
+ }
+ }
+
+ public function testNotInsertableIsFetchedFromDatabase(): void
+ {
+ $insertable = new Insertable();
+ $insertable->insertableContent = 'abcdefg';
+
+ $this->_em->persist($insertable);
+ $this->_em->flush();
+
+ // gets inserted from default value and fetches value from database
+ self::assertEquals('1234', $insertable->nonInsertableContent);
+
+ $insertable->nonInsertableContent = '5678';
+
+ $this->_em->flush();
+ $this->_em->clear();
+
+ $insertable = $this->_em->find(Insertable::class, $insertable->id);
+
+ // during UPDATE statement it is not ignored
+ self::assertEquals('5678', $insertable->nonInsertableContent);
+ }
+
+ public function testNotUpdatableIsFetched(): void
+ {
+ $updatable = new Updatable();
+ $updatable->updatableContent = 'foo';
+ $updatable->nonUpdatableContent = 'foo';
+
+ $this->_em->persist($updatable);
+ $this->_em->flush();
+
+ $updatable->updatableContent = 'bar';
+ $updatable->nonUpdatableContent = 'baz';
+
+ $this->_em->flush();
+
+ self::assertEquals('foo', $updatable->nonUpdatableContent);
+
+ $this->_em->clear();
+
+ $cleanUpdatable = $this->_em->find(Updatable::class, $updatable->id);
+
+ self::assertEquals('bar', $cleanUpdatable->updatableContent);
+ self::assertEquals('foo', $cleanUpdatable->nonUpdatableContent);
+ }
+}
diff --git a/tests/Doctrine/Tests/ORM/Mapping/AbstractMappingDriverTest.php b/tests/Doctrine/Tests/ORM/Mapping/AbstractMappingDriverTest.php
index e3b05196cbf..9b17ed30270 100644
--- a/tests/Doctrine/Tests/ORM/Mapping/AbstractMappingDriverTest.php
+++ b/tests/Doctrine/Tests/ORM/Mapping/AbstractMappingDriverTest.php
@@ -68,6 +68,8 @@
use Doctrine\Tests\Models\Enums\Suit;
use Doctrine\Tests\Models\TypedProperties\Contact;
use Doctrine\Tests\Models\TypedProperties\UserTyped;
+use Doctrine\Tests\Models\Upsertable\Insertable;
+use Doctrine\Tests\Models\Upsertable\Updatable;
use Doctrine\Tests\OrmTestCase;
use function assert;
@@ -1145,6 +1147,30 @@ public function testReservedWordInTableColumn(): void
self::assertSame('count', $metadata->getFieldMapping('count')['columnName']);
}
+ public function testInsertableColumn(): void
+ {
+ $metadata = $this->createClassMetadata(Insertable::class);
+
+ $mapping = $metadata->getFieldMapping('nonInsertableContent');
+
+ self::assertArrayHasKey('notInsertable', $mapping);
+ self::assertArrayHasKey('generated', $mapping);
+ self::assertSame(ClassMetadataInfo::GENERATED_INSERT, $mapping['generated']);
+ self::assertArrayNotHasKey('notInsertable', $metadata->getFieldMapping('insertableContent'));
+ }
+
+ public function testUpdatableColumn(): void
+ {
+ $metadata = $this->createClassMetadata(Updatable::class);
+
+ $mapping = $metadata->getFieldMapping('nonUpdatableContent');
+
+ self::assertArrayHasKey('notUpdatable', $mapping);
+ self::assertArrayHasKey('generated', $mapping);
+ self::assertSame(ClassMetadataInfo::GENERATED_ALWAYS, $mapping['generated']);
+ self::assertArrayNotHasKey('notUpdatable', $metadata->getFieldMapping('updatableContent'));
+ }
+
/**
* @requires PHP 8.1
*/
diff --git a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php
index 5010b6c9349..65d3343f873 100644
--- a/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php
+++ b/tests/Doctrine/Tests/ORM/Mapping/ClassMetadataTest.php
@@ -67,7 +67,10 @@ public function testClassMetadataInstanceSerialization(): void
$cm->setDiscriminatorColumn(['name' => 'disc', 'type' => 'integer']);
$cm->mapOneToOne(['fieldName' => 'phonenumbers', 'targetEntity' => 'CmsAddress', 'mappedBy' => 'foo']);
$cm->markReadOnly();
+ $cm->mapField(['fieldName' => 'status', 'notInsertable' => true, 'generated' => ClassMetadata::GENERATED_ALWAYS]);
$cm->addNamedQuery(['name' => 'dql', 'query' => 'foo']);
+
+ self::assertTrue($cm->requiresFetchAfterChange);
self::assertEquals(1, count($cm->associationMappings));
$serialized = serialize($cm);
@@ -92,6 +95,7 @@ public function testClassMetadataInstanceSerialization(): void
self::assertEquals(CMS\CmsAddress::class, $oneOneMapping['targetEntity']);
self::assertTrue($cm->isReadOnly);
self::assertEquals(['dql' => ['name' => 'dql', 'query' => 'foo', 'dql' => 'foo']], $cm->namedQueries);
+ self::assertTrue($cm->requiresFetchAfterChange);
}
public function testFieldIsNullable(): void
diff --git a/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.Upsertable.Insertable.php b/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.Upsertable.Insertable.php
new file mode 100644
index 00000000000..403cdb2ef2e
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.Upsertable.Insertable.php
@@ -0,0 +1,29 @@
+setPrimaryTable(
+ ['name' => 'insertable_column']
+);
+
+$metadata->mapField(
+ [
+ 'id' => true,
+ 'fieldName' => 'id',
+ ]
+);
+$metadata->setIdGeneratorType(ClassMetadataInfo::GENERATOR_TYPE_AUTO);
+
+$metadata->mapField(
+ [
+ 'fieldName' => 'nonInsertableContent',
+ 'notInsertable' => true,
+ 'options' => ['default' => '1234'],
+ 'generated' => ClassMetadataInfo::GENERATED_INSERT,
+ ]
+);
+$metadata->mapField(
+ ['fieldName' => 'insertableContent']
+);
diff --git a/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.Upsertable.Updatable.php b/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.Upsertable.Updatable.php
new file mode 100644
index 00000000000..9fe2627c0e6
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Mapping/php/Doctrine.Tests.Models.Upsertable.Updatable.php
@@ -0,0 +1,28 @@
+setPrimaryTable(
+ ['name' => 'updatable_column']
+);
+
+$metadata->mapField(
+ [
+ 'id' => true,
+ 'fieldName' => 'id',
+ ]
+);
+$metadata->setIdGeneratorType(ClassMetadataInfo::GENERATOR_TYPE_AUTO);
+
+$metadata->mapField(
+ [
+ 'fieldName' => 'nonUpdatableContent',
+ 'notUpdatable' => true,
+ 'generated' => ClassMetadataInfo::GENERATED_ALWAYS,
+ ]
+);
+$metadata->mapField(
+ ['fieldName' => 'updatableContent']
+);
diff --git a/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Upsertable.Insertable.dcm.xml b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Upsertable.Insertable.dcm.xml
new file mode 100644
index 00000000000..6a216375822
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Upsertable.Insertable.dcm.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Upsertable.Updatable.dcm.xml b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Upsertable.Updatable.dcm.xml
new file mode 100644
index 00000000000..acaf07a8cd7
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Mapping/xml/Doctrine.Tests.Models.Upsertable.Updatable.dcm.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.Upsertable.Insertable.dcm.yml b/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.Upsertable.Insertable.dcm.yml
new file mode 100644
index 00000000000..4e5e8531841
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.Upsertable.Insertable.dcm.yml
@@ -0,0 +1,17 @@
+Doctrine\Tests\Models\Upsertable\Insertable:
+ type: entity
+ table: insertable_column
+ id:
+ id:
+ generator:
+ strategy: AUTO
+ fields:
+ nonInsertableContent:
+ type: string
+ insertable: false
+ generated: INSERT
+ options:
+ default: 1234
+ insertableContent:
+ type: string
+ insertable: true
\ No newline at end of file
diff --git a/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.Upsertable.Updatable.dcm.yml b/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.Upsertable.Updatable.dcm.yml
new file mode 100644
index 00000000000..386ae77209b
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Mapping/yaml/Doctrine.Tests.Models.Upsertable.Updatable.dcm.yml
@@ -0,0 +1,17 @@
+Doctrine\Tests\Models\Upsertable\Updatable:
+ type: entity
+ table: updatable_column
+ id:
+ id:
+ generator:
+ strategy: AUTO
+ fields:
+ nonUpdatableContent:
+ type: string
+ updatable: false
+ generated: ALWAYS
+ options:
+ default: 1234
+ updatableContent:
+ type: string
+ updatable: true