Generated/Virtual Columns: Insertable / Updateable (#9118)

* Generated/Virtual Columns: Insertable / Updateable

Defines whether a column is included in an SQL INSERT and/or UPDATE statement.
Throws an exception for UPDATE statements attempting to update this field/column.

Closes #5728

* Apply suggestions from code review

Co-authored-by: Grégoire Paris <postmaster@greg0ire.fr>

* Add example for virtual column usage in attributes to docs.

Co-authored-by: Benjamin Eberlei <kontakt@beberlei.de>
Co-authored-by: Grégoire Paris <postmaster@greg0ire.fr>
This commit is contained in:
Christian Mehldau
2022-01-12 08:06:11 +01:00
committed by GitHub
parent ec391be4f2
commit e369cb6e73
29 changed files with 737 additions and 60 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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.

View File

@@ -288,6 +288,14 @@
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="generated-type">
<xs:restriction base="xs:token">
<xs:enumeration value="NEVER"/>
<xs:enumeration value="INSERT"/>
<xs:enumeration value="ALWAYS"/>
</xs:restriction>
</xs:simpleType>
<xs:complexType name="field">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="options" type="orm:options" minOccurs="0" />
@@ -299,6 +307,9 @@
<xs:attribute name="length" type="xs:NMTOKEN" />
<xs:attribute name="unique" type="xs:boolean" default="false" />
<xs:attribute name="nullable" type="xs:boolean" default="false" />
<xs:attribute name="insertable" type="xs:boolean" default="true" />
<xs:attribute name="updatable" type="xs:boolean" default="true" />
<xs:attribute name="generated" type="orm:generated-type" default="NEVER" />
<xs:attribute name="enum-type" type="xs:string" />
<xs:attribute name="version" type="xs:boolean" />
<xs:attribute name="column-definition" type="xs:string" />
@@ -623,6 +634,8 @@
<xs:attribute name="length" type="xs:NMTOKEN" />
<xs:attribute name="unique" type="xs:boolean" default="false" />
<xs:attribute name="nullable" type="xs:boolean" default="false" />
<xs:attribute name="insertable" type="xs:boolean" default="true" />
<xs:attribute name="updateable" type="xs:boolean" default="true" />
<xs:attribute name="version" type="xs:boolean" />
<xs:attribute name="column-definition" type="xs:string" />
<xs:attribute name="precision" type="xs:integer" use="optional" />

View File

@@ -55,8 +55,16 @@ class DefaultEntityHydrator implements EntityHydrator
$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) {

View File

@@ -110,6 +110,34 @@ class FieldBuilder
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.
*

View File

@@ -80,6 +80,9 @@ use const PHP_VERSION_ID;
* length?: int,
* id?: bool,
* nullable?: bool,
* notInsertable?: bool,
* notUpdatable?: bool,
* generated?: string,
* enumType?: class-string<BackedEnum>,
* 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
* - <b>nullable</b> (boolean, optional)
* Whether the column is nullable. Defaults to FALSE.
*
* - <b>'notInsertable'</b> (boolean, optional)
* Whether the column is not insertable. Optional. Is only set if value is TRUE.
*
* - <b>'notUpdatable'</b> (boolean, optional)
* Whether the column is updatable. Optional. Is only set if value is TRUE.
*
* - <b>columnDefinition</b> (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 @@ class ClassMetadataInfo implements ClassMetadata
$serialized[] = 'cache';
}
if ($this->requiresFetchAfterChange) {
$serialized[] = 'requiresFetchAfterChange';
}
return $serialized;
}
@@ -1611,6 +1647,16 @@ class ClassMetadataInfo implements ClassMetadata
$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 @@ class ClassMetadataInfo implements ClassMetadata
$mapping = $this->validateAndCompleteFieldMapping($mapping);
$this->assertFieldNotMapped($mapping['fieldName']);
if (isset($mapping['generated'])) {
$this->requiresFetchAfterChange = true;
}
$this->fieldMappings[$mapping['fieldName']] = $mapping;
}
@@ -3405,8 +3455,9 @@ class ClassMetadataInfo implements ClassMetadata
*/
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 @@ class ClassMetadataInfo implements ClassMetadata
public function setVersioned($bool)
{
$this->isVersioned = $bool;
if ($bool) {
$this->requiresFetchAfterChange = true;
}
}
/**

View File

@@ -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<string,mixed> $options
* @psalm-param 'NEVER'|'INSERT'|'ALWAYS'|null $generated
*/
public function __construct(
?string $name = null,
@@ -65,9 +79,12 @@ final class Column implements Annotation
?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 @@ final class Column implements Annotation
$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;
}
}

View File

@@ -633,6 +633,22 @@ class AnnotationDriver extends AbstractAnnotationDriver
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 @@ class AnnotationDriver extends AbstractAnnotationDriver
* unique: bool,
* nullable: bool,
* precision: int,
* notInsertable?: bool,
* notUpdateble?: bool,
* generated?: ClassMetadataInfo::GENERATED_*,
* enumType?: class-string,
* options?: mixed[],
* columnName?: string,
@@ -727,15 +746,27 @@ class AnnotationDriver extends AbstractAnnotationDriver
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;
}

View File

@@ -528,6 +528,20 @@ class AttributeDriver extends AnnotationDriver
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 @@ class AttributeDriver extends AnnotationDriver
$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;
}

View File

@@ -800,6 +800,8 @@ class XmlDriver extends FileDriver
* scale?: int,
* unique?: bool,
* nullable?: bool,
* notInsertable?: bool,
* notUpdatable?: bool,
* enumType?: string,
* version?: bool,
* columnDefinition?: string,
@@ -840,6 +842,18 @@ class XmlDriver extends FileDriver
$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']);
}

View File

@@ -786,6 +786,9 @@ class YamlDriver extends FileDriver
* unique?: mixed,
* options?: mixed,
* nullable?: mixed,
* insertable?: mixed,
* updatable?: mixed,
* generated?: mixed,
* enumType?: class-string,
* version?: mixed,
* columnDefinition?: mixed
@@ -802,6 +805,9 @@ class YamlDriver extends FileDriver
* unique?: bool,
* options?: mixed,
* nullable?: mixed,
* notInsertable?: mixed,
* notUpdatable?: mixed,
* generated?: mixed,
* enumType?: class-string,
* version?: mixed,
* columnDefinition?: mixed
@@ -850,6 +856,18 @@ class YamlDriver extends FileDriver
$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'];
}

View File

@@ -825,6 +825,11 @@ class MappingException extends ORMException
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
*

View File

@@ -30,8 +30,10 @@ use Doctrine\ORM\Repository\Exception\InvalidFindByCall;
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 @@ class BasicEntityPersister implements EntityPersister
$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 @@ class BasicEntityPersister implements EntityPersister
/**
* 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 @@ class BasicEntityPersister implements EntityPersister
$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 @@ class BasicEntityPersister implements EntityPersister
* )
* </code>
*
* @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<string, array<array-key, mixed|null>>
*/
protected function prepareUpdateData($entity)
protected function prepareUpdateData($entity, bool $isInsert = false)
{
$versionField = null;
$result = [];
@@ -625,6 +649,14 @@ class BasicEntityPersister implements EntityPersister
$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 @@ class BasicEntityPersister implements EntityPersister
*/
protected function prepareInsertData($entity)
{
return $this->prepareUpdateData($entity);
return $this->prepareUpdateData($entity, true);
}
/**
@@ -1440,6 +1472,10 @@ class BasicEntityPersister implements EntityPersister
}
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'];
}

View File

@@ -6,6 +6,7 @@ namespace Doctrine\ORM\Persisters\Entity;
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 @@ class JoinedSubclassPersister extends AbstractEntityInheritancePersister
$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 @@ class JoinedSubclassPersister extends AbstractEntityInheritancePersister
}
$isVersioned = $this->class->isVersioned;
if ($isVersioned === false) {
return;
}
$versionedClass = $this->getVersionedClassMetadata();
$versionedTable = $versionedClass->getTableName();
@@ -225,10 +223,10 @@ class JoinedSubclassPersister extends AbstractEntityInheritancePersister
$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 @@ class JoinedSubclassPersister extends AbstractEntityInheritancePersister
$identifiers = $this->em->getUnitOfWork()->getEntityIdentifier($entity);
$this->assignDefaultVersionValue($entity, $identifiers);
$this->assignDefaultVersionAndUpsertableValues($entity, $identifiers);
}
}
@@ -549,10 +547,15 @@ class JoinedSubclassPersister extends AbstractEntityInheritancePersister
/**
* {@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

View File

@@ -215,6 +215,14 @@ class XmlExporter extends AbstractExporter
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');
}
}
}

View File

@@ -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\\<BackedEnum\\>, 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

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\Models\Upsertable;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
/**
* @Entity
* @Table(name="insertable_column")
*/
#[Entity]
#[Table(name: 'insertable_column')]
class Insertable
{
/**
* @var int
* @Id
* @GeneratedValue
* @Column(type="integer")
*/
#[Id, GeneratedValue, Column(type: 'integer')]
public $id;
/**
* @var string
* @Column(type="string", insertable=false, options={"default": "1234"}, generated="INSERT")
*/
#[Column(type: 'string', insertable: false, options: ['default' => '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;
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\Models\Upsertable;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
/**
* @Entity
* @Table(name="updatable_column")
*/
#[Entity, Table(name: 'updatable_column')]
class Updatable
{
/**
* @var int
* @Id
* @GeneratedValue
* @Column(type="integer")
*/
#[Id, GeneratedValue, Column(type: 'integer')]
public $id;
/**
* @var string
* @Column(type="string", name="non_updatable_content", updatable=false, generated="ALWAYS")
*/
#[Column(type: 'string', name: 'non_updatable_content', updatable: false, generated: 'ALWAYS')]
public $nonUpdatableContent;
/**
* @var string
* @Column(type="string", updatable=true)
*/
#[Column(type: 'string', updatable: true)]
public $updatableContent;
public static function loadMetadata(ClassMetadata $metadata): ClassMetadata
{
$metadata->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;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional;
use Doctrine\ORM\Tools\ToolsException;
use Doctrine\Tests\Models\Upsertable\Insertable;
use Doctrine\Tests\Models\Upsertable\Updatable;
use Doctrine\Tests\OrmFunctionalTestCase;
class InsertableUpdatableTest extends OrmFunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();
try {
$this->_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);
}
}

View File

@@ -68,6 +68,8 @@ use Doctrine\Tests\Models\Enums\Card;
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 @@ abstract class AbstractMappingDriverTest extends OrmTestCase
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
*/

View File

@@ -67,7 +67,10 @@ class ClassMetadataTest extends OrmTestCase
$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 @@ class ClassMetadataTest extends OrmTestCase
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

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Doctrine\ORM\Mapping\ClassMetadataInfo;
$metadata->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']
);

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Doctrine\ORM\Mapping\ClassMetadataInfo;
$metadata->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']
);

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="Doctrine\Tests\Models\Upsertable\Insertable" table="insertable_column">
<id name="id">
<generator strategy="AUTO"/>
</id>
<field name="nonInsertableContent" insertable="false" type="string" generated="INSERT" />
<field name="insertableContent" insertable="true" type="string" />
</entity>
</doctrine-mapping>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="Doctrine\Tests\Models\Upsertable\Updatable" table="updatable_column">
<id name="id">
<generator strategy="AUTO"/>
</id>
<field name="nonUpdatableContent" updatable="false" type="string" generated="ALWAYS" />
<field name="updatableContent" updatable="true" type="string" />
</entity>
</doctrine-mapping>

View File

@@ -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

View File

@@ -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