From f8dbec95d4700c741e793d458ce8e3b0ae3d21eb Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 15 Jun 2026 11:35:41 +0200 Subject: [PATCH 01/17] 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; + } +} From c1ec3a5666026ff2f8c0085014f74a42e9145a66 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 25 Jun 2026 19:51:01 +0200 Subject: [PATCH 02/17] Migrate tag forms to `DatabaseObjectBuilder` with command and events Replace the `TagAction`-based persistence in `TagAddForm`/`TagEditForm` with the new `DatabaseObjectBuilder` flow. --- .../files/lib/acp/form/TagAddForm.class.php | 76 +++-- .../files/lib/acp/form/TagEditForm.class.php | 2 +- .../files/lib/command/tag/CreateTag.class.php | 32 +++ .../files/lib/command/tag/UpdateTag.class.php | 32 +++ .../lib/data/DatabaseObjectBuilder.class.php | 22 ++ .../files/lib/data/tag/TagBuilder.class.php | 69 +++++ .../files/lib/event/tag/TagCreated.class.php | 21 ++ .../files/lib/event/tag/TagUpdated.class.php | 21 ++ ...bstractDatabaseObjectBuilderForm.class.php | 262 ++++++++++++++++++ ...atabaseObjectBuilderFormDocument.class.php | 67 +++++ .../builder/field/AbstractFormField.class.php | 21 ++ .../AbstractFormFieldDecorator.class.php | 14 + .../form/builder/field/IFormField.class.php | 32 +++ 13 files changed, 642 insertions(+), 29 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/tag/CreateTag.class.php create mode 100644 wcfsetup/install/files/lib/command/tag/UpdateTag.class.php create mode 100644 wcfsetup/install/files/lib/event/tag/TagCreated.class.php create mode 100644 wcfsetup/install/files/lib/event/tag/TagUpdated.class.php create mode 100644 wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php create mode 100644 wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php diff --git a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php index 64e8f14f59..09d249ee92 100644 --- a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php @@ -2,13 +2,17 @@ namespace wcf\acp\form; +use wcf\command\tag\CreateTag; +use wcf\command\tag\UpdateTag; +use wcf\data\DatabaseObjectBuilder; use wcf\data\IStorableObject; use wcf\data\tag\Tag; -use wcf\data\tag\TagAction; +use wcf\data\tag\TagBuilder; use wcf\data\tag\TagList; -use wcf\form\AbstractFormBuilderForm; +use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\form\builder\container\FormContainer; use wcf\system\form\builder\data\processor\CustomFormDataProcessor; +use wcf\system\form\builder\field\IFormField; use wcf\system\form\builder\field\SingleSelectionFormField; use wcf\system\form\builder\field\tag\TagFormField; use wcf\system\form\builder\field\TextFormField; @@ -27,9 +31,9 @@ * @copyright 2001-2024 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends AbstractFormBuilderForm + * @extends AbstractDatabaseObjectBuilderForm */ -class TagAddForm extends AbstractFormBuilderForm +class TagAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -49,15 +53,30 @@ class TagAddForm extends AbstractFormBuilderForm /** * @inheritDoc */ - public $objectActionClass = TagAction::class; + public string $objectEditLinkController = TagEditForm::class; - /** - * @inheritDoc - */ - public $objectEditLinkController = TagEditForm::class; + #[\Override] + protected function getDatabaseObjectBuilder(): TagBuilder + { + if ($this->formObject !== null) { + return TagBuilder::forUpdate($this->formObject); + } + + return TagBuilder::forCreate(); + } #[\Override] - protected function createForm() + protected function getCommand(DatabaseObjectBuilder $builder): callable + { + if ($this->formObject !== null) { + return new UpdateTag($builder); + } + + return new CreateTag($builder); + } + + #[\Override] + protected function createForm(): void { parent::createForm(); @@ -70,12 +89,17 @@ protected function createForm() ->label('wcf.global.name') ->required() ->maximumLength(\TAGGING_MAX_TAG_LENGTH) + ->saveValueCallback( + static fn(TagBuilder $builder, IFormField $field) => $builder->setName( + \str_replace(',', '', StringUtil::trim($field->getSaveValue())) + ) + ) ->addValidator( new FormFieldValidator('duplicateTagValidator', function (TextFormField $field) { $languageIDFormField = $field->getDocument()->getFormField('languageID'); $languageID = $languageIDFormField->getValue(); - $tag = Tag::getTag($field->getValue(), $languageID); + $tag = Tag::getTag($field->getValue(), $languageID ?? 0); if ($tag !== null && $tag->tagID !== $this->formObject?->tagID) { $field->addValidationError( new FormFieldValidationError( @@ -92,10 +116,20 @@ protected function createForm() ->options($contentLanguages) ->value(isset($contentLanguages[WCF::getLanguage()->languageID]) ? WCF::getLanguage()->languageID : null) ->immutable($this->formAction !== 'create') - ->required(), + ->required() + ->saveValueCallback( + static fn(TagBuilder $builder, IFormField $field) => $builder->setLanguageID( + (int)$field->getSaveValue() + ) + ), TagFormField::create('synonyms') ->available($this->formObject?->synonymFor === null) - ->label('wcf.acp.tag.synonyms'), + ->label('wcf.acp.tag.synonyms') + ->saveValueCallback( + static fn(TagBuilder $builder, IFormField $field) => $builder->setSynonyms( + $field->getSaveValue() ?? [] + ) + ), TemplateFormNode::create('tagSynonymFor') ->available($this->formObject?->synonymFor !== null) ->variables([ @@ -107,25 +141,11 @@ protected function createForm() } #[\Override] - protected function finalizeForm() + protected function finalizeForm(): void { parent::finalizeForm(); $this->form->getDataHandler() - ->addProcessor( - new CustomFormDataProcessor( - 'tagNameProcessor', - static function (IFormDocument $document, array $parameters) { - $parameters['data']['name'] = \str_replace( - ',', - '', - StringUtil::trim($parameters['data']['name']) - ); - - return $parameters; - } - ) - ) ->addProcessor( new CustomFormDataProcessor( 'synonymsProcessor', diff --git a/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php b/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php index 2b53db1058..b79b9828fd 100644 --- a/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php @@ -34,7 +34,7 @@ class TagEditForm extends TagAddForm /** * @inheritDoc */ - public $formAction = 'edit'; + public string $formAction = 'edit'; #[\Override] public function readParameters() diff --git a/wcfsetup/install/files/lib/command/tag/CreateTag.class.php b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php new file mode 100644 index 0000000000..d1610dd668 --- /dev/null +++ b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class CreateTag +{ + public function __construct( + private readonly TagBuilder $builder, + ) {} + + public function __invoke(): Tag + { + $tag = $this->builder->save(); + + EventHandler::getInstance()->fire(new TagCreated($tag)); + + return $tag; + } +} diff --git a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php new file mode 100644 index 0000000000..d98cd76709 --- /dev/null +++ b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class UpdateTag +{ + public function __construct( + private readonly TagBuilder $builder, + ) {} + + public function __invoke(): Tag + { + $tag = $this->builder->save(); + + EventHandler::getInstance()->fire(new TagUpdated($tag)); + + return $tag; + } +} diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 89ec16810f..603b024cdd 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -94,6 +94,8 @@ private function create(): int|string throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'"); } + $this->afterCreate($id); + return $id; } @@ -122,6 +124,8 @@ private function update(): void WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; $statement = WCF::getDB()->prepare($sql); $statement->execute($statementParameters); + + $this->afterUpdate(); } /** @@ -247,4 +251,22 @@ public function setCustomProperty(string $name, string|int|float|null $value): s return $this; } + + /** + * This method is called after the creation of a new object. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + */ + protected function afterCreate(int|string $id): void + { + // does nothing + } + + /** + * This method is called after an update. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + */ + protected function afterUpdate(): void + { + // does nothing + } } diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 882ee6f3de..4646ce7ff1 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -3,6 +3,7 @@ namespace wcf\data\tag; use wcf\data\DatabaseObjectBuilder; +use wcf\system\WCF; /** * Builder for creating, updating and deleting tags. @@ -16,6 +17,11 @@ */ final class TagBuilder extends DatabaseObjectBuilder { + /** + * @var ?list + */ + private ?array $synonyms = null; + public function setTagID(int $tagID): static { $this->properties['tagID'] = $tagID; @@ -43,4 +49,67 @@ public function setSynonymFor(Tag $tag): static return $this; } + + /** + * @param list $synonyms + */ + public function setSynonyms(array $synonyms): static + { + $this->synonyms = $synonyms; + + return $this; + } + + #[\Override] + protected function afterCreate(int|string $id): void + { + if ($this->synonyms !== null && $this->synonyms !== []) { + $this->saveSynonyms(new Tag($id), $this->synonyms); + } + } + + #[\Override] + protected function afterUpdate(): void + { + if ($this->synonyms !== null) { + $this->removeSynonyms($this->object); + + if ($this->synonyms !== []) { + $this->saveSynonyms($this->object, $this->synonyms); + } + } + } + + private function removeSynonyms(Tag $tag): void + { + $sql = "UPDATE wcf1_tag + SET synonymFor = ? + WHERE synonymFor = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + null, + $tag->tagID, + ]); + } + + /** + * @param list $synonyms + */ + private function saveSynonyms(Tag $tag, array $synonyms): void + { + foreach ($synonyms as $synonym) { + $synonymObj = Tag::getTag($synonym, $tag->languageID); + if ($synonymObj === null) { + TagBuilder::forCreate() + ->setName($synonym) + ->setLanguageID($tag->languageID) + ->setSynonymFor($tag) + ->save(); + } else { + TagBuilder::forUpdate($synonymObj) + ->setSynonymFor($tag) + ->save(); + } + } + } } diff --git a/wcfsetup/install/files/lib/event/tag/TagCreated.class.php b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php new file mode 100644 index 0000000000..cc1760b0c6 --- /dev/null +++ b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class TagCreated implements IPsr14Event +{ + public function __construct( + public readonly Tag $tag + ) {} +} diff --git a/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php new file mode 100644 index 0000000000..bbe312305f --- /dev/null +++ b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class TagUpdated implements IPsr14Event +{ + public function __construct( + public readonly Tag $tag + ) {} +} diff --git a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php new file mode 100644 index 0000000000..dc1dee200a --- /dev/null +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -0,0 +1,262 @@ + + * @since 6.3 + * + * @template TIStorableObject of IStorableObject|null + * @template TDatabaseObjectBuilder of DatabaseObjectBuilder + */ +abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm +{ + public DatabaseObjectBuilderFormDocument $form; + + /** + * Action performed by the form by default `create` and `edit` is supported. + */ + public string $formAction = 'create'; + + /** + * updated object, not relevant for form action `create` + * @var ?TIStorableObject + */ + public ?IStorableObject $formObject = null; + + /** + * name of the controller for the link to the edit form + */ + public string $objectEditLinkController = ''; + + /** + * object persisted by the most recent `save()` call + */ + public ?DatabaseObject $object = null; + + /** + * Returns the builder used to persist the form data. + * + * For the `create` action a builder obtained via `forCreate()` is expected, + * for the `edit` action a builder obtained via `forUpdate($this->formObject)`. + * + * @return TDatabaseObjectBuilder + */ + abstract protected function getDatabaseObjectBuilder(): DatabaseObjectBuilder; + + /** + * Returns the invokable command that persists the given builder and returns + * the resulting database object. + * + * The default command simply calls `DatabaseObjectBuilder::save()`. Override + * this method to wrap saving in a command that performs additional side + * effects. + * + * @param TDatabaseObjectBuilder $builder + * @return callable(): DatabaseObject + */ + protected function getCommand(DatabaseObjectBuilder $builder): callable + { + return static fn() => $builder->save(); + } + + #[\Override] + public function assignVariables() + { + parent::assignVariables(); + + WCF::getTPL()->assign([ + 'action' => $this->formAction === 'create' ? 'add' : 'edit', + 'form' => $this->form, + 'formObject' => $this->formObject, + ]); + } + + /** + * Builds the form. + */ + public function buildForm(): void + { + $classNamePieces = \explode('\\', static::class); + $controller = \preg_replace('~Form$~', '', \end($classNamePieces)); + + $this->form = DatabaseObjectBuilderFormDocument::create(\lcfirst($controller)); + + if ($this->formObject !== null) { + $this->form->formMode(IFormDocument::FORM_MODE_UPDATE); + } + + $this->createForm(); + + EventHandler::getInstance()->fireAction($this, 'createForm'); + + $this->form->build(); + + $this->finalizeForm(); + + EventHandler::getInstance()->fireAction($this, 'buildForm'); + } + + /** + * Creates the form. + * + * This is the method that is intended to be overwritten by child classes + * to add the form containers and fields. + */ + protected function createForm(): void + { + // does nothing + } + + /** + * Finalizes the form after it has been successfully built. + * + * This method can be used to add form field dependencies. + */ + protected function finalizeForm(): void + { + // does nothing + } + + #[\Override] + public function readData(): void + { + if ($this->formObject !== null) { + $this->setFormObjectData(); + } elseif ($this->formAction === 'edit') { + throw new \UnexpectedValueException("Missing form object to update."); + } + + parent::readData(); + + $this->setFormAction(); + } + + #[\Override] + public function readFormParameters(): void + { + parent::readFormParameters(); + + $this->form->readValues(); + } + + #[\Override] + public function save(): void + { + parent::save(); + + $builder = $this->getDatabaseObjectBuilder(); + $this->form->applyValuesToBuilder($builder); + + foreach ($this->additionalFields as $name => $value) { + $builder->setCustomProperty($name, $value); + } + + $this->object = ($this->getCommand($builder))(); + + $this->saved(); + + WCF::getTPL()->assign('success', true); + + if ($this->formAction === 'create' && $this->objectEditLinkController) { + WCF::getTPL()->assign( + 'objectEditLink', + LinkHandler::getInstance()->getControllerLink($this->objectEditLinkController, [ + 'id' => $this->object->getObjectID(), + ]) + ); + } + } + + #[\Override] + public function saved(): void + { + parent::saved(); + + // re-build form after having created a new object + if ($this->formAction === 'create') { + $this->form->cleanup(); + + $this->buildForm(); + } + + $this->form->showSuccessMessage(true); + } + + /** + * Sets the action of the form. + */ + protected function setFormAction(): void + { + $parameters = []; + if ($this->formObject !== null) { + if ($this->formObject instanceof IRouteController) { + $parameters['object'] = $this->formObject; + } else { + $object = $this->formObject; + // @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue + \assert($object instanceof IStorableObject); + + $parameters['id'] = $object->{$object::getDatabaseTableIndexName()}; + } + } + + $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, $parameters)); + } + + /** + * Sets the form data based on the current form object. + */ + protected function setFormObjectData(): void + { + $this->form->updatedObject($this->formObject, empty($_POST)); + } + + #[\Override] + public function checkPermissions(): void + { + parent::checkPermissions(); + + $this->buildForm(); + } + + #[\Override] + public function validate(): void + { + parent::validate(); + + $this->form->validate(); + + if ($this->form->hasValidationErrors()) { + throw new UserInputException($this->form->getPrefixedId()); + } + } + + #[\Override] + protected function validateSecurityToken(): void + { + // does nothing, is handled by `IFormDocument` object + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php new file mode 100644 index 0000000000..58625d7472 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php @@ -0,0 +1,67 @@ +saveValueCallback( + * static fn(DatabaseObjectBuilder $builder, IFormField $formField) => $builder->setName($formField->getSaveValue()) + * ) + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +class DatabaseObjectBuilderFormDocument extends FormDocument +{ + /** + * Applies the save values of all available form fields that have registered + * a save value callback to the given builder and returns the builder. + * + * @param DatabaseObjectBuilder<*> $builder + * @throws \BadMethodCallException if the method is called before `readValues()` is called + */ + public function applyValuesToBuilder(DatabaseObjectBuilder $builder): void + { + if (!$this->didReadValues()) { + throw new \BadMethodCallException("Applying values to a builder is only possible after calling 'readValues()'."); + } + + $this->applyNodeValues($this, $builder); + } + + /** + * Recursively applies the save value callbacks of the given node and its + * children to the builder, mirroring the availability and dependency + * handling of `DefaultFormDataProcessor`. + * + * @param DatabaseObjectBuilder<*> $builder + */ + protected function applyNodeValues(IFormNode $node, DatabaseObjectBuilder $builder): void + { + if (!$node->isAvailable() || !$node->checkDependencies()) { + return; + } + + if ($node instanceof IFormParentNode) { + foreach ($node as $childNode) { + $this->applyNodeValues($childNode, $builder); + } + } elseif ($node instanceof IFormField) { + $callback = $node->getSaveValueCallback(); + if ($callback !== null) { + $callback($builder, $node); + } + } + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php index badb8b8a35..a517052d6c 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php @@ -71,6 +71,13 @@ abstract class AbstractFormField implements IFormField */ protected $value; + /** + * callback transferring this field's save value into a `DatabaseObjectBuilder` + * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): mixed + * @since 6.3 + */ + protected ?\Closure $saveValueCallback = null; + #[\Override] public function addValidationError(IFormFieldValidationError $error) { @@ -165,6 +172,20 @@ public function getValue() return $this->value; } + #[\Override] + public function saveValueCallback(\Closure $callback): static + { + $this->saveValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getSaveValueCallback(): ?\Closure + { + return $this->saveValueCallback; + } + #[\Override] public function hasValidator(string $validatorId) { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php index 4d00dbea84..0c649ddbe9 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php @@ -87,6 +87,20 @@ public function getValue() return $this->field->getValue(); } + #[\Override] + public function saveValueCallback(\Closure $callback): static + { + $this->field->saveValueCallback($callback); + + return $this; + } + + #[\Override] + public function getSaveValueCallback(): ?\Closure + { + return $this->field->getSaveValueCallback(); + } + #[\Override] public function hasValidator(string $validatorId) { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index 89330390b2..4197450242 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -2,6 +2,7 @@ namespace wcf\system\form\builder\field; +use wcf\data\DatabaseObjectBuilder; use wcf\data\IStorableObject; use wcf\system\form\builder\field\validation\IFormFieldValidationError; use wcf\system\form\builder\field\validation\IFormFieldValidator; @@ -94,6 +95,37 @@ public function getValidators(); */ public function getValue(); + /** + * Sets a callback that transfers this field's save value into a + * `DatabaseObjectBuilder` instance and returns this field. + * + * The callback is invoked by `DatabaseObjectBuilderFormDocument` when the + * builder is populated from the form's fields, for example: + * + * $field->saveValueCallback( + * static fn(DatabaseObjectBuilder $builder, IFormField $formField) => $builder->setName($formField->getSaveValue()) + * ) + * + * The builder type is a template parameter so that the callback may narrow + * it to a concrete `DatabaseObjectBuilder` implementation (e.g. `TagBuilder`) + * without triggering a contravariance error. + * + * @template TBuilder of DatabaseObjectBuilder + * @param \Closure(TBuilder, IFormField): mixed $callback + * @return static this field + * @since 6.3 + */ + public function saveValueCallback(\Closure $callback): static; + + /** + * Returns the callback set via `saveValueCallback()` or `null` if no such + * callback has been set. + * + * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): mixed + * @since 6.3 + */ + public function getSaveValueCallback(): ?\Closure; + /** * Returns `true` if this field has a validator with the given id and * returns `false` otherwise. From 66329357b5725bcd14a2add6b400116848aaabe9 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 25 Jun 2026 23:01:17 +0200 Subject: [PATCH 03/17] Simplify checks in `AbstractDatabaseObjectBuilderForm` --- .../lib/form/AbstractDatabaseObjectBuilderForm.class.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php index dc1dee200a..c83d619f1b 100644 --- a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -216,9 +216,6 @@ protected function setFormAction(): void $parameters['object'] = $this->formObject; } else { $object = $this->formObject; - // @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue - \assert($object instanceof IStorableObject); - $parameters['id'] = $object->{$object::getDatabaseTableIndexName()}; } } @@ -231,7 +228,7 @@ protected function setFormAction(): void */ protected function setFormObjectData(): void { - $this->form->updatedObject($this->formObject, empty($_POST)); + $this->form->updatedObject($this->formObject, $_POST === []); } #[\Override] From b90f591680eee894ccbfc7c5f6f8e7cb15ff32f0 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 26 Jun 2026 13:46:03 +0200 Subject: [PATCH 04/17] Add `beforeDeleteAll()` hook and seal `DatabaseObjectBuilder` API --- .../lib/data/DatabaseObjectBuilder.class.php | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 603b024cdd..2da48d4082 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -42,7 +42,7 @@ private function __construct(protected readonly ?DatabaseObject $object = null) * * @return TDatabaseObject */ - public function save(): DatabaseObject + final public function save(): DatabaseObject { return new (static::getBaseClass())($this->fastSave()); } @@ -51,7 +51,7 @@ public function save(): DatabaseObject * Persists the pending changes and returns the object's identifier without * instantiating the full database object. */ - public function fastSave(): int|string + final public function fastSave(): int|string { if ($this->object !== null) { $this->update(); @@ -133,7 +133,7 @@ private function update(): void * * @return ?TDatabaseObject */ - public function createOrIgnore(): ?DatabaseObject + final public function createOrIgnore(): ?DatabaseObject { if ($this->object !== null) { throw new \BadMethodCallException("createOrIgnore() can only be used with forCreate()."); @@ -156,7 +156,7 @@ public function createOrIgnore(): ?DatabaseObject * * @param TDatabaseObject $object */ - public static function delete(DatabaseObject $object): void + final public static function delete(DatabaseObject $object): void { static::deleteAll([$object->getObjectID()]); } @@ -167,12 +167,14 @@ public static function delete(DatabaseObject $object): void * * @param (string|int)[] $objectIDs */ - public static function deleteAll(array $objectIDs = []): void + final public static function deleteAll(array $objectIDs = []): void { if ($objectIDs === []) { return; } + static::beforeDeleteAll($objectIDs); + $itemsPerLoop = 1000; $loopCount = \ceil(\count($objectIDs) / $itemsPerLoop); @@ -202,7 +204,7 @@ public static function deleteAll(array $objectIDs = []): void /** * Returns a builder instance for inserting a new row. */ - public static function forCreate(): static + final public static function forCreate(): static { return new (static::class)(); } @@ -212,7 +214,7 @@ public static function forCreate(): static * * @param TDatabaseObject $object */ - public static function forUpdate(DatabaseObject $object): static + final public static function forUpdate(DatabaseObject $object): static { return new static($object); } @@ -223,7 +225,7 @@ public static function forUpdate(DatabaseObject $object): static * * @return class-string */ - public static function getBaseClass(): string + final 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."); @@ -245,7 +247,7 @@ public static function getBaseClass(): string * 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 + final public function setCustomProperty(string $name, string|int|float|null $value): static { $this->customProperties[$name] = $value; @@ -269,4 +271,15 @@ protected function afterUpdate(): void { // does nothing } + + /** + * This method is called before the deletion of objects. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param (string|int)[] $objectIDs + */ + protected static function beforeDeleteAll(array $objectIDs): void + { + // does nothing + } } From 51cfd925be37a98aea956145795bfa0780669fc2 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 26 Jun 2026 13:48:05 +0200 Subject: [PATCH 05/17] Use fully qualified `\array_merge()` calls --- .../install/files/lib/data/DatabaseObjectBuilder.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 2da48d4082..842e240460 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -69,7 +69,7 @@ private function create(): int|string { $keys = $values = ''; $statementParameters = []; - foreach (array_merge($this->properties, $this->customProperties) as $key => $value) { + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { if ($keys !== '') { $keys .= ','; $values .= ','; @@ -110,7 +110,7 @@ private function update(): void $updateSQL = ''; $statementParameters = []; - foreach (array_merge($this->properties, $this->customProperties) as $key => $value) { + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { if ($updateSQL !== '') { $updateSQL .= ', '; } From a73221e2f82c6ce61f68a77f7f10d8cced1ad4ed Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 26 Jun 2026 14:10:41 +0200 Subject: [PATCH 06/17] Add `setID()` for explicit ID assignment on create --- .../lib/data/DatabaseObjectBuilder.class.php | 30 +++++++++++++++---- .../files/lib/data/tag/TagBuilder.class.php | 7 ----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 842e240460..ed8375c7a2 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -86,10 +86,10 @@ private function create(): int|string $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()])) { + if (isset($this->properties[static::getBaseClass()::getDatabaseTableIndexName()])) { $id = $this->properties[static::getBaseClass()::getDatabaseTableIndexName()]; + } elseif (static::getBaseClass()::getDatabaseTableIndexIsIdentity()) { + $id = WCF::getDB()->getInsertID(static::getBaseClass()::getDatabaseTableName(), static::getBaseClass()::getDatabaseTableIndexName()); } else { throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'"); } @@ -165,7 +165,7 @@ final public static function delete(DatabaseObject $object): void * Deletes the rows identified by the given primary keys in batches inside * a single transaction. * - * @param (string|int)[] $objectIDs + * @param (int|string)[] $objectIDs */ final public static function deleteAll(array $objectIDs = []): void { @@ -276,10 +276,30 @@ protected function afterUpdate(): void * This method is called before the deletion of objects. * It can be overriden to handle additional tasks that are not handled by the default implementation. * - * @param (string|int)[] $objectIDs + * @param (int|string)[] $objectIDs */ protected static function beforeDeleteAll(array $objectIDs): void { // does nothing } + + /** + * Sets the ID of the object that is being created. + * + * This method should only be used in cases where the ID needs to be set + * explicitly, for example when importing existing records from another + * installation, where the ID should be preserved if possible. + * + * @throws \BadMethodCallException if an existing object is being updated + */ + final public function setID(int|string $id): static + { + if ($this->object !== null) { + throw new \BadMethodCallException('The ID cannot be set when updating an existing object.'); + } + + $this->properties[static::getBaseClass()::getDatabaseTableIndexName()] = $id; + + return $this; + } } diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 4646ce7ff1..5dc775293a 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -22,13 +22,6 @@ final class TagBuilder extends DatabaseObjectBuilder */ private ?array $synonyms = null; - public function setTagID(int $tagID): static - { - $this->properties['tagID'] = $tagID; - - return $this; - } - public function setLanguageID(int $languageID): static { $this->properties['languageID'] = $languageID; From eb6cf2be8337bc18e45ec463a7d38beed210f3a3 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 28 Jun 2026 16:21:50 +0200 Subject: [PATCH 07/17] Add `loadValueCallback()` to `IFormField` for custom value loading Introduce a counterpart to `saveValueCallback()` that loads a field's value from an `IStorableObject` when an edit form is populated. When set, the callback takes precedence over the default property-based loading in `updatedObject()`, allowing values that must be derived from a related object or an additional query. --- .../files/lib/acp/form/TagAddForm.class.php | 53 ++++++------------- .../builder/field/AbstractFormField.class.php | 29 +++++++++- .../AbstractFormFieldDecorator.class.php | 14 +++++ .../form/builder/field/IFormField.class.php | 41 ++++++++++++++ .../builder/field/tag/TagFormField.class.php | 4 +- 5 files changed, 102 insertions(+), 39 deletions(-) diff --git a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php index 09d249ee92..05be1bb548 100644 --- a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php @@ -5,20 +5,17 @@ use wcf\command\tag\CreateTag; use wcf\command\tag\UpdateTag; use wcf\data\DatabaseObjectBuilder; -use wcf\data\IStorableObject; use wcf\data\tag\Tag; use wcf\data\tag\TagBuilder; use wcf\data\tag\TagList; use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\form\builder\container\FormContainer; -use wcf\system\form\builder\data\processor\CustomFormDataProcessor; use wcf\system\form\builder\field\IFormField; use wcf\system\form\builder\field\SingleSelectionFormField; use wcf\system\form\builder\field\tag\TagFormField; use wcf\system\form\builder\field\TextFormField; use wcf\system\form\builder\field\validation\FormFieldValidationError; use wcf\system\form\builder\field\validation\FormFieldValidator; -use wcf\system\form\builder\IFormDocument; use wcf\system\form\builder\TemplateFormNode; use wcf\system\language\LanguageFactory; use wcf\system\WCF; @@ -27,8 +24,8 @@ /** * Shows the tag add form. * - * @author Olaf Braun, Tim Duesterhus - * @copyright 2001-2024 WoltLab GmbH + * @author Olaf Braun, Tim Duesterhus, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * * @extends AbstractDatabaseObjectBuilderForm @@ -78,8 +75,6 @@ protected function getCommand(DatabaseObjectBuilder $builder): callable #[\Override] protected function createForm(): void { - parent::createForm(); - $contentLanguages = LanguageFactory::getInstance()->getContentLanguages(); $this->form->appendChildren([ @@ -94,6 +89,9 @@ protected function createForm(): void \str_replace(',', '', StringUtil::trim($field->getSaveValue())) ) ) + ->loadValueCallback(static function (Tag $object, IFormField $field) { + $field->value($object->name); + }) ->addValidator( new FormFieldValidator('duplicateTagValidator', function (TextFormField $field) { $languageIDFormField = $field->getDocument()->getFormField('languageID'); @@ -121,7 +119,9 @@ protected function createForm(): void static fn(TagBuilder $builder, IFormField $field) => $builder->setLanguageID( (int)$field->getSaveValue() ) - ), + )->loadValueCallback(static function (Tag $object, IFormField $field) { + $field->value($object->languageID); + }), TagFormField::create('synonyms') ->available($this->formObject?->synonymFor === null) ->label('wcf.acp.tag.synonyms') @@ -129,7 +129,15 @@ protected function createForm(): void static fn(TagBuilder $builder, IFormField $field) => $builder->setSynonyms( $field->getSaveValue() ?? [] ) - ), + )->loadValueCallback(static function (Tag $object, IFormField $field) { + $synonymList = new TagList(); + $synonymList->getConditionBuilder()->add('synonymFor = ?', [$object->getObjectID()]); + $synonymList->readObjects(); + $field->value(\array_map( + static fn($synonym) => $synonym->name, + $synonymList->getObjects() + )); + }), TemplateFormNode::create('tagSynonymFor') ->available($this->formObject?->synonymFor !== null) ->variables([ @@ -139,31 +147,4 @@ protected function createForm(): void ]) ]); } - - #[\Override] - protected function finalizeForm(): void - { - parent::finalizeForm(); - - $this->form->getDataHandler() - ->addProcessor( - new CustomFormDataProcessor( - 'synonymsProcessor', - null, - static function (IFormDocument $document, array $data, IStorableObject $tag) { - \assert($tag instanceof Tag); - - $synonymList = new TagList(); - $synonymList->getConditionBuilder()->add('synonymFor = ?', [$tag->tagID]); - $synonymList->readObjects(); - $data['synonyms'] = []; - foreach ($synonymList as $synonym) { - $data['synonyms'][] = $synonym->name; - } - - return $data; - } - ) - ); - } } diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php index a517052d6c..73d60f44ba 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php @@ -78,6 +78,13 @@ abstract class AbstractFormField implements IFormField */ protected ?\Closure $saveValueCallback = null; + /** + * callback loading this field's value from an `IStorableObject` + * @var ?\Closure(\wcf\data\IStorableObject, IFormField): void + * @since 6.3 + */ + protected ?\Closure $loadValueCallback = null; + #[\Override] public function addValidationError(IFormFieldValidationError $error) { @@ -186,6 +193,20 @@ public function getSaveValueCallback(): ?\Closure return $this->saveValueCallback; } + #[\Override] + public function loadValueCallback(\Closure $callback): static + { + $this->loadValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getLoadValueCallback(): ?\Closure + { + return $this->loadValueCallback; + } + #[\Override] public function hasValidator(string $validatorId) { @@ -213,8 +234,12 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa $loadValues = true; } - if ($loadValues && isset($data[$this->getObjectProperty()])) { - $this->value($data[$this->getObjectProperty()]); + if ($loadValues) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { + $this->value($data[$this->getObjectProperty()]); + } } return $this; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php index 0c649ddbe9..5ed8d4b772 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php @@ -101,6 +101,20 @@ public function getSaveValueCallback(): ?\Closure return $this->field->getSaveValueCallback(); } + #[\Override] + public function loadValueCallback(\Closure $callback): static + { + $this->field->loadValueCallback($callback); + + return $this; + } + + #[\Override] + public function getLoadValueCallback(): ?\Closure + { + return $this->field->getLoadValueCallback(); + } + #[\Override] public function hasValidator(string $validatorId) { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index 4197450242..c15cf1bc56 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -126,6 +126,47 @@ public function saveValueCallback(\Closure $callback): static; */ public function getSaveValueCallback(): ?\Closure; + /** + * Sets a callback that loads this field's value from an `IStorableObject` + * and returns this field. + * + * This is the counterpart to `saveValueCallback()`: while the save callback + * writes the field's value into a builder, this callback reads the value + * back out of an existing object when an edit form is populated. It is + * invoked by `updatedObject()` and is expected to assign the value via + * `$field->value()`, for example: + * + * $field->loadValueCallback( + * static function (Tag $object, IFormField $formField) { + * $formField->value($object->name); + * } + * ) + * + * When a callback is set it takes precedence over the default behaviour of + * loading the value from the object property named after this field. Use it + * when the value cannot be read from a single property, e.g. when it must be + * derived from a related object or an additional query. + * + * The object type is a template parameter so that the callback may narrow it + * to a concrete `IStorableObject` implementation (e.g. `Tag`) without + * triggering a contravariance error. + * + * @template TObject of IStorableObject + * @param \Closure(TObject, IFormField): void $callback + * @return static this field + * @since 6.3 + */ + public function loadValueCallback(\Closure $callback): static; + + /** + * Returns the callback set via `loadValueCallback()` or `null` if no such + * callback has been set. + * + * @return ?\Closure(IStorableObject, IFormField): void + * @since 6.3 + */ + public function getLoadValueCallback(): ?\Closure; + /** * Returns `true` if this field has a validator with the given id and * returns `false` otherwise. diff --git a/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php index 295fca4207..bd6c1fe61a 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php @@ -66,7 +66,9 @@ public function hasSaveValue() public function updatedObject(array $data, IStorableObject $object, bool $loadValues = true) { if ($loadValues) { - if (isset($data[$this->getObjectProperty()])) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { $this->value($data[$this->getObjectProperty()]); } else { $objectID = $object->{$object::getDatabaseTableIndexName()}; From 7f1c07a4dcbb81709030b40ea52d0453de1b05c7 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 28 Jun 2026 20:02:54 +0200 Subject: [PATCH 08/17] Return void from save value callbacks --- .../files/lib/acp/form/TagAddForm.class.php | 24 +++++++++---------- .../builder/field/AbstractFormField.class.php | 2 +- .../form/builder/field/IFormField.class.php | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php index 05be1bb548..6760932a7e 100644 --- a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php @@ -84,11 +84,11 @@ protected function createForm(): void ->label('wcf.global.name') ->required() ->maximumLength(\TAGGING_MAX_TAG_LENGTH) - ->saveValueCallback( - static fn(TagBuilder $builder, IFormField $field) => $builder->setName( + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setName( \str_replace(',', '', StringUtil::trim($field->getSaveValue())) - ) - ) + ); + }) ->loadValueCallback(static function (Tag $object, IFormField $field) { $field->value($object->name); }) @@ -115,21 +115,21 @@ protected function createForm(): void ->value(isset($contentLanguages[WCF::getLanguage()->languageID]) ? WCF::getLanguage()->languageID : null) ->immutable($this->formAction !== 'create') ->required() - ->saveValueCallback( - static fn(TagBuilder $builder, IFormField $field) => $builder->setLanguageID( + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setLanguageID( (int)$field->getSaveValue() - ) - )->loadValueCallback(static function (Tag $object, IFormField $field) { + ); + })->loadValueCallback(static function (Tag $object, IFormField $field) { $field->value($object->languageID); }), TagFormField::create('synonyms') ->available($this->formObject?->synonymFor === null) ->label('wcf.acp.tag.synonyms') - ->saveValueCallback( - static fn(TagBuilder $builder, IFormField $field) => $builder->setSynonyms( + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setSynonyms( $field->getSaveValue() ?? [] - ) - )->loadValueCallback(static function (Tag $object, IFormField $field) { + ); + })->loadValueCallback(static function (Tag $object, IFormField $field) { $synonymList = new TagList(); $synonymList->getConditionBuilder()->add('synonymFor = ?', [$object->getObjectID()]); $synonymList->readObjects(); diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php index 73d60f44ba..b0e3860d44 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php @@ -73,7 +73,7 @@ abstract class AbstractFormField implements IFormField /** * callback transferring this field's save value into a `DatabaseObjectBuilder` - * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): mixed + * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): void * @since 6.3 */ protected ?\Closure $saveValueCallback = null; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index c15cf1bc56..b5006af21d 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -111,7 +111,7 @@ public function getValue(); * without triggering a contravariance error. * * @template TBuilder of DatabaseObjectBuilder - * @param \Closure(TBuilder, IFormField): mixed $callback + * @param \Closure(TBuilder, IFormField): void $callback * @return static this field * @since 6.3 */ @@ -121,7 +121,7 @@ public function saveValueCallback(\Closure $callback): static; * Returns the callback set via `saveValueCallback()` or `null` if no such * callback has been set. * - * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): mixed + * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): void * @since 6.3 */ public function getSaveValueCallback(): ?\Closure; From 2056d4e5678318524e2a64db9a57e2a7bd3905d5 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 16:38:57 +0200 Subject: [PATCH 09/17] Add `updateCounters()` to `DatabaseObjectBuilder Provides a static helper to atomically increment or decrement counter columns for a given object, mirroring the behavior of the legacy `DatabaseObjectEditor::updateCounters()`. --- .../lib/data/DatabaseObjectBuilder.class.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index ed8375c7a2..3850c1d8c1 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -302,4 +302,36 @@ final public function setID(int|string $id): static return $this; } + + /** + * Updates counters for the given object. + * + * @param TDatabaseObject $object + * @param array $counters + */ + final public static function updateCounters(DatabaseObject $object, array $counters): void + { + if ($counters === []) { + throw new \InvalidArgumentException("The list of counters to update must not be empty."); + } + + \assert($object instanceof (static::getBaseClass())); + + $updateSQL = ''; + $statementParameters = []; + foreach ($counters as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + $updateSQL .= $key . ' = ' . $key . ' + ?'; + $statementParameters[] = $value; + } + $statementParameters[] = $object->getObjectID(); + + $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " + SET " . $updateSQL . " + WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + } } From 3a8cb2fe41820e110d1829e2915e2b61d781e559 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 16:51:21 +0200 Subject: [PATCH 10/17] Add `getHtmlInputProcessor()` to `WysiwygFormField` --- .../builder/field/wysiwyg/WysiwygFormField.class.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php index 4be0c94a24..8e82c40dad 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php @@ -393,4 +393,16 @@ public function getValue() $upcastProcessor->process(parent::getValue() ?? '', $this->getObjectType()->objectType); return $upcastProcessor->getHtml(); } + + /** + * @since 6.3 + */ + public function getHtmlInputProcessor(): HtmlInputProcessor + { + if ($this->htmlInputProcessor === null) { + throw new \BadMethodCallException("The html input processor is not available before validate() has been called."); + } + + return $this->htmlInputProcessor; + } } From 73d38c5c04268293f255ce65d61a504ab0fb7ecf Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 17:09:16 +0200 Subject: [PATCH 11/17] Add `afterSave()` hook and use `DatabaseObject` for the form object --- ...bstractDatabaseObjectBuilderForm.class.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php index c83d619f1b..6a2c04568f 100644 --- a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -4,7 +4,6 @@ use wcf\data\DatabaseObject; use wcf\data\DatabaseObjectBuilder; -use wcf\data\IStorableObject; use wcf\system\event\EventHandler; use wcf\system\exception\UserInputException; use wcf\system\form\builder\DatabaseObjectBuilderFormDocument; @@ -28,7 +27,7 @@ * @license GNU Lesser General Public License * @since 6.3 * - * @template TIStorableObject of IStorableObject|null + * @template TDatabaseObject of DatabaseObject|null * @template TDatabaseObjectBuilder of DatabaseObjectBuilder */ abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm @@ -42,9 +41,9 @@ abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm /** * updated object, not relevant for form action `create` - * @var ?TIStorableObject + * @var ?TDatabaseObject */ - public ?IStorableObject $formObject = null; + public ?DatabaseObject $formObject = null; /** * name of the controller for the link to the edit form @@ -53,6 +52,7 @@ abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm /** * object persisted by the most recent `save()` call + * @var ?TDatabaseObject */ public ?DatabaseObject $object = null; @@ -188,6 +188,8 @@ public function save(): void ]) ); } + + $this->afterSave(); } #[\Override] @@ -256,4 +258,13 @@ protected function validateSecurityToken(): void { // does nothing, is handled by `IFormDocument` object } + + /** + * This method is called after a save. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + */ + protected function afterSave(): void + { + // does nothing + } } From 0a42c91fd00d7846b300557d359f02fb36112574 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 17:55:05 +0200 Subject: [PATCH 12/17] Drop `DatabaseObjectBuilder::fastSave()` The method was based on `DatabaseObjectEditor::fastCreate()` which is used very rarely and therefore is not needed in the new API. The change simplifies the code and allows the DBO to be passed directly as a parameter to `afterCreate()` and `afterUpdate()` --- .../lib/data/DatabaseObjectBuilder.class.php | 89 +++++++++---------- .../files/lib/data/tag/TagBuilder.class.php | 11 +-- 2 files changed, 48 insertions(+), 52 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 3850c1d8c1..0b31fd8044 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -43,20 +43,9 @@ private function __construct(protected readonly ?DatabaseObject $object = null) * @return TDatabaseObject */ final 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. - */ - final public function fastSave(): int|string { if ($this->object !== null) { - $this->update(); - - return $this->object->getObjectID(); + return $this->update(); } return $this->create(); @@ -64,8 +53,10 @@ final public function fastSave(): int|string /** * Inserts a new row and returns the primary key of the created object. + * + * @return TDatabaseObject */ - private function create(): int|string + private function create(): DatabaseObject { $keys = $values = ''; $statementParameters = []; @@ -94,38 +85,46 @@ private function create(): int|string throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'"); } - $this->afterCreate($id); + $object = new (static::getBaseClass())($id); + + $this->afterCreate($object); - return $id; + return $object; } /** * Writes the pending property changes to the existing row. + * + * @return TDatabaseObject */ - private function update(): void + private function update(): DatabaseObject { - if ($this->properties === [] && $this->customProperties === []) { - return; - } - - $updateSQL = ''; - $statementParameters = []; - foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { - if ($updateSQL !== '') { - $updateSQL .= ', '; + if ($this->properties !== [] || $this->customProperties !== []) { + $updateSQL = ''; + $statementParameters = []; + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + $updateSQL .= $key . ' = ?'; + $statementParameters[] = $value; } - $updateSQL .= $key . ' = ?'; - $statementParameters[] = $value; - } - $statementParameters[] = $this->object->getObjectID(); + $statementParameters[] = $this->object->getObjectID(); - $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " + $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " SET " . $updateSQL . " WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($statementParameters); + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + + $object = new (static::getBaseClass())($this->object->getObjectID()); + } else { + $object = $this->object; + } + + $this->afterUpdate($object); - $this->afterUpdate(); + return $object; } /** @@ -165,14 +164,10 @@ final public static function delete(DatabaseObject $object): void * Deletes the rows identified by the given primary keys in batches inside * a single transaction. * - * @param (int|string)[] $objectIDs + * @param non-empty-list|non-empty-list $objectIDs */ - final public static function deleteAll(array $objectIDs = []): void + final public static function deleteAll(array $objectIDs): void { - if ($objectIDs === []) { - return; - } - static::beforeDeleteAll($objectIDs); $itemsPerLoop = 1000; @@ -257,8 +252,10 @@ final public function setCustomProperty(string $name, string|int|float|null $val /** * This method is called after the creation of a new object. * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param TDatabaseObject $object */ - protected function afterCreate(int|string $id): void + protected function afterCreate(DatabaseObject $object): void { // does nothing } @@ -266,8 +263,10 @@ protected function afterCreate(int|string $id): void /** * This method is called after an update. * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param TDatabaseObject $object */ - protected function afterUpdate(): void + protected function afterUpdate(DatabaseObject $object): void { // does nothing } @@ -276,7 +275,7 @@ protected function afterUpdate(): void * This method is called before the deletion of objects. * It can be overriden to handle additional tasks that are not handled by the default implementation. * - * @param (int|string)[] $objectIDs + * @param non-empty-list|non-empty-list $objectIDs */ protected static function beforeDeleteAll(array $objectIDs): void { @@ -307,14 +306,10 @@ final public function setID(int|string $id): static * Updates counters for the given object. * * @param TDatabaseObject $object - * @param array $counters + * @param non-empty-array $counters */ final public static function updateCounters(DatabaseObject $object, array $counters): void { - if ($counters === []) { - throw new \InvalidArgumentException("The list of counters to update must not be empty."); - } - \assert($object instanceof (static::getBaseClass())); $updateSQL = ''; diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 5dc775293a..0f0ec75751 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -2,6 +2,7 @@ namespace wcf\data\tag; +use wcf\data\DatabaseObject; use wcf\data\DatabaseObjectBuilder; use wcf\system\WCF; @@ -54,21 +55,21 @@ public function setSynonyms(array $synonyms): static } #[\Override] - protected function afterCreate(int|string $id): void + protected function afterCreate(DatabaseObject $object): void { if ($this->synonyms !== null && $this->synonyms !== []) { - $this->saveSynonyms(new Tag($id), $this->synonyms); + $this->saveSynonyms($object, $this->synonyms); } } #[\Override] - protected function afterUpdate(): void + protected function afterUpdate(DatabaseObject $object): void { if ($this->synonyms !== null) { - $this->removeSynonyms($this->object); + $this->removeSynonyms($object); if ($this->synonyms !== []) { - $this->saveSynonyms($this->object, $this->synonyms); + $this->saveSynonyms($object, $this->synonyms); } } } From 43b15ffd387fbba91e1a9af0f920185baf7e91ab Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 21:55:15 +0200 Subject: [PATCH 13/17] Add `getObject()` to `DatabaseObjectBuilder` --- .../files/lib/data/DatabaseObjectBuilder.class.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 0b31fd8044..6ad5805667 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -329,4 +329,12 @@ final public static function updateCounters(DatabaseObject $object, array $count $statement = WCF::getDB()->prepare($sql); $statement->execute($statementParameters); } + + /** + * @return ?TDatabaseObject + */ + public function getObject(): ?DatabaseObject + { + return $this->object; + } } From e81a35141ecae1dddcbd59d4631d1baa2fa86192 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 21:57:16 +0200 Subject: [PATCH 14/17] Allow accessing wysiwyg and attachment fields before the form is built --- .../wysiwyg/WysiwygFormContainer.class.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php index b474225179..89eb0757a7 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php @@ -250,14 +250,11 @@ public function enablePreviewButton(bool $enablePreviewButton = true) * Returns the form field handling attachments. * * @return WysiwygAttachmentFormField - * @throws \BadMethodCallException if the form field container has not been populated yet/form has not been built yet */ public function getAttachmentField() { if ($this->attachmentField === null) { - throw new \BadMethodCallException( - "Wysiwyg form field can only be requested after the form has been built for container '{$this->getId()}'." - ); + $this->attachmentField = WysiwygAttachmentFormField::create($this->wysiwygId . 'Attachments'); } return $this->attachmentField; @@ -345,14 +342,11 @@ public function getSmiliesContainer() * Returns the wysiwyg form field handling the actual text. * * @return WysiwygFormField - * @throws \BadMethodCallException if the form field container has not been populated yet/form has not been built yet */ public function getWysiwygField() { if ($this->wysiwygField === null) { - throw new \BadMethodCallException( - "Wysiwyg form field can only be requested after the form has been built for container '{$this->getId()}'." - ); + $this->wysiwygField = WysiwygFormField::create($this->wysiwygId); } return $this->wysiwygField; @@ -469,7 +463,7 @@ public function populate() { parent::populate(); - $this->wysiwygField = WysiwygFormField::create($this->wysiwygId) + $this->wysiwygField = $this->getWysiwygField() ->objectType($this->messageObjectType) ->minimumLength($this->getMinimumLength()) ->maximumLength($this->getMaximumLength()) @@ -480,7 +474,7 @@ public function populate() ->wysiwygId($this->getWysiwygId()) ->label('wcf.message.smilies') ->available($this->supportSmilies); - $this->attachmentField = WysiwygAttachmentFormField::create($this->wysiwygId . 'Attachments') + $this->attachmentField = $this->getAttachmentField() ->wysiwygId($this->getWysiwygId()); $this->settingsContainer = FormContainer::create($this->wysiwygId . 'SettingsContainer') ->appendChildren($this->settingsNodes); From ed0fadd7fa3874e09459cce88938d43a1cd49800 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 21:57:45 +0200 Subject: [PATCH 15/17] Use a dedicated template for the form field in save/load callback types --- .../lib/system/form/builder/field/IFormField.class.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index b5006af21d..44b2daacc7 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -111,7 +111,8 @@ public function getValue(); * without triggering a contravariance error. * * @template TBuilder of DatabaseObjectBuilder - * @param \Closure(TBuilder, IFormField): void $callback + * @template TIFormField of IFormField + * @param \Closure(TBuilder, TIFormField): void $callback * @return static this field * @since 6.3 */ @@ -152,7 +153,8 @@ public function getSaveValueCallback(): ?\Closure; * triggering a contravariance error. * * @template TObject of IStorableObject - * @param \Closure(TObject, IFormField): void $callback + * @template TIFormField of IFormField + * @param \Closure(TObject, TIFormField): void $callback * @return static this field * @since 6.3 */ From a5569f38d37576a4b067e15deb6eafba0e8dca9e Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 21:58:13 +0200 Subject: [PATCH 16/17] Honor `loadValueCallback` in `TI18nFormField::updatedObject()` --- .../builder/field/TI18nFormField.class.php | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php index cbec854598..0523a612f9 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php @@ -279,16 +279,20 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa $loadValues = true; } - if ($loadValues && isset($data[$this->getObjectProperty()])) { - $value = $data[$this->getObjectProperty()]; - - if ($this->isI18n()) { - // do not use `I18nHandler::setOptions()` because then `I18nHandler` only - // reads the values when assigning the template variables and the values - // are not available in this class via `getValue()` - $this->setStringValue($value); - } else { - $this->value = $value; + if ($loadValues) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { + $value = $data[$this->getObjectProperty()]; + + if ($this->isI18n()) { + // do not use `I18nHandler::setOptions()` because then `I18nHandler` only + // reads the values when assigning the template variables and the values + // are not available in this class via `getValue()` + $this->setStringValue($value); + } else { + $this->value = $value; + } } } From 0aaf82d85985b45bb3a0780bfdb456a08cb1bdd0 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 1 Jul 2026 13:50:19 +0200 Subject: [PATCH 17/17] Validate required properties in `DatabaseObjectBuilder::create()` --- .../lib/data/DatabaseObjectBuilder.class.php | 32 +++++++++++++++++++ .../files/lib/data/tag/TagBuilder.class.php | 6 ++++ 2 files changed, 38 insertions(+) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 6ad5805667..0302b3f574 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -58,6 +58,8 @@ final public function save(): DatabaseObject */ private function create(): DatabaseObject { + $this->validateCreate(); + $keys = $values = ''; $statementParameters = []; foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { @@ -92,6 +94,36 @@ private function create(): DatabaseObject return $object; } + /** + * Validates that the pending changes are sufficient to create a new object. + * + * @throws \BadMethodCallException if no properties are set or a required property is missing + */ + private function validateCreate(): void + { + if ($this->properties === [] && $this->customProperties === []) { + throw new \BadMethodCallException("Cannot create an object without any properties."); + } + + foreach ($this->getRequiredProperties() as $property) { + if (!\array_key_exists($property, $this->properties)) { + throw new \BadMethodCallException("Missing value for required property '{$property}'."); + } + } + } + + /** + * Returns the names of the properties that must be set when creating a new + * object. Subclasses can override this method to enforce that required + * values are provided before the object is persisted. + * + * @return list + */ + protected function getRequiredProperties(): array + { + return []; + } + /** * Writes the pending property changes to the existing row. * diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 0f0ec75751..41fc70d62a 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -106,4 +106,10 @@ private function saveSynonyms(Tag $tag, array $synonyms): void } } } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['name']; + } }