feat(entity): Attributes for Entity

Signed-off-by: Carl Schwan <carl.schwan@nextcloud.com>
This commit is contained in:
Carl Schwan 2025-11-04 16:01:41 +01:00
parent df8d838186
commit 14cd485ce2
6 changed files with 226 additions and 70 deletions

View file

@ -8,9 +8,14 @@ declare(strict_types=1);
*/
namespace OCA\TwoFactorBackupCodes\Db;
use OCP\AppFramework\Db\Attribute\Column;
use OCP\AppFramework\Db\Attribute\Table;
use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;
/**
* @method string getId()
* @method void setId(string $id)
* @method string getUserId()
* @method void setUserId(string $userId)
* @method string getCode()
@ -18,14 +23,14 @@ use OCP\AppFramework\Db\Entity;
* @method int getUsed()
* @method void setUsed(int $code)
*/
#[Table(name: 'twofactor_backupcodes', useSnowflakeId: true)]
class BackupCode extends Entity {
#[Column(name: 'user_id', type: Types::STRING, length: 64, nullable: false)]
protected ?string $userId = null;
/** @var string */
protected $userId;
#[Column(name: 'code', type: Types::STRING, length: 128, nullable: false)]
protected ?string $code = null;
/** @var string */
protected $code;
/** @var int */
protected $used;
#[Column(name: 'used', type: Types::SMALLINT, nullable: false)]
protected ?int $used = null;
}

View file

@ -7,11 +7,16 @@
*/
namespace OC\Tagging;
use OCP\AppFramework\Db\Attribute\Column;
use OCP\AppFramework\Db\Attribute\Table;
use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;
/**
* Class to represent a tag.
*
* @method string getId()
* @method void setId(string $id)
* @method string getOwner()
* @method void setOwner(string $owner)
* @method string getType()
@ -19,59 +24,29 @@ use OCP\AppFramework\Db\Entity;
* @method string getName()
* @method void setName(string $name)
*/
#[Table(name: 'vcategory', useSnowflakeId: true)]
class Tag extends Entity {
protected $owner;
protected $type;
protected $name;
#[Column(name: 'uid', type: Types::STRING, length: 64, nullable: false)]
protected ?string $owner = null;
#[Column(name: 'type', type: Types::STRING, length: 64, nullable: false)]
protected ?string $type = null;
#[Column(name: 'category', type: Types::STRING, length: 255, nullable: false)]
protected ?string $name = null;
/**
* Constructor.
*
* @param string $owner The tag's owner
* @param string $type The type of item this tag is used for
* @param string $name The tag's name
* @param ?string $owner The tag's owner
* @param ?string $type The type of item this tag is used for
* @param ?string $name The tag's name
*/
public function __construct($owner = null, $type = null, $name = null) {
public function __construct(?string $owner = null, ?string $type = null, ?string $name = null) {
parent::__construct();
$this->setOwner($owner);
$this->setType($type);
$this->setName($name);
}
/**
* Transform a database columnname to a property
*
* @param string $columnName the name of the column
* @return string the property name
* @todo migrate existing database columns to the correct names
* to be able to drop this direct mapping
*/
public function columnToProperty(string $columnName): string {
if ($columnName === 'category') {
return 'name';
}
if ($columnName === 'uid') {
return 'owner';
}
return parent::columnToProperty($columnName);
}
/**
* Transform a property to a database column name
*
* @param string $property the name of the property
* @return string the column name
*/
public function propertyToColumn(string $property): string {
if ($property === 'name') {
return 'category';
}
if ($property === 'owner') {
return 'uid';
}
return parent::propertyToColumn($property);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\AppFramework\Db\Attribute;
use Attribute;
use OCP\AppFramework\Attribute\Consumable;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
#[Attribute(Attribute::TARGET_PROPERTY)]
#[Consumable(since: '33.0.0')]
final readonly class Column {
public function __construct(
public string $name,
public string|null $type,
public int|null $length = null,
public bool $nullable = false,
) {
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH
* SPDX-FileContributor: Carl Schwan
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\AppFramework\Db\Attribute;
use Attribute;
use OCP\AppFramework\Attribute\Consumable;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
#[Attribute(Attribute::TARGET_CLASS)]
#[Consumable(since: '33.0.0')]
final readonly class Table {
public function __construct(
public string $name,
public bool $useSnowflakeId = false,
) {
}
}

View file

@ -7,6 +7,8 @@
*/
namespace OCP\AppFramework\Db;
use OCP\AppFramework\Db\Attribute\Column;
use OCP\AppFramework\Db\Attribute\Table;
use OCP\DB\Types;
use function lcfirst;
@ -19,12 +21,44 @@ use function substr;
* @psalm-consistent-constructor
*/
abstract class Entity {
/** @var int $id */
public $id = null;
#[Column(name: 'id', type: Types::BIGINT)]
public string|int|null $id = null;
/** @var array<string, bool> */
private array $_updatedFields = [];
/** @var array<string, \OCP\DB\Types::*> */
private array $_fieldTypes = ['id' => 'integer'];
private array $_fieldTypes = ['id' => Types::INTEGER];
/** @var array<string, string> */
private array $_mappingColumnToProperty = [];
/** @var array<string, string> */
private array $_mappingPropertyToColumn = [];
public function __construct() {
$reflection = new \ReflectionObject($this);
foreach ($reflection->getProperties() as $property) {
$columnAttributes = $property->getAttributes(Column::class);
if (count($columnAttributes) > 0) {
/** @var Column $columnAttribute */
$columnAttribute = $columnAttributes[0];
$this->_fieldTypes[$property->name] = $columnAttribute->type;
$this->_mappingColumnToProperty[$columnAttribute->name] = $property->name;
$this->_mappingPropertyToColumn[$property->name] = $columnAttribute->name;
}
}
$tableAttributes =$reflection->getAttributes(Table::class);
if (count($tableAttributes) > 0) {
/** @var Table $tableAttribute */
$tableAttribute = $tableAttributes[0];
if ($tableAttribute->useSnowflakeId) {
$this->_fieldTypes['id'] = Types::STRING;
}
}
}
/**
* Simple alternative constructor for building entities from a request
@ -101,6 +135,7 @@ abstract class Entity {
// if type definition exists, cast to correct type
if ($args[0] !== null && array_key_exists($name, $this->_fieldTypes)) {
$type = $this->_fieldTypes[$name];
if ($type === Types::BLOB) {
// (B)LOB is treated as string when we read from the DB
if (is_resource($args[0])) {
@ -212,11 +247,16 @@ abstract class Entity {
* @param string $columnName the name of the column
* @return string the property name
* @since 7.0.0
* @deprecated Use Column attribute to map a property to a column
*/
public function columnToProperty(string $columnName) {
$parts = explode('_', $columnName);
$property = '';
if (isset($this->_mappingColumnToProperty[$columnName])) {
return $this->_mappingColumnToProperty[$columnName];
}
foreach ($parts as $part) {
if ($property === '') {
$property = $part;
@ -235,10 +275,15 @@ abstract class Entity {
* @param string $property the name of the property
* @return string the column name
* @since 7.0.0
* @deprecated Use Column attribute to map a property to a column
*/
public function propertyToColumn(string $property): string {
$parts = preg_split('/(?=[A-Z])/', $property);
if (isset($this->_mappingPropertyToColumn[$property])) {
return $this->_mappingPropertyToColumn[$property];
}
$column = '';
foreach ($parts as $part) {
if ($column === '') {

View file

@ -7,11 +7,17 @@ declare(strict_types=1);
*/
namespace OCP\AppFramework\Db;
use BadMethodCallException;
use Generator;
use OCP\AppFramework\Db\Attribute\Table;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
use OCP\IDBConnection;
use OCP\Server;
use OCP\Snowflake\IGenerator;
use ReflectionObject;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
/**
* Simple parent class for inheriting your data access layer from. This class
@ -22,14 +28,8 @@ use OCP\IDBConnection;
* @template T of Entity
*/
abstract class QBMapper {
/** @var string */
protected $tableName;
/** @var string|class-string<T> */
protected $entityClass;
/** @var IDBConnection */
protected $db;
protected string $entityClass;
/**
* @param IDBConnection $db Instance of the Db abstraction layer
@ -38,10 +38,11 @@ abstract class QBMapper {
* mapped to queries without using sql
* @since 14.0.0
*/
public function __construct(IDBConnection $db, string $tableName, ?string $entityClass = null) {
$this->db = $db;
$this->tableName = $tableName;
public function __construct(
protected IDBConnection $db,
protected string $tableName,
?string $entityClass = null,
) {
// if not given set the entity name to the class without the mapper part
// cache it here for later use since reflection is slow
if ($entityClass === null) {
@ -51,7 +52,6 @@ abstract class QBMapper {
}
}
/**
* @return string the table name
* @since 14.0.0
@ -60,7 +60,6 @@ abstract class QBMapper {
return $this->tableName;
}
/**
* Deletes an entity from the table
*
@ -99,6 +98,18 @@ abstract class QBMapper {
// be saved
$properties = $entity->getUpdatedFields();
if ($entity->id === null) {
$reflection = new ReflectionObject($entity);
$tables = $reflection->getAttributes(Table::class);
if (count($tables) > 0) {
/** @var Table $table */
$table = $tables[0];
if ($table->useSnowflakeId) {
$entity->id = Server::get(IGenerator::class);
}
}
}
$qb = $this->db->getQueryBuilder();
$qb->insert($this->tableName);
@ -359,8 +370,8 @@ abstract class QBMapper {
/**
* Returns an db result and throws exceptions when there are more or less
* results
* Returns a db result and throws exceptions when there are more or less
* results.
*
* @param IQueryBuilder $query
* @return Entity the entity
@ -373,4 +384,70 @@ abstract class QBMapper {
protected function findEntity(IQueryBuilder $query): Entity {
return $this->mapRowToEntity($this->findOneQuery($query));
}
/**
* Finds all entities in the repository.
*
* @return \Generator<T>
* @since 33.0.0
*/
public function findAll(): \Generator {
return $this->findBy([]);
}
/**
* Finds entities by a set of criteria.
*
* @param array<string, int|float|string> $criteria
* @param array<string, 'asc'|'desc'>|null $orderBy
* @return \Generator<T>
* @since 33.0.0
*/
public function findBy(array $criteria, array|null $orderBy = null, int|null $limit = null, int|null $offset = null): \Generator {
$qb = $this->db->getQueryBuilder();
$qb->select('*');
foreach ($criteria as $field => $value) {
$qb->andWhere($qb->expr()->eq($field, $qb->createNamedParameter($field)));
}
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy($qb->createNamedParameter($field), $direction);
}
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
return $this->yieldEntities($qb);
}
/**
* Finds a single entity by a set of criteria.
*
* @param array<string, int|float|string> $criteria
* @param array<string, 'asc'|'desc'>|null $orderBy
* @return T|null
* @since 33.0.0
*/
public function findOneBy(array $criteria, array|null $orderBy = null): Entity|null {
$qb = $this->db->getQueryBuilder();
$qb->select('*');
foreach ($criteria as $field => $value) {
$qb->andWhere($qb->expr()->eq($field, $qb->createNamedParameter($field)));
}
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy($qb->createNamedParameter($field), $direction);
}
$qb->setMaxResults(1);
try {
return $this->findEntity($qb);
} catch (DoesNotExistException) {
return null;
}
}
}