Skip to content

Commit abafdee

Browse files
committed
feat(entity): Add support for composite primary key
Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Carl Schwan <carl@carlschwan.eu>
1 parent 476c196 commit abafdee

6 files changed

Lines changed: 200 additions & 37 deletions

File tree

apps/comments/composer/composer/installed.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
'name' => '__root__',
44
'pretty_version' => 'dev-master',
55
'version' => 'dev-master',
6-
'reference' => '85618b795280107ce542bff646bc0957bc09a452',
6+
'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b',
77
'type' => 'library',
88
'install_path' => __DIR__ . '/../',
99
'aliases' => array(),
@@ -13,7 +13,7 @@
1313
'__root__' => array(
1414
'pretty_version' => 'dev-master',
1515
'version' => 'dev-master',
16-
'reference' => '85618b795280107ce542bff646bc0957bc09a452',
16+
'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b',
1717
'type' => 'library',
1818
'install_path' => __DIR__ . '/../',
1919
'aliases' => array(),

lib/private/AppFramework/ORM/EntityInfo.php

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ final class EntityInfo {
3333
/** @var \ReflectionClass<T> */
3434
public readonly \ReflectionClass $reflection;
3535

36-
public ?\ReflectionProperty $idProperty = null;
36+
/**
37+
* All properties carrying an #[Id] attribute, in declaration order. More than one entry
38+
* means the entity has a composite primary key.
39+
*
40+
* @var list<\ReflectionProperty>
41+
*/
42+
public array $idProperties = [];
3743

3844
/**
3945
* @var list<PropertyAttributes> $propertiesAttributes
@@ -68,7 +74,7 @@ public function __construct(
6874
$this->mappingPropertyToColumn[$property->getName()] = $instance->name;
6975
} elseif ($instance instanceof Id) {
7076
$propertyAttributes->id = $instance;
71-
$this->idProperty = $property;
77+
$this->idProperties[] = $property;
7278
} elseif ($instance instanceof OneToOne) {
7379
$propertyAttributes->oneToOne = $instance;
7480
} elseif ($instance instanceof ManyToOne) {
@@ -96,17 +102,37 @@ public function __construct(
96102
$this->propertiesAttributes[] = $propertyAttributes;
97103
}
98104

99-
if (!$this->idProperty instanceof \ReflectionProperty) {
105+
if ($this->idProperties === []) {
100106
throw new \RuntimeException($this->entityClass . ' does not have a primary key. This is not supported for repositories backed tables.');
101107
}
102108
}
103109

104-
public function getIdProperty(): \ReflectionProperty {
105-
if (!$this->idProperty instanceof \ReflectionProperty) {
106-
throw new \LogicException('Unreachable: the constructor already guarantees idProperty is set.');
110+
/**
111+
* @return non-empty-list<\ReflectionProperty>
112+
*/
113+
public function getIdProperties(): array {
114+
if ($this->idProperties === []) {
115+
throw new \LogicException('Unreachable: the constructor already guarantees idProperties is not empty.');
116+
}
117+
118+
return $this->idProperties;
119+
}
120+
121+
public function hasCompositeIdProperty(): bool {
122+
return count($this->idProperties) > 1;
123+
}
124+
125+
/**
126+
* Convenience accessor for code paths (e.g. relation joins) that only support entities with
127+
* a single-column primary key.
128+
*/
129+
public function getSingleIdProperty(): \ReflectionProperty {
130+
$idProperties = $this->getIdProperties();
131+
if (count($idProperties) > 1) {
132+
throw new \LogicException($this->entityClass . ' has a composite primary key, which is not supported here.');
107133
}
108134

109-
return $this->idProperty;
135+
return $idProperties[0];
110136
}
111137

112138
/**

lib/private/AppFramework/ORM/EntityManager.php

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ public function insert(object $entity): object {
7070
$entityInfo = $this->getEntityInfo($entity::class);
7171
$insert = $this->connection->getQueryBuilder();
7272

73-
$isSnowflake = false;
73+
$isComposite = $entityInfo->hasCompositeIdProperty();
74+
$autoIncrementProperty = null;
7475
$values = [];
7576

7677
foreach ($entityInfo->propertiesAttributes as $propertyAttributes) {
@@ -80,12 +81,33 @@ public function insert(object $entity): object {
8081
if ($generatorClass) {
8182
if ($generatorClass === ISnowflakeGenerator::class) {
8283
$generator = Server::get($generatorClass);
83-
$isSnowflake = true;
8484
$values[$propertyAttributes->column->name] = $generator->nextId();
8585
$property->setValue($entity, $insert->createNamedParameter($values[$propertyAttributes->column->name]));
8686
}
87+
88+
continue;
8789
}
8890

91+
if ($isComposite) {
92+
// A composite primary key can't rely on a single autoincrement column: every
93+
// part must already be set on the entity (e.g. a foreign key id, or a value
94+
// assigned by the caller) before insert() is called.
95+
/** @var mixed $value */
96+
$value = $property->getValue($entity);
97+
if ($value === null) {
98+
throw new \LogicException($entity::class . '::' . $property->getName() . ' is part of a composite primary key and must be set before insert(); it cannot rely on DB autoincrement.');
99+
}
100+
if (!is_string($value) && !is_int($value)) {
101+
throw new \LogicException($entity::class . '::' . $property->getName() . ' is part of a composite primary key and must be set to a int or string before insert();.');
102+
}
103+
104+
$type = $this->getParameterType($propertyAttributes->column->type, false);
105+
$values[$propertyAttributes->column->name] = $insert->createNamedParameter($value, $type);
106+
continue;
107+
}
108+
109+
// Single autoincrement primary key: let the DB generate it, then read it back below.
110+
$autoIncrementProperty = $property;
89111
continue;
90112
}
91113

@@ -106,7 +128,7 @@ public function insert(object $entity): object {
106128
if ($targetEntity === null) {
107129
$values[$joinColumn->name] = $insert->createNamedParameter(null);
108130
} else {
109-
$values[$joinColumn->name] = $insert->createNamedParameter($targetEntityInfo->getIdProperty()->getValue($targetEntity));
131+
$values[$joinColumn->name] = $insert->createNamedParameter($targetEntityInfo->getSingleIdProperty()->getValue($targetEntity));
110132
}
111133

112134
continue;
@@ -122,8 +144,8 @@ public function insert(object $entity): object {
122144
->values($values)
123145
->executeStatement();
124146

125-
if (!$isSnowflake) {
126-
$entityInfo->getIdProperty()->setValue($entity, $insert->getLastInsertId());
147+
if ($autoIncrementProperty !== null) {
148+
$autoIncrementProperty->setValue($entity, $insert->getLastInsertId());
127149
}
128150

129151
return $entity;
@@ -151,7 +173,7 @@ public function update(object $entity): object {
151173
throw new \LogicException('Trying to update an entity with no primary key set.');
152174
}
153175

154-
$update->andWhere($update->expr()->eq($entityInfo->mappingPropertyToColumn[$entityInfo->getIdProperty()->getName()], $update->createNamedParameter($property->getValue($entity))));
176+
$update->andWhere($update->expr()->eq($propertyAttributes->column->name, $update->createNamedParameter($value)));
155177
// don't update the id
156178
continue;
157179
}
@@ -169,7 +191,7 @@ public function update(object $entity): object {
169191
if ($targetEntity === null) {
170192
$update->set($joinColumn->name, $update->createNamedParameter(null));
171193
} else {
172-
$update->set($joinColumn->name, $update->createNamedParameter($targetEntityInfo->getIdProperty()->getValue($targetEntity)));
194+
$update->set($joinColumn->name, $update->createNamedParameter($targetEntityInfo->getSingleIdProperty()->getValue($targetEntity)));
173195
}
174196

175197
continue;
@@ -266,11 +288,19 @@ public function createTable(string $entityClass, SchemaWrapper $schema): void {
266288

267289
$table = $schema->createTable($entityInfo->tableName);
268290

291+
/** @var list<string> $idColumns */
292+
$idColumns = [];
269293
foreach ($entityInfo->propertiesAttributes as $propertyAttributes) {
270-
$this->createProperty($propertyAttributes, $table);
294+
$this->createProperty($entityInfo, $propertyAttributes, $table);
295+
296+
if ($propertyAttributes->id instanceof Id && $propertyAttributes->column instanceof Column) {
297+
$idColumns[] = $propertyAttributes->column->name;
298+
}
271299

272300
$this->createRelationColumn($propertyAttributes, $table, $schema);
273301
}
302+
303+
$table->setPrimaryKey($idColumns);
274304
}
275305

276306
/**
@@ -281,7 +311,7 @@ public function dropTable(string $entityClass, string $prefix): void {
281311
$this->connection->dropTable($prefix . $entityInfo->tableName);
282312
}
283313

284-
private function createProperty(PropertyAttributes $attributes, Table $table): void {
314+
private function createProperty(EntityInfo $entityInfo, PropertyAttributes $attributes, Table $table): void {
285315
if (!$attributes->column instanceof Column) {
286316
return;
287317
}
@@ -299,15 +329,12 @@ private function createProperty(PropertyAttributes $attributes, Table $table): v
299329
$options['default'] = $columnAttribute->default;
300330
}
301331

302-
if ($attributes->id instanceof Id && $attributes->id->generatorClass === null) {
332+
// A composite primary key can't rely on a single autoincrement column; see insert().
333+
if ($attributes->id instanceof Id && $attributes->id->generatorClass === null && !$entityInfo->hasCompositeIdProperty()) {
303334
$options['autoincrement'] = true;
304335
}
305336

306337
$table->addColumn($columnAttribute->name, $columnAttribute->type, $options);
307-
308-
if ($attributes->id instanceof Id) {
309-
$table->setPrimaryKey([$columnAttribute->name]);
310-
}
311338
}
312339

313340
private function createRelationColumn(PropertyAttributes $attributes, Table $table, SchemaWrapper $schema): void {

lib/public/AppFramework/ORM/Attribute/Id.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
use OCP\Snowflake\ISnowflakeGenerator;
1313

1414
/**
15-
* Attribute for marking a column as a primary id.
15+
* Attribute for marking a column as (part of) the primary key.
1616
*
1717
* ```php
1818
* #[Entity(name: 'my_entity']
@@ -23,6 +23,23 @@
2323
* }
2424
* ```
2525
*
26+
* Applying #[Id] to more than one property declares a composite primary key. In that case every
27+
* id property must have its value set before calling `insert()` (via `generatorClass`, or
28+
* assigned by the caller), since a composite key cannot rely on a single autoincrement column:
29+
*
30+
* ```php
31+
* #[Entity(name: 'my_join_entity']
32+
* final class MyJoinEntity {
33+
* #[Id]
34+
* #[Column(name: 'left_id', type: Types::BIGINT)]
35+
* public int $leftId;
36+
*
37+
* #[Id]
38+
* #[Column(name: 'right_id', type: Types::BIGINT)]
39+
* public int $rightId;
40+
* }
41+
* ```
42+
*
2643
* @since 35.0.0
2744
*/
2845
#[Attribute(Attribute::TARGET_PROPERTY)]

lib/public/AppFramework/ORM/Repository.php

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
use OC\AppFramework\ORM\PropertyAttributes;
1313
use OCP\AppFramework\Db\DoesNotExistException;
1414
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
15-
use OCP\AppFramework\ORM\Attribute\Id;
1615
use OCP\DB\Exception;
1716
use OCP\DB\QueryBuilder\IQueryBuilder;
1817
use OCP\DB\Types;
@@ -84,17 +83,9 @@ private function hydrateRow(string $entityClass, mixed $row): object {
8483
$type = Types::STRING;
8584
}
8685

87-
if ($column === $entityInfo->getIdProperty()->getName()) {
88-
$ids = $entityInfo->getIdProperty()->getAttributes(Id::class, \ReflectionAttribute::IS_INSTANCEOF);
89-
$id = array_shift($ids);
90-
if ($id === null) {
91-
throw new \LogicException('Unreachable: the id property is missing its #[Id] attribute.');
92-
}
93-
94-
if ($id->newInstance()->generatorClass !== null) {
95-
$entity->$property = (string)$value;
96-
continue;
97-
}
86+
if ($this->isGeneratedIdColumn($entityInfo, $column)) {
87+
$entity->$property = (string)$value;
88+
continue;
9889
}
9990

10091
/** @psalm-suppress DeprecatedConstant Types::JSON is only discouraged in WHERE clauses; mapping it is still supported. */
@@ -153,6 +144,16 @@ private function hydrateRow(string $entityClass, mixed $row): object {
153144
return $entity;
154145
}
155146

147+
private function isGeneratedIdColumn(EntityInfo $entityInfo, string $column): bool {
148+
foreach ($entityInfo->propertiesAttributes as $propertyAttributes) {
149+
if ($propertyAttributes->id !== null && $propertyAttributes->column?->name === $column) {
150+
return $propertyAttributes->id->generatorClass !== null;
151+
}
152+
}
153+
154+
return false;
155+
}
156+
156157
/**
157158
* Builds a select query resolving OneToOne and ManyToOne relations via a LEFT JOIN.
158159
* Columns are aliased `e_<column>` (main entity) and `r<index>_<column>` (each relation)
@@ -274,7 +275,7 @@ private function mapJoinedRowToEntity(array $relations, mixed $row): object {
274275
foreach ($relations as $alias => $relation) {
275276
$propertyName = $relation['attributes']->property->getName();
276277
$targetEntityInfo = $relation['entityInfo'];
277-
$idColumn = $targetEntityInfo->mappingPropertyToColumn[$targetEntityInfo->getIdProperty()->getName()];
278+
$idColumn = $targetEntityInfo->mappingPropertyToColumn[$targetEntityInfo->getSingleIdProperty()->getName()];
278279
$relationRow = $relationRows[$alias] ?? [];
279280

280281
if (($relationRow[$idColumn] ?? null) === null) {

0 commit comments

Comments
 (0)