Skip to content

Commit 0a43e4a

Browse files
authored
Merge pull request #11946 from doctrine/3.3.x
Merge 3.3.x up into 3.4.x
2 parents 35d301b + 7111cc0 commit 0a43e4a

22 files changed

+498
-28
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
Generated Columns
2+
=================
3+
4+
Generated columns, sometimes also called virtual columns, are populated by
5+
the database engine itself. They are a tool for performance optimization, to
6+
avoid calculating a value on each query.
7+
8+
You can define generated columns on entities and have Doctrine map the values
9+
to your entity.
10+
11+
Declaring a generated column
12+
----------------------------
13+
14+
There is no explicit mapping instruction for generated columns. Instead, you
15+
specify that the column should not be written to, and define a custom column
16+
definition.
17+
18+
.. code-block:: php
19+
.. literalinclude:: generated-columns/Person.php
20+
21+
* ``insertable``, ``updatable``: Setting these to false tells Doctrine to never
22+
write this column - writing to a generated column would result in an error
23+
from the database.
24+
* ``columnDefinition``: We specify the full DDL to create the column. To allow
25+
to use database specific features, this attribute does not use Doctrine Query
26+
Language but native SQL. Note that you need to reference columns by their
27+
database name (either explicitly set in the mapping or per the current
28+
:doc:`naming strategy <../reference/namingstrategy>`).
29+
Be aware that specifying a column definition makes the ``SchemaTool``
30+
completely ignore all other configuration for this column. See also
31+
:ref:`#[Column] <attrref_column>`
32+
* ``generated``: Specifying that this column is always generated tells Doctrine
33+
to update the field on the entity with the value from the database after
34+
every write operation.
35+
36+
Advanced example: Extracting a value from a JSON structure
37+
----------------------------------------------------------
38+
39+
Lets assume we have an entity that stores a blogpost as structured JSON.
40+
To avoid extracting all titles on the fly when listing the posts, we create a
41+
generated column with the field.
42+
43+
.. code-block:: php
44+
.. literalinclude:: generated-columns/Article.php
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
7+
#[ORM\Entity]
8+
class Article
9+
{
10+
#[ORM\Id]
11+
#[ORM\GeneratedValue]
12+
#[ORM\Column]
13+
private int $id;
14+
15+
/**
16+
* When working with Postgres, it is recommended to use the jsonb
17+
* format for better performance.
18+
*/
19+
#[ORM\Column(options: ['jsonb' => true])]
20+
private array $content;
21+
22+
/**
23+
* Because we specify NOT NULL, inserting will fail if the content does
24+
* not have a string in the title field.
25+
*/
26+
#[ORM\Column(
27+
insertable: false,
28+
updatable: false,
29+
columnDefinition: "VARCHAR(255) generated always as (content->>'title') stored NOT NULL",
30+
generated: 'ALWAYS',
31+
)]
32+
private string $title;
33+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
7+
#[ORM\Entity]
8+
class Person
9+
{
10+
#[ORM\Column(type: 'string')]
11+
private string $firstName;
12+
13+
#[ORM\Column(type: 'string', name: 'name')]
14+
private string $lastName;
15+
16+
#[ORM\Column(
17+
type: 'string',
18+
insertable: false,
19+
updatable: false,
20+
columnDefinition: "VARCHAR(255) GENERATED ALWAYS AS (concat(firstName, ' ', name) stored NOT NULL",
21+
generated: 'ALWAYS',
22+
)]
23+
private string $fullName;
24+
}

docs/en/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Cookbook
102102

103103
* **Patterns**:
104104
:doc:`Aggregate Fields <cookbook/aggregate-fields>` \|
105+
:doc:`Generated/Virtual Columns <cookbook/generated-columns>` \|
105106
:doc:`Decorator Pattern <cookbook/decorator-pattern>` \|
106107
:doc:`Strategy Pattern <cookbook/strategy-cookbook-introduction>`
107108

@@ -121,4 +122,5 @@ Cookbook
121122

122123
* **Custom Datatypes**
123124
:doc:`MySQL Enums <cookbook/mysql-enums>`
125+
:doc:`Custom Mapping Types <cookbook/custom-mapping-types>`
124126
:doc:`Advanced Field Value Conversion <cookbook/advanced-field-value-conversion-using-custom-mapping-types>`

docs/en/reference/attributes-reference.rst

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,15 @@ Optional parameters:
214214
- ``check``: Adds a check constraint type to the column (might not
215215
be supported by all vendors).
216216

217-
- **columnDefinition**: DDL SQL snippet that starts after the column
217+
- **columnDefinition**: Specify the DDL SQL snippet that starts after the column
218218
name and specifies the complete (non-portable!) column definition.
219219
This attribute allows to make use of advanced RMDBS features.
220-
However you should make careful use of this feature and the
221-
consequences. ``SchemaTool`` will not detect changes on the column correctly
222-
anymore if you use ``columnDefinition``.
220+
However, as this needs to be specified in the DDL native to the database,
221+
the resulting schema changes are no longer portable. If you specify a
222+
``columnDefinition``, the ``SchemaTool`` ignores all other attributes
223+
that are normally used to build the definition DDL. Changes to the
224+
``columnDefinition`` are not detected, you will need to manually create a
225+
migration to apply changes.
223226

224227
Additionally you should remember that the ``type``
225228
attribute still handles the conversion between PHP and Database
@@ -262,10 +265,11 @@ Examples:
262265
)]
263266
protected $loginCount;
264267
265-
// MySQL example: full_name char(41) GENERATED ALWAYS AS (concat(firstname,' ',lastname)),
268+
// columnDefinition is raw SQL, not DQL. This example works for MySQL:
266269
#[Column(
267270
type: "string",
268271
name: "user_fullname",
272+
columnDefinition: "VARCHAR(255) GENERATED ALWAYS AS (concat(firstname,' ',lastname))",
269273
insertable: false,
270274
updatable: false
271275
)]
@@ -366,7 +370,7 @@ Optional parameters:
366370

367371
- **type**: By default this is string.
368372
- **length**: By default this is 255.
369-
- **columnDefinition**: By default this is null the definition according to the type will be used. This option allows to override it.
373+
- **columnDefinition**: Allows to override how the column is generated. See the "columnDefinition" attribute on :ref:`#[Column] <attrref_column>`
370374
- **enumType**: By default this is `null`. Allows to map discriminatorColumn value to PHP enum
371375
- **options**: See "options" attribute on :ref:`#[Column] <attrref_column>`.
372376

@@ -678,8 +682,10 @@ Optional parameters:
678682
- **onDelete**: Cascade Action (Database-level)
679683
- **columnDefinition**: DDL SQL snippet that starts after the column
680684
name and specifies the complete (non-portable!) column definition.
681-
This attribute enables the use of advanced RMDBS features. Using
682-
this attribute on ``#[JoinColumn]`` is necessary if you need slightly
685+
This attribute enables the use of advanced RMDBS features. Note that you
686+
need to reference columns by their database name (either explicitly set in
687+
the mapping or per the current :doc:`naming strategy <namingstrategy>`).
688+
Using this attribute on ``#[JoinColumn]`` is necessary if you need
683689
different column definitions for joining columns, for example
684690
regarding NULL/NOT NULL defaults. However by default a
685691
"columnDefinition" attribute on :ref:`#[Column] <attrref_column>` also sets
@@ -1134,7 +1140,7 @@ Marker attribute that defines a specified column as version attribute used in
11341140
an :ref:`optimistic locking <transactions-and-concurrency_optimistic-locking>`
11351141
scenario. It only works on :ref:`#[Column] <attrref_column>` attributes that have
11361142
the type ``integer`` or ``datetime``. Setting ``#[Version]`` on a property with
1137-
:ref:`#[Id <attrref_id>` is not supported.
1143+
:ref:`#[Id] <attrref_id>` is not supported.
11381144

11391145
Example:
11401146

docs/en/sidebar.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
cookbook/decorator-pattern
6565
cookbook/dql-custom-walkers
6666
cookbook/dql-user-defined-functions
67+
cookbook/generated-columns
6768
cookbook/implementing-arrayaccess-for-domain-objects
6869
cookbook/resolve-target-entity-listener
6970
cookbook/sql-table-prefixes

src/Persisters/Entity/BasicEntityPersister.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,16 @@ public function __construct(
201201
);
202202
}
203203

204+
final protected function isFilterHashUpToDate(): bool
205+
{
206+
return $this->filterHash === $this->em->getFilters()->getHash();
207+
}
208+
209+
final protected function updateFilterHash(): void
210+
{
211+
$this->filterHash = $this->em->getFilters()->getHash();
212+
}
213+
204214
public function getClassMetadata(): ClassMetadata
205215
{
206216
return $this->class;
@@ -1231,7 +1241,7 @@ final protected function getOrderBySQL(array $orderBy, string $baseTableAlias):
12311241
*/
12321242
protected function getSelectColumnsSQL(): string
12331243
{
1234-
if ($this->currentPersisterContext->selectColumnListSql !== null && $this->filterHash === $this->em->getFilters()->getHash()) {
1244+
if ($this->currentPersisterContext->selectColumnListSql !== null && $this->isFilterHashUpToDate()) {
12351245
return $this->currentPersisterContext->selectColumnListSql;
12361246
}
12371247

@@ -1347,7 +1357,7 @@ protected function getSelectColumnsSQL(): string
13471357
}
13481358

13491359
$this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList);
1350-
$this->filterHash = $this->em->getFilters()->getHash();
1360+
$this->updateFilterHash();
13511361

13521362
return $this->currentPersisterContext->selectColumnListSql;
13531363
}

src/Persisters/Entity/JoinedSubclassPersister.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ protected function getLockTablesSql(LockMode|int $lockMode): string
358358
protected function getSelectColumnsSQL(): string
359359
{
360360
// Create the column list fragment only once
361-
if ($this->currentPersisterContext->selectColumnListSql !== null) {
361+
if ($this->currentPersisterContext->selectColumnListSql !== null && $this->isFilterHashUpToDate()) {
362362
return $this->currentPersisterContext->selectColumnListSql;
363363
}
364364

@@ -445,6 +445,7 @@ protected function getSelectColumnsSQL(): string
445445
}
446446

447447
$this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList);
448+
$this->updateFilterHash();
448449

449450
return $this->currentPersisterContext->selectColumnListSql;
450451
}

src/Persisters/Entity/SingleTablePersister.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ protected function getDiscriminatorColumnTableName(): string
3535
protected function getSelectColumnsSQL(): string
3636
{
3737
$columnList = [];
38-
if ($this->currentPersisterContext->selectColumnListSql !== null) {
38+
if ($this->currentPersisterContext->selectColumnListSql !== null && $this->isFilterHashUpToDate()) {
3939
return $this->currentPersisterContext->selectColumnListSql;
4040
}
4141

@@ -89,6 +89,7 @@ protected function getSelectColumnsSQL(): string
8989
}
9090

9191
$this->currentPersisterContext->selectColumnListSql = implode(', ', $columnList);
92+
$this->updateFilterHash();
9293

9394
return $this->currentPersisterContext->selectColumnListSql;
9495
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Tests\ORM\Functional\Ticket\SwitchContextWithFilter;
6+
7+
use Doctrine\Tests\OrmFunctionalTestCase;
8+
9+
use function sprintf;
10+
use function str_replace;
11+
12+
abstract class AbstractTestCase extends OrmFunctionalTestCase
13+
{
14+
protected function generateMessage(string $message): string
15+
{
16+
$log = $this->getLastLoggedQuery();
17+
18+
return sprintf("%s\nSQL: %s", $message, str_replace(['?'], (array) $log['params'], $log['sql']));
19+
}
20+
21+
protected function clearCachedData(object ...$entities): void
22+
{
23+
foreach ($entities as $entity) {
24+
$this->_em->refresh($entity);
25+
}
26+
}
27+
28+
protected function persistFlushClear(object ...$entities): void
29+
{
30+
foreach ($entities as $entity) {
31+
$this->_em->persist($entity);
32+
}
33+
34+
$this->_em->flush();
35+
$this->_em->clear();
36+
}
37+
}

tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/ChangeFiltersTest.php

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44

55
namespace Doctrine\Tests\ORM\Functional\Ticket\SwitchContextWithFilter;
66

7-
use Doctrine\Tests\OrmFunctionalTestCase;
7+
use Doctrine\Tests\ORM\Functional\Ticket\SwitchContextWithFilter\Entity\Order;
8+
use Doctrine\Tests\ORM\Functional\Ticket\SwitchContextWithFilter\Entity\User;
9+
use Doctrine\Tests\ORM\Functional\Ticket\SwitchContextWithFilter\SQLFilter\CompanySQLFilter;
810

9-
use function sprintf;
10-
use function str_replace;
11-
12-
final class ChangeFiltersTest extends OrmFunctionalTestCase
11+
final class ChangeFiltersTest extends AbstractTestCase
1312
{
1413
private const COMPANY_A = 'A';
1514
private const COMPANY_B = 'B';
@@ -130,11 +129,4 @@ public function testUseQueryBuilder(): void
130129
self::assertInstanceOf(User::class, $order->user);
131130
self::assertEquals($companyB['userId'], $order->user->id);
132131
}
133-
134-
private function generateMessage(string $message): string
135-
{
136-
$log = $this->getLastLoggedQuery();
137-
138-
return sprintf("%s\nSQL: %s", $message, str_replace(['?'], (array) $log['params'], $log['sql']));
139-
}
140132
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Tests\ORM\Functional\Ticket\SwitchContextWithFilter\Entity;
6+
7+
use Doctrine\ORM\Mapping as ORM;
8+
9+
#[ORM\Entity]
10+
class Insurance
11+
{
12+
#[ORM\Id]
13+
#[ORM\GeneratedValue]
14+
#[ORM\Column(type: 'integer')]
15+
public int $id;
16+
17+
#[ORM\Column(type: 'string')]
18+
public string $name;
19+
20+
#[ORM\ManyToOne(targetEntity: Practice::class)]
21+
public Practice $practice;
22+
23+
public function __construct(Practice $practice, string $name)
24+
{
25+
$this->practice = $practice;
26+
$this->name = $name;
27+
}
28+
}

tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/Order.php renamed to tests/Tests/ORM/Functional/Ticket/SwitchContextWithFilter/Entity/Order.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
declare(strict_types=1);
44

5-
namespace Doctrine\Tests\ORM\Functional\Ticket\SwitchContextWithFilter;
5+
namespace Doctrine\Tests\ORM\Functional\Ticket\SwitchContextWithFilter\Entity;
66

77
use Doctrine\ORM\Mapping as ORM;
88

0 commit comments

Comments
 (0)