From f8dbec95d4700c741e793d458ce8e3b0ae3d21eb Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 15 Jun 2026 11:35:41 +0200 Subject: [PATCH] Add `DatabaseObjectBuilder` with `TagBuilder` implementation Introduces an abstract builder for creating, updating and deleting database objects with a fluent setter API, batched transactional deletes and an `INSERT IGNORE`-style helper. `TagBuilder` is the first concrete implementation. --- .../lib/data/DatabaseObjectBuilder.class.php | 250 ++++++++++++++++++ .../files/lib/data/tag/TagBuilder.class.php | 46 ++++ 2 files changed, 296 insertions(+) create mode 100644 wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php create mode 100644 wcfsetup/install/files/lib/data/tag/TagBuilder.class.php diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php new file mode 100644 index 0000000000..89ec16810f --- /dev/null +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -0,0 +1,250 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + */ +abstract class DatabaseObjectBuilder +{ + /** + * @var array + */ + protected array $properties = []; + + /** + * @var array + */ + 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 + { + 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) { + 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; + } + + /** + * 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 + */ + 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; + } +} diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php new file mode 100644 index 0000000000..882ee6f3de --- /dev/null +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -0,0 +1,46 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class TagBuilder extends DatabaseObjectBuilder +{ + public function setTagID(int $tagID): static + { + $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; + } +}