From 04dbaec67d74d0ed2c95f7463840093cf1c39368 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sat, 26 Sep 2026 00:54:38 +0200 Subject: [PATCH] feat(db): add conflict tolerant inserts to query builder and QBMapper Signed-off-by: Kostiantyn Miakshyn --- lib/public/AppFramework/Db/QBMapper.php | 36 ++++++++++ tests/lib/AppFramework/Db/QBMapperDBTest.php | 19 ++++++ tests/lib/AppFramework/Db/QBMapperTest.php | 69 ++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/lib/public/AppFramework/Db/QBMapper.php b/lib/public/AppFramework/Db/QBMapper.php index 1fde5c71aaa04..c40f82e531bf9 100644 --- a/lib/public/AppFramework/Db/QBMapper.php +++ b/lib/public/AppFramework/Db/QBMapper.php @@ -153,6 +153,42 @@ public function insertOrUpdate(Entity $entity): Entity { } } + /** + * Creates a new entry in the db from an entity, ignoring conflicts on + * unique constraints + * + * @param Entity $entity the entity that should be created + * @psalm-param T $entity the entity that should be created + * @return int number of inserted rows (0 if a conflicting row already exists) + * @throws Exception + * @since 36.0.0 + */ + public function insertIgnoreConflict(Entity $entity): int { + if ($entity instanceof SnowflakeAwareEntity) { + /** @psalm-suppress DocblockTypeContradiction */ + $entity->generateId(); + } + + // get updated fields to save, fields have to be set using a setter to + // be saved + $properties = $entity->getUpdatedFields(); + + $values = []; + + // build the fields + foreach ($properties as $property => $updated) { + if ($property === 'id' && $entity->id === null) { + continue; + } + + $column = $entity->propertyToColumn($property); + $getter = 'get' . ucfirst($property); + $values[$column] = $entity->$getter(); + } + + return $this->db->insertIgnoreConflict($this->tableName, $values); + } + /** * Updates an entry in the db from an entity * diff --git a/tests/lib/AppFramework/Db/QBMapperDBTest.php b/tests/lib/AppFramework/Db/QBMapperDBTest.php index 24b8f06e6ce63..063f0595b01b4 100644 --- a/tests/lib/AppFramework/Db/QBMapperDBTest.php +++ b/tests/lib/AppFramework/Db/QBMapperDBTest.php @@ -127,6 +127,25 @@ public function testUpdateDateTime(): void { $this->assertEquals($datetime->format('Y-m-d H:i:s'), $dbEntity->getDatetime()->format('Y-m-d H:i:s')); } + public function testInsertIgnoreConflict(): void { + $mapper = new QBDBTestMapper($this->connection); + + $entity = new class extends Entity { + protected ?string $datetime = null; + }; + $entity->setId(200001); + $entity->setDatetime('2000-01-01 23:45:00'); + $this->assertSame(1, $mapper->insertIgnoreConflict($entity)); + + $conflicting = clone $entity; + $conflicting->setDatetime('2010-05-05 05:05:05'); + $this->assertSame(0, $mapper->insertIgnoreConflict($conflicting)); + + // the conflicting insert must not have modified the existing row + $dbEntity = $mapper->getById(200001); + $this->assertEquals('2000-01-01 23:45:00', $dbEntity->getDatetime()->format('Y-m-d H:i:s')); + } + protected function prepareTestingTable(): void { if ($this->schemaSetup) { $this->connection->getQueryBuilder()->delete('testing')->executeStatement(); diff --git a/tests/lib/AppFramework/Db/QBMapperTest.php b/tests/lib/AppFramework/Db/QBMapperTest.php index 09cfee14720d7..81566b7d3f920 100644 --- a/tests/lib/AppFramework/Db/QBMapperTest.php +++ b/tests/lib/AppFramework/Db/QBMapperTest.php @@ -9,6 +9,7 @@ use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Db\SnowflakeAwareEntity; use OCP\DB\QueryBuilder\IExpressionBuilder; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\Types; @@ -212,6 +213,74 @@ public function testUpdateEntityParameterTypeMapping(): void { $this->mapper->update($entity); } + public function testInsertIgnoreConflictEntityValueMapping(): void { + $entity = new QBTestEntity(); + $entity->setIntProp(123); + $entity->setBoolProp(true); + $entity->setStringProp('string'); + $entity->setIntegerProp(456); + $entity->setBooleanProp(false); + + $this->db->expects($this->never())->method('getQueryBuilder'); + $this->db->expects($this->once()) + ->method('insertIgnoreConflict') + ->with('table', [ + 'int_prop' => 123, + 'bool_prop' => true, + 'string_prop' => 'string', + 'integer_prop' => 456, + 'boolean_prop' => false, + ]) + ->willReturn(1); + + $this->assertSame(1, $this->mapper->insertIgnoreConflict($entity)); + } + + public function testInsertIgnoreConflictReturnsZero(): void { + $entity = new QBTestEntity(); + $entity->setId(123); + $entity->setStringProp('string'); + + $this->db->expects($this->once()) + ->method('insertIgnoreConflict') + ->with('table', ['id' => 123, 'string_prop' => 'string']) + ->willReturn(0); + + $this->assertSame(0, $this->mapper->insertIgnoreConflict($entity)); + } + + public function testInsertIgnoreConflictSkipsNullId(): void { + $entity = new QBTestEntity(); + $entity->setId(123); + $entity->setId(null); + $entity->setStringProp('string'); + + $this->db->expects($this->once()) + ->method('insertIgnoreConflict') + ->with('table', ['string_prop' => 'string']) + ->willReturn(1); + + $this->assertSame(1, $this->mapper->insertIgnoreConflict($entity)); + } + + public function testInsertIgnoreConflictGeneratesSnowflakeId(): void { + $entity = new class extends SnowflakeAwareEntity { + #[\Override] + public function generateId(): void { + $this->id = '123'; + $this->markFieldUpdated('id'); + } + }; + + $this->db->expects($this->once()) + ->method('insertIgnoreConflict') + ->with('table', ['id' => '123']) + ->willReturn(1); + + $this->assertSame(1, $this->mapper->insertIgnoreConflict($entity)); + $this->assertSame('123', $entity->getId()); + } + public function testGetParameterTypeForProperty(): void { $entity = new QBTestEntity();