Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
<?php

namespace wcf\data;

use wcf\system\database\exception\DatabaseQueryExecutionException;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\ClassNotFoundException;
use wcf\system\exception\ImplementationException;
use wcf\system\WCF;

/**
* Abstract builder for creating, updating and deleting database objects.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
* @since 6.3
*
* @template TDatabaseObject of DatabaseObject
*/
abstract class DatabaseObjectBuilder
{
/**
* @var array<string, string|int|float|null>
*/
protected array $properties = [];

/**
* @var array<string, string|int|float|null>
*/
protected array $customProperties = [];

/**
* Use forCreate() or forUpdate() to obtain a builder instance.
*
* @param ?TDatabaseObject $object
*/
private function __construct(protected readonly ?DatabaseObject $object = null) {}

/**
* Persists the pending changes and returns the resulting database object.
*
* @return TDatabaseObject
*/
public function save(): DatabaseObject

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and all other methods declared by DatabaseObjectBuilder should be declared as final to allow us to make adjustments to this crucial component.

{
return new (static::getBaseClass())($this->fastSave());
}

/**
* Persists the pending changes and returns the object's identifier without
* instantiating the full database object.
*/
public function fastSave(): int|string
{
if ($this->object !== null) {
$this->update();

return $this->object->getObjectID();
}

return $this->create();
}

/**
* Inserts a new row and returns the primary key of the created object.
*/
private function create(): int|string
{
$keys = $values = '';
$statementParameters = [];
foreach (array_merge($this->properties, $this->customProperties) as $key => $value) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be \array_merge(), not array_merge().

if ($keys !== '') {
$keys .= ',';
$values .= ',';
}

$keys .= $key;
$values .= '?';
$statementParameters[] = $value;
}

$sql = "INSERT INTO " . static::getBaseClass()::getDatabaseTableName() . "
(" . $keys . ")
VALUES (" . $values . ")";
$statement = WCF::getDB()->prepare($sql);
$statement->execute($statementParameters);

if (static::getBaseClass()::getDatabaseTableIndexIsIdentity()) {
$id = WCF::getDB()->getInsertID(static::getBaseClass()::getDatabaseTableName(), static::getBaseClass()::getDatabaseTableIndexName());
} elseif (isset($this->properties[static::getBaseClass()::getDatabaseTableIndexName()])) {
$id = $this->properties[static::getBaseClass()::getDatabaseTableIndexName()];
} else {
throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'");
}

return $id;
Comment on lines +89 to +97

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be untangled into two conditional returns and an unconditional exception. The return $id just obfuscates the flow.

}

/**
* Writes the pending property changes to the existing row.
*/
private function update(): void
{
if ($this->properties === [] && $this->customProperties === []) {
return;
}

$updateSQL = '';
$statementParameters = [];
foreach (array_merge($this->properties, $this->customProperties) as $key => $value) {
if ($updateSQL !== '') {
$updateSQL .= ', ';
}
$updateSQL .= $key . ' = ?';
$statementParameters[] = $value;
}
$statementParameters[] = $this->object->getObjectID();

$sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . "
SET " . $updateSQL . "
WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?";
$statement = WCF::getDB()->prepare($sql);
$statement->execute($statementParameters);
}

/**
* Creates a new object, returns null if the row already exists.
*
* @return ?TDatabaseObject
*/
public function createOrIgnore(): ?DatabaseObject
{
if ($this->object !== null) {
throw new \BadMethodCallException("createOrIgnore() can only be used with forCreate().");
}

try {
return $this->save();
} catch (DatabaseQueryExecutionException $e) {
// Error code 23000 = duplicate key
if (\intval($e->getCode()) === 23000 && $e->getDriverCode() === '1062') {
return null;
}

throw $e;
}
}

/**
* Deletes the given database object.
*
* @param TDatabaseObject $object
*/
public static function delete(DatabaseObject $object): void
{
static::deleteAll([$object->getObjectID()]);
}

/**
* Deletes the rows identified by the given primary keys in batches inside
* a single transaction.
*
* @param (string|int)[] $objectIDs
*/
public static function deleteAll(array $objectIDs = []): void
{
if ($objectIDs === []) {
return;
}

$itemsPerLoop = 1000;
$loopCount = \ceil(\count($objectIDs) / $itemsPerLoop);

WCF::getDB()->beginTransaction();
$committed = false;
try {
for ($i = 0; $i < $loopCount; $i++) {
$batchObjectIDs = \array_slice($objectIDs, $i * $itemsPerLoop, $itemsPerLoop);

$conditionBuilder = new PreparedStatementConditionBuilder();
$conditionBuilder->add(static::getBaseClass()::getDatabaseTableIndexName() . ' IN (?)', [$batchObjectIDs]);

$sql = "DELETE FROM " . static::getBaseClass()::getDatabaseTableName() . "
" . $conditionBuilder;
$statement = WCF::getDB()->prepare($sql);
$statement->execute($conditionBuilder->getParameters());
}
WCF::getDB()->commitTransaction();
$committed = true;
} finally {
if (!$committed) {
WCF::getDB()->rollBackTransaction();
}
}
}

/**
* Returns a builder instance for inserting a new row.
*/
public static function forCreate(): static
{
return new (static::class)();
}

/**
* Returns a builder instance for updating an existing database object.
*
* @param TDatabaseObject $object
*/
public static function forUpdate(DatabaseObject $object): static
{
return new static($object);
}

/**
* Resolves the database object class associated with this builder by
* stripping the `Builder` suffix from the current class name.
*
* @return class-string<DatabaseObject>
*/
public static function getBaseClass(): string
{
if (!\str_ends_with(static::class, 'Builder')) {
throw new \LogicException("Builder class '" . static::class . "' must end with the 'Builder' suffix.");
}

$className = \mb_substr(static::class, 0, -7);
if (!\class_exists($className)) {
throw new ClassNotFoundException($className);
}

if (!\is_subclass_of($className, DatabaseObject::class)) {
throw new ImplementationException($className, DatabaseObject::class);
}

return $className;
}

/**
* Sets a custom property value that is written alongside the regular
* properties when the object is persisted.
*/
public function setCustomProperty(string $name, string|int|float|null $value): static
{
$this->customProperties[$name] = $value;

return $this;
}
}
46 changes: 46 additions & 0 deletions wcfsetup/install/files/lib/data/tag/TagBuilder.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

namespace wcf\data\tag;

use wcf\data\DatabaseObjectBuilder;

/**
* Builder for creating, updating and deleting tags.
*
* @author Marcel Werk
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
* @since 6.3
*
* @extends DatabaseObjectBuilder<Tag>
*/
final class TagBuilder extends DatabaseObjectBuilder
{
public function setTagID(int $tagID): static

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This must not be available inside the forUpdate() flow. Maybe introduce a generic method DatabaseObjectBuilder::setID() that does that. After all, this is only ever required for (a) sessions and (b) data imports.

{
$this->properties['tagID'] = $tagID;

return $this;
}

public function setLanguageID(int $languageID): static
{
$this->properties['languageID'] = $languageID;

return $this;
}

public function setName(string $name): static
{
$this->properties['name'] = $name;

return $this;
}

public function setSynonymFor(Tag $tag): static
{
$this->properties['synonymFor'] = $tag->tagID;

return $this;
}
}
Loading