Skip to content

Commit 681f175

Browse files
authored
Merge pull request #215 from moufmouf/fix/computedcolumns
Adding a @readonly annotation
2 parents f50fbb6 + bf2e068 commit 681f175

10 files changed

+172
-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 '@ReadOnly',
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 `@ReadOnly` 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+
'ReadOnly' => 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
use TheCodingMachine\TDBM\Utils\Annotation\AddInterface;
4444
use TheCodingMachine\TDBM\Utils\DefaultNamingStrategy;
4545
use TheCodingMachine\TDBM\Utils\PathFinder\PathFinder;
46+
use function stripos;
4647

4748
abstract class TDBMAbstractServiceTest extends TestCase
4849
{
@@ -415,6 +416,19 @@ private static function initSchema(Connection $connection): void
415416
$connection->exec($sqlStmt);
416417
}
417418

419+
// Let's generate computed columns
420+
if ($connection->getDatabasePlatform() instanceof MySqlPlatform && !self::isMariaDb($connection)) {
421+
$connection->exec('CREATE TABLE `players` (
422+
`id` INT UNSIGNED AUTO_INCREMENT NOT NULL,
423+
`player_and_games` JSON NOT NULL,
424+
`names_virtual` VARCHAR(20) GENERATED ALWAYS AS (`player_and_games` ->> \'$.name\') NOT NULL COMMENT \'@ReadOnly\',
425+
`animal_id` INT COMMENT \'@ReadOnly\',
426+
PRIMARY KEY (`id`),
427+
FOREIGN KEY (animal_id) REFERENCES animal(id)
428+
);
429+
');
430+
}
431+
418432
self::insert($connection, 'country', [
419433
'label' => 'France',
420434
]);
@@ -757,4 +771,13 @@ protected static function delete(Connection $connection, string $tableName, arra
757771
}
758772
$connection->delete($connection->quoteIdentifier($tableName), $quotedData);
759773
}
774+
775+
protected static function isMariaDb(Connection $connection): bool
776+
{
777+
if (!$connection->getDatabasePlatform() instanceof MySqlPlatform) {
778+
return false;
779+
}
780+
$version = $connection->fetchColumn('SELECT VERSION()');
781+
return stripos($version, 'maria') !== false;
782+
}
760783
}

tests/TDBMDaoGeneratorTest.php

Lines changed: 51 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;
@@ -90,6 +92,7 @@
9092
use TheCodingMachine\TDBM\Utils\PathFinder\PathFinder;
9193
use TheCodingMachine\TDBM\Utils\TDBMDaoGenerator;
9294
use Symfony\Component\Process\Process;
95+
use function get_class;
9396

9497
class TDBMDaoGeneratorTest extends TDBMAbstractServiceTest
9598
{
@@ -2277,4 +2280,52 @@ public function testFindFromRawSQLOnInheritance(): void
22772280
$this->assertNotNull($objects->first());
22782281
$this->assertEquals(6, $objects->count());
22792282
}
2283+
2284+
public function testGeneratedColumnsAreNotPartOfTheConstructor(): void
2285+
{
2286+
if (!$this->tdbmService->getConnection()->getDatabasePlatform() instanceof MySqlPlatform || self::isMariaDb($this->tdbmService->getConnection())) {
2287+
$this->markTestSkipped('ReadOnly column is only tested with MySQL');
2288+
}
2289+
2290+
$dao = new PlayerDao($this->tdbmService);
2291+
2292+
$player = new PlayerBean([
2293+
'id' => 1,
2294+
'name' => 'Sally',
2295+
'games_played' =>
2296+
[
2297+
'Battlefield' =>
2298+
[
2299+
'weapon' => 'sniper rifle',
2300+
'rank' => 'Sergeant V',
2301+
'level' => 20,
2302+
],
2303+
'Crazy Tennis' =>
2304+
[
2305+
'won' => 4,
2306+
'lost' => 1,
2307+
],
2308+
'Puzzler' =>
2309+
[
2310+
'time' => 7,
2311+
],
2312+
],
2313+
]);
2314+
2315+
$dao->save($player);
2316+
2317+
$this->assertTrue(true);
2318+
}
2319+
2320+
public function testCanReadVirtualColumn(): void
2321+
{
2322+
if (!$this->tdbmService->getConnection()->getDatabasePlatform() instanceof MySqlPlatform || self::isMariaDb($this->tdbmService->getConnection())) {
2323+
$this->markTestSkipped('ReadOnly column is only tested with MySQL');
2324+
}
2325+
2326+
$dao = new PlayerDao($this->tdbmService);
2327+
2328+
$player = $dao->getById(1);
2329+
$this->assertSame('Sally', $player->getNamesVirtual());
2330+
}
22802331
}

0 commit comments

Comments
 (0)