Skip to content

Commit 533cd54

Browse files
committed
Adding a @readonly annotation
The goal of this annotation is to make a column completely disappear from the constructor and from the setter. This is useful for computed/generated columns that are dynamically computed by the DBMS.
1 parent f50fbb6 commit 533cd54

10 files changed

+151
-34
lines changed

doc/annotations.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,28 @@ This annotation can be put on a column comment to alter the visibility of the "i
179179
For instance, if you put the `@ProtectedOneToMany` on the "country_id" column of a "users" table,
180180
then in the `Country` bean, the `getUsers()` method will be protected.
181181

182+
The @ReadOnly annotation
183+
-----------------------------------------------------
184+
<small>(Available in TDBM 5.2+)</small>
185+
186+
Columns with the "@ReadOnly" annotation cannot be written at all by TDBM.
187+
188+
Add this annotation to any column that is generated/computed in your database.
189+
190+
For instance:
191+
192+
```sql
193+
CREATE TABLE `products` (
194+
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
195+
`data` JSON NOT NULL,
196+
`names_virtual` VARCHAR(20) GENERATED ALWAYS AS (`data` ->> '$.name') NOT NULL COMMENT '@Generated',
197+
PRIMARY KEY (`id`)
198+
)
199+
```
200+
201+
Note: TDBM is based in Doctrine DBAL and Doctrine DBAL offers no way of knowing which columns are computed. So each time
202+
you have a generated column in your data model, you will need to put the `@Generated` annotation explicitly.
203+
182204
The @Json annotations
183205
---------------------
184206

phpstan.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ parameters:
2323
-
2424
message: '#TheCodingMachine\\TDBM\\Schema\\LockFileSchemaManager::__construct\(\) does not call parent constructor from Doctrine\\DBAL\\Schema\\AbstractSchemaManager.#'
2525
path: src/Schema/LockFileSchemaManager.php
26+
-
27+
message: '#is "array". Please provide a more specific .* annotation#'
28+
path: src/Test/Dao/Bean/Generated/PlayerBaseBean.php
2629
#reportUnmatchedIgnoredErrors: false
2730
includes:
2831
- vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon

src/Utils/AbstractBeanPropertyDescriptor.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace TheCodingMachine\TDBM\Utils;
55

66
use Doctrine\DBAL\Schema\Table;
7+
use TheCodingMachine\TDBM\Utils\Annotation\ReadOnly;
78
use Zend\Code\Generator\DocBlock\Tag\ParamTag;
89
use Zend\Code\Generator\MethodGenerator;
910

@@ -149,7 +150,7 @@ public function getTable(): Table
149150
/**
150151
* Returns the PHP code for getters and setters.
151152
*
152-
* @return MethodGenerator[]
153+
* @return (MethodGenerator|null)[]
153154
*/
154155
abstract public function getGetterSetterCode(): array;
155156

@@ -180,4 +181,12 @@ abstract public function getCloneRule(): ?string;
180181
* @return bool
181182
*/
182183
abstract public function isTypeHintable() : bool;
184+
185+
/**
186+
* Returns true if the property is tagged with the "ReadOnly" annotation.
187+
* ReadOnly annotations should be used on generated/computed database columns.
188+
*
189+
* @return bool
190+
*/
191+
abstract public function isReadOnly(): bool;
183192
}

src/Utils/Annotation/AnnotationParser.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public static function buildWithDefaultAnnotations(array $additionalAnnotations)
4343
'ProtectedGetter' => ProtectedGetter::class,
4444
'ProtectedSetter' => ProtectedSetter::class,
4545
'ProtectedOneToMany' => ProtectedOneToMany::class,
46+
'Generated' => ReadOnly::class,
4647
'JsonKey' => JsonKey::class,
4748
'JsonIgnore' => JsonIgnore::class,
4849
'JsonInclude' => JsonInclude::class,

src/Utils/Annotation/ReadOnly.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
namespace TheCodingMachine\TDBM\Utils\Annotation;
3+
4+
/**
5+
* Declares a column as "read-only".
6+
* Read-only columns cannot be set neither in a setter nor in a constructor argument.
7+
* They are very useful on generated/computed columns.
8+
*
9+
* This annotation can only be used in a database column comment.
10+
*
11+
* @Annotation
12+
*/
13+
final class ReadOnly
14+
{
15+
}

src/Utils/BeanDescriptor.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ public function getBeanPropertyDescriptors(): array
214214
*/
215215
public function getConstructorProperties(): array
216216
{
217-
$constructorProperties = array_filter($this->beanPropertyDescriptors, function (AbstractBeanPropertyDescriptor $property) {
218-
return !$property instanceof InheritanceReferencePropertyDescriptor && $property->isCompulsory();
217+
$constructorProperties = array_filter($this->beanPropertyDescriptors, static function (AbstractBeanPropertyDescriptor $property) {
218+
return !$property instanceof InheritanceReferencePropertyDescriptor && $property->isCompulsory() && !$property->isReadOnly();
219219
});
220220

221221
return $constructorProperties;

src/Utils/ObjectBeanPropertyDescriptor.php

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public function isPrimaryKey(): bool
148148
/**
149149
* Returns the PHP code for getters and setters.
150150
*
151-
* @return MethodGenerator[]
151+
* @return (MethodGenerator|null)[]
152152
*/
153153
public function getGetterSetterCode(): array
154154
{
@@ -177,17 +177,21 @@ public function getGetterSetterCode(): array
177177
$getter->setVisibility(AbstractMemberGenerator::VISIBILITY_PROTECTED);
178178
}
179179

180-
$setter = new MethodGenerator($setterName);
181-
$setter->setDocBlock(new DocBlockGenerator('The setter for the ' . $referencedBeanName . ' object bound to this object via the ' . implode(' and ', $this->foreignKey->getUnquotedLocalColumns()) . ' column.'));
180+
if (!$this->isReadOnly()) {
181+
$setter = new MethodGenerator($setterName);
182+
$setter->setDocBlock(new DocBlockGenerator('The setter for the ' . $referencedBeanName . ' object bound to this object via the ' . implode(' and ', $this->foreignKey->getUnquotedLocalColumns()) . ' column.'));
182183

183-
$setter->setParameter(new ParameterGenerator('object', ($isNullable ? '?' : '') . $this->beanNamespace . '\\' . $referencedBeanName));
184+
$setter->setParameter(new ParameterGenerator('object', ($isNullable ? '?' : '') . $this->beanNamespace . '\\' . $referencedBeanName));
184185

185-
$setter->setReturnType('void');
186+
$setter->setReturnType('void');
186187

187-
$setter->setBody('$this->setRef(' . var_export($tdbmFk->getCacheKey(), true) . ', $object, ' . var_export($tableName, true) . ');');
188+
$setter->setBody('$this->setRef(' . var_export($tdbmFk->getCacheKey(), true) . ', $object, ' . var_export($tableName, true) . ');');
188189

189-
if ($this->isSetterProtected()) {
190-
$setter->setVisibility(AbstractMemberGenerator::VISIBILITY_PROTECTED);
190+
if ($this->isSetterProtected()) {
191+
$setter->setVisibility(AbstractMemberGenerator::VISIBILITY_PROTECTED);
192+
}
193+
} else {
194+
$setter = null;
191195
}
192196

193197
return [$getter, $setter];
@@ -313,6 +317,11 @@ private function isSetterProtected(): bool
313317
return $this->findAnnotation(Annotation\ProtectedSetter::class) !== null;
314318
}
315319

320+
public function isReadOnly(): bool
321+
{
322+
return $this->findAnnotation(Annotation\ReadOnly::class) !== null;
323+
}
324+
316325
/**
317326
* @param string $type
318327
* @return null|object

src/Utils/ScalarBeanPropertyDescriptor.php

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ public function isPrimaryKey(): bool
207207
/**
208208
* Returns the PHP code for getters and setters.
209209
*
210-
* @return MethodGenerator[]
210+
* @return (MethodGenerator|null)[]
211211
*/
212212
public function getGetterSetterCode(): array
213213
{
@@ -260,26 +260,30 @@ public function getGetterSetterCode(): array
260260
$getter->setVisibility(AbstractMemberGenerator::VISIBILITY_PROTECTED);
261261
}
262262

263-
$setter = new MethodGenerator($columnSetterName);
264-
$setterDocBlock = new DocBlockGenerator(sprintf('The setter for the "%s" column.', $this->column->getName()));
265-
$setterDocBlock->setTag(new ParamTag($variableName, $types))->setWordWrap(false);
266-
$setter->setDocBlock($setterDocBlock);
263+
if (!$this->isReadOnly()) {
264+
$setter = new MethodGenerator($columnSetterName);
265+
$setterDocBlock = new DocBlockGenerator(sprintf('The setter for the "%s" column.', $this->column->getName()));
266+
$setterDocBlock->setTag(new ParamTag($variableName, $types))->setWordWrap(false);
267+
$setter->setDocBlock($setterDocBlock);
267268

268-
$parameter = new ParameterGenerator($variableName, $paramType);
269-
$setter->setParameter($parameter);
270-
$setter->setReturnType('void');
269+
$parameter = new ParameterGenerator($variableName, $paramType);
270+
$setter->setParameter($parameter);
271+
$setter->setReturnType('void');
271272

272-
$setter->setBody(sprintf(
273-
'%s
273+
$setter->setBody(sprintf(
274+
'%s
274275
$this->set(%s, $%s, %s);',
275-
$resourceTypeCheck,
276-
var_export($this->column->getName(), true),
277-
$variableName,
278-
var_export($this->table->getName(), true)
279-
));
280-
281-
if ($this->isSetterProtected()) {
282-
$setter->setVisibility(AbstractMemberGenerator::VISIBILITY_PROTECTED);
276+
$resourceTypeCheck,
277+
var_export($this->column->getName(), true),
278+
$variableName,
279+
var_export($this->table->getName(), true)
280+
));
281+
282+
if ($this->isSetterProtected()) {
283+
$setter->setVisibility(AbstractMemberGenerator::VISIBILITY_PROTECTED);
284+
}
285+
} else {
286+
$setter = null;
283287
}
284288

285289
return [$getter, $setter];
@@ -415,11 +419,12 @@ private function isSetterProtected(): bool
415419
return $this->findAnnotation(Annotation\ProtectedSetter::class) !== null;
416420
}
417421

418-
/**
419-
* @param string $type
420-
* @return null|object
421-
*/
422-
private function findAnnotation(string $type)
422+
public function isReadOnly(): bool
423+
{
424+
return $this->findAnnotation(Annotation\ReadOnly::class) !== null;
425+
}
426+
427+
private function findAnnotation(string $type): ?object
423428
{
424429
return $this->getAnnotations()->findAnnotation($type);
425430
}

tests/TDBMAbstractServiceTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,17 @@ private static function initSchema(Connection $connection): void
415415
$connection->exec($sqlStmt);
416416
}
417417

418+
// Let's generate computed columns
419+
if ($connection->getDatabasePlatform() instanceof MySqlPlatform) {
420+
$connection->exec('CREATE TABLE `players` (
421+
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
422+
`player_and_games` JSON NOT NULL,
423+
`names_virtual` VARCHAR(20) GENERATED ALWAYS AS (`player_and_games` ->> \'$.name\') NOT NULL COMMENT \'@Generated\',
424+
PRIMARY KEY (`id`)
425+
);
426+
');
427+
}
428+
418429
self::insert($connection, 'country', [
419430
'label' => 'France',
420431
]);

tests/TDBMDaoGeneratorTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
use TheCodingMachine\TDBM\Test\Dao\Bean\InheritedObjectBean;
6767
use TheCodingMachine\TDBM\Test\Dao\Bean\NodeBean;
6868
use TheCodingMachine\TDBM\Test\Dao\Bean\PersonBean;
69+
use TheCodingMachine\TDBM\Test\Dao\Bean\PlayerBean;
6970
use TheCodingMachine\TDBM\Test\Dao\Bean\RefNoPrimKeyBean;
7071
use TheCodingMachine\TDBM\Test\Dao\Bean\RoleBean;
7172
use TheCodingMachine\TDBM\Test\Dao\Bean\StateBean;
@@ -82,6 +83,7 @@
8283
use TheCodingMachine\TDBM\Test\Dao\InheritedObjectDao;
8384
use TheCodingMachine\TDBM\Test\Dao\NodeDao;
8485
use TheCodingMachine\TDBM\Test\Dao\PersonDao;
86+
use TheCodingMachine\TDBM\Test\Dao\PlayerDao;
8587
use TheCodingMachine\TDBM\Test\Dao\RefNoPrimKeyDao;
8688
use TheCodingMachine\TDBM\Test\Dao\RoleDao;
8789
use TheCodingMachine\TDBM\Test\Dao\StateDao;
@@ -2277,4 +2279,44 @@ public function testFindFromRawSQLOnInheritance(): void
22772279
$this->assertNotNull($objects->first());
22782280
$this->assertEquals(6, $objects->count());
22792281
}
2282+
2283+
public function testGeneratedColumnsAreNotPartOfTheConstructor(): void
2284+
{
2285+
$dao = new PlayerDao($this->tdbmService);
2286+
2287+
$player = new PlayerBean([
2288+
'id' => 1,
2289+
'name' => 'Sally',
2290+
'games_played' =>
2291+
[
2292+
'Battlefield' =>
2293+
[
2294+
'weapon' => 'sniper rifle',
2295+
'rank' => 'Sergeant V',
2296+
'level' => 20,
2297+
],
2298+
'Crazy Tennis' =>
2299+
[
2300+
'won' => 4,
2301+
'lost' => 1,
2302+
],
2303+
'Puzzler' =>
2304+
[
2305+
'time' => 7,
2306+
],
2307+
],
2308+
]);
2309+
2310+
$dao->save($player);
2311+
2312+
$this->assertTrue(true);
2313+
}
2314+
2315+
public function testCanReadVirtualColumn(): void
2316+
{
2317+
$dao = new PlayerDao($this->tdbmService);
2318+
2319+
$player = $dao->getById(1);
2320+
$this->assertSame('Sally', $player->getNamesVirtual());
2321+
}
22802322
}

0 commit comments

Comments
 (0)