From f17cde1fb8ec865f0d73a1d109e491dfa9f25f08 Mon Sep 17 00:00:00 2001 From: hail-cookies <81558994+hail-cookies@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:53:35 +0100 Subject: [PATCH 1/2] NEW Added BehaviorDependencies (backend only) --- .../AbstractBehaviorOrderDependency.php | 62 +++++++++++++++ .../ApplyAfterBehaviors.php | 31 ++++++++ .../ApplyBeforeBehaviors.php | 31 ++++++++ .../BehaviorDependencies/RequireBehaviors.php | 78 +++++++++++++++++++ Behaviors/UndeletableBehavior.php | 1 + .../Model/Behaviors/AbstractBehavior.php | 15 ++++ .../Model/BehaviorDependencyInterface.php | 14 ++++ 7 files changed, 232 insertions(+) create mode 100644 Behaviors/BehaviorDependencies/AbstractBehaviorOrderDependency.php create mode 100644 Behaviors/BehaviorDependencies/ApplyAfterBehaviors.php create mode 100644 Behaviors/BehaviorDependencies/ApplyBeforeBehaviors.php create mode 100644 Behaviors/BehaviorDependencies/RequireBehaviors.php create mode 100644 Interfaces/Model/BehaviorDependencyInterface.php diff --git a/Behaviors/BehaviorDependencies/AbstractBehaviorOrderDependency.php b/Behaviors/BehaviorDependencies/AbstractBehaviorOrderDependency.php new file mode 100644 index 000000000..652bde900 --- /dev/null +++ b/Behaviors/BehaviorDependencies/AbstractBehaviorOrderDependency.php @@ -0,0 +1,62 @@ +orderAgainstBehaviorClasses = $orderAgainstBehaviorClasses; + } + + public function apply( + BehaviorInterface $toBehavior, + BehaviorListInterface $behaviors, + array $behaviorClasses + ): void + { + $targetPriority = $currentPriority = $toBehavior->getPriority(); + + foreach ($behaviors as $i => $behavior) { + if(in_array($behaviorClasses[$i], $this->orderAgainstBehaviorClasses)) { + $targetPriority = $this->comparePriorities($targetPriority, $behavior->getPriority()); + } + } + + $targetPriority = $this->processTargetPriority($currentPriority, $targetPriority); + if($targetPriority === true) { + return; + } + + $toBehavior->setPriority($targetPriority); + $occupiedPriority = $targetPriority; + + foreach ($behaviors as $behavior) { + // TODO 2025-12-05: Should we flatten the entire list or shift as few priorities as possible? + // TODO Flattening the entire list feels "right" but is a bigger change than just cleaning up locally. + if($behavior->getPriority() !== $occupiedPriority) { + break; + } + + $occupiedPriority = $this->shiftOccupiedPriority($occupiedPriority); + $behavior->setPriority($occupiedPriority); + } + } + + protected abstract function comparePriorities(int $self, int $other) : int; + + protected abstract function processTargetPriority( + int $currentPriority, + int $targetPriority + ) : int|true; + + protected abstract function shiftOccupiedPriority(int $priority) : int; +} \ No newline at end of file diff --git a/Behaviors/BehaviorDependencies/ApplyAfterBehaviors.php b/Behaviors/BehaviorDependencies/ApplyAfterBehaviors.php new file mode 100644 index 000000000..995cc1f0a --- /dev/null +++ b/Behaviors/BehaviorDependencies/ApplyAfterBehaviors.php @@ -0,0 +1,31 @@ + EventManagerInterface::PRIORITY_MIN) { + if($currentPriority <= $targetPriority) { + return true; + } + + $targetPriority -= 1; + } + + return $targetPriority; + } + + protected function shiftOccupiedPriority(int $priority): int + { + return $priority + 1; + } +} \ No newline at end of file diff --git a/Behaviors/BehaviorDependencies/ApplyBeforeBehaviors.php b/Behaviors/BehaviorDependencies/ApplyBeforeBehaviors.php new file mode 100644 index 000000000..0de69fe62 --- /dev/null +++ b/Behaviors/BehaviorDependencies/ApplyBeforeBehaviors.php @@ -0,0 +1,31 @@ += $targetPriority) { + return true; + } + + $targetPriority += 1; + } + + return $targetPriority; + } + + protected function shiftOccupiedPriority(int $priority): int + { + return $priority - 1; + } +} \ No newline at end of file diff --git a/Behaviors/BehaviorDependencies/RequireBehaviors.php b/Behaviors/BehaviorDependencies/RequireBehaviors.php new file mode 100644 index 000000000..ac13f9fbc --- /dev/null +++ b/Behaviors/BehaviorDependencies/RequireBehaviors.php @@ -0,0 +1,78 @@ +requiredBehaviorClasses = $requiredBehaviorClasses; + + $this->forbiddenBehaviorClasses = []; + foreach ($forbiddenBehaviorClasses as $class) { + if(!in_array($class, $requiredBehaviorClasses)) { + $this->forbiddenBehaviorClasses[] = $class; + } + } + } + + public function apply( + BehaviorInterface $toBehavior, + BehaviorListInterface $behaviors, + array $behaviorClasses + ) : void + { + $missingBehaviors = []; + $conflictingBehaviors = []; + + foreach ($this->requiredBehaviorClasses as $requiredBehaviorClass) { + if(!in_array($requiredBehaviorClass, $behaviorClasses)) { + $missingBehaviors[] = StringDataType::substringAfter( + $requiredBehaviorClass, + '\\', + false, + false, + true + ); + } + } + + foreach ($this->forbiddenBehaviorClasses as $forbiddenBehaviorClass) { + if(in_array($forbiddenBehaviorClass, $behaviorClasses)) { + $conflictingBehaviors[] = StringDataType::substringAfter( + $forbiddenBehaviorClass, + '\\', + false, + false, + true + ); + } + } + + if(empty($missingBehaviors) && empty($conflictingBehaviors)) { + return; + } + + $msg = 'Could not register behavior "' . $toBehavior->getAliasWithNamespace() . '" on object "' . + $toBehavior->getObject()->getAliasWithNamespace() . '".'; + + if(!empty($missingBehaviors)) { + $msg .= ' Missing REQUIRED behaviors: ' . json_encode($missingBehaviors) . '.'; + } + + if(!empty($conflictingBehaviors)) { + $msg .= ' Detected CONFLICTING behaviors: ' . json_encode($conflictingBehaviors) . '.'; + } + + throw new BehaviorConfigurationError($toBehavior, $msg); + } +} \ No newline at end of file diff --git a/Behaviors/UndeletableBehavior.php b/Behaviors/UndeletableBehavior.php index 757af17ca..286e8fa05 100644 --- a/Behaviors/UndeletableBehavior.php +++ b/Behaviors/UndeletableBehavior.php @@ -16,6 +16,7 @@ /** * Prevents the deletion of data if it matches the provided conditions. + * You must provide at least one condition for this behavior to function. * * ## Examples * diff --git a/CommonLogic/Model/Behaviors/AbstractBehavior.php b/CommonLogic/Model/Behaviors/AbstractBehavior.php index 437dd3477..70fa00859 100644 --- a/CommonLogic/Model/Behaviors/AbstractBehavior.php +++ b/CommonLogic/Model/Behaviors/AbstractBehavior.php @@ -247,6 +247,16 @@ public static function getUxonSchemaClass() : ?string */ public function register() : BehaviorInterface { + $behaviorList = $this->getObject()->getBehaviors(); + $classList = []; + foreach ($behaviorList as $behavior) { + $classList[] = get_class($behavior); + } + + foreach ($this->getDependencies() as $dependency) { + $dependency->apply($this, $behaviorList, $classList); + } + $this->registerEventListeners(); $this->setRegistered(true); return $this; @@ -430,4 +440,9 @@ protected function setName(string $name) : BehaviorInterface $this->name = $name; return $this; } + + protected function getDependencies() : array + { + return []; + } } \ No newline at end of file diff --git a/Interfaces/Model/BehaviorDependencyInterface.php b/Interfaces/Model/BehaviorDependencyInterface.php new file mode 100644 index 000000000..54a48642b --- /dev/null +++ b/Interfaces/Model/BehaviorDependencyInterface.php @@ -0,0 +1,14 @@ + Date: Tue, 9 Dec 2025 15:04:43 +0100 Subject: [PATCH 2/2] DEV BehaviorDependencies --- .../AbstractBehaviorOrderDependency.php | 132 +++++++++++++----- .../ApplyAfterBehaviors.php | 34 ++++- .../ApplyBeforeBehaviors.php | 34 ++++- .../BehaviorDependencies/RequireBehaviors.php | 31 ++-- Behaviors/SoftDeleteBehavior.php | 13 ++ .../Model/Behaviors/AbstractBehavior.php | 39 +++++- .../Model/BehaviorDependencyInterface.php | 22 ++- 7 files changed, 239 insertions(+), 66 deletions(-) diff --git a/Behaviors/BehaviorDependencies/AbstractBehaviorOrderDependency.php b/Behaviors/BehaviorDependencies/AbstractBehaviorOrderDependency.php index 652bde900..70416e181 100644 --- a/Behaviors/BehaviorDependencies/AbstractBehaviorOrderDependency.php +++ b/Behaviors/BehaviorDependencies/AbstractBehaviorOrderDependency.php @@ -2,12 +2,17 @@ namespace exface\Core\Behaviors\BehaviorDependencies; -use exface\Core\CommonLogic\Debugger\LogBooks\BehaviorLogBook; -use exface\Core\DataTypes\StringDataType; use exface\Core\Interfaces\Model\BehaviorDependencyInterface; use exface\Core\Interfaces\Model\BehaviorInterface; use exface\Core\Interfaces\Model\BehaviorListInterface; +/** + * Base class for managing behavior order dependencies, i.e. making sure a behavior runs "Before" or "After" + * a specified list of behaviors. Note that ordering dependencies are resolved naively: If multiple ordering + * dependencies conflict with one another, the last one to be applied wins out. + * + * // TODO If the dependency pattern proves to be valuable we might upgrade this with proper graph resolution. + */ abstract class AbstractBehaviorOrderDependency implements BehaviorDependencyInterface { private array $orderAgainstBehaviorClasses; @@ -16,47 +21,108 @@ function __construct(array $orderAgainstBehaviorClasses) { $this->orderAgainstBehaviorClasses = $orderAgainstBehaviorClasses; } - - public function apply( - BehaviorInterface $toBehavior, - BehaviorListInterface $behaviors, - array $behaviorClasses + + /** + * @inheritDoc + */ + public function resolve( + BehaviorInterface $subjectBehavior, + BehaviorListInterface $otherBehaviors, + array $behaviorClasses ): void { - $targetPriority = $currentPriority = $toBehavior->getPriority(); + $targetPriority = $currentPriority = $subjectBehavior->getPriority() ?? 0; - foreach ($behaviors as $i => $behavior) { - if(in_array($behaviorClasses[$i], $this->orderAgainstBehaviorClasses)) { - $targetPriority = $this->comparePriorities($targetPriority, $behavior->getPriority()); + // Find the desired priority for the subject + foreach ($otherBehaviors as $key => $behavior) { + if(in_array($behaviorClasses[$key], $this->orderAgainstBehaviorClasses)) { + $targetPriority = $this->comparePriorities($subjectBehavior, $behavior) ? + $targetPriority : + $behavior->getPriority() ?? 0; } } - - $targetPriority = $this->processTargetPriority($currentPriority, $targetPriority); - if($targetPriority === true) { + + if($this->isInOrder($currentPriority, $targetPriority)) { return; } - $toBehavior->setPriority($targetPriority); - $occupiedPriority = $targetPriority; - - foreach ($behaviors as $behavior) { - // TODO 2025-12-05: Should we flatten the entire list or shift as few priorities as possible? - // TODO Flattening the entire list feels "right" but is a bigger change than just cleaning up locally. - if($behavior->getPriority() !== $occupiedPriority) { - break; + // Shift the priorities of all other behaviors to maintain pre-existing dependencies. + $otherBehaviors->sort(function ($self, $other) { return !$this->comparePriorities($self, $other);} ); + $currentPriority = $targetPriority; + $shiftedIndices = []; + + foreach ($otherBehaviors as $key => $behavior) { + if($behavior === $subjectBehavior) { + $shiftedIndices[$key] = $targetPriority; + continue; } + + $priority = $behavior->getPriority() ?? 0; + $nextPriority = $this->getNextPriority($currentPriority); + + switch (true) { + // Shift from current to next. + case $priority === $currentPriority: + break; + // Move on to nex index, then shift from there. + case $priority === $nextPriority: + $currentPriority = $nextPriority; + $nextPriority = $this->getNextPriority($currentPriority); + break; + // If we have not shifted any indices yet, we keep searching. + case empty($shiftedIndices): + continue 2; + // If we did not create any new overlaps, we are done. + default: + break 2; + } + + $shiftedIndices[$key] = $nextPriority; + } + + // Now we have to ensure all indices remain within the boundaries of EventManagerInterface. + $delta = $this->getShiftDelta($shiftedIndices); + foreach ($shiftedIndices as $key => $index) { + $behavior = $otherBehaviors->get($key); + $enable = !$behavior->isDisabled(); + + // We have to toggle the behaviors to ensure that the shifted priorities are applied + // to all event delegates. + $behavior->disable(); - $occupiedPriority = $this->shiftOccupiedPriority($occupiedPriority); - $behavior->setPriority($occupiedPriority); + $behavior->setPriority($index + $delta); + + if($enable) { + $behavior->enable(); + } } } - - protected abstract function comparePriorities(int $self, int $other) : int; - - protected abstract function processTargetPriority( - int $currentPriority, - int $targetPriority - ) : int|true; - - protected abstract function shiftOccupiedPriority(int $priority) : int; + + /** + * @param BehaviorInterface $self + * @param BehaviorInterface $other + * @return bool + */ + protected abstract function comparePriorities(BehaviorInterface $self, BehaviorInterface $other) : bool; + + /** + * Returns TRUE if `$currentPriority` already fulfills the desired conditions. + * + * @param int $currentPriority + * @param int $targetPriority + * @return bool + */ + protected abstract function isInOrder(int $currentPriority, int $targetPriority) : bool; + + /** + * @param int $priority + * @return int + */ + protected abstract function getNextPriority(int $priority) : int; + + /** + * @param array $shiftedIndices + * @return int + */ + protected abstract function getShiftDelta(array $shiftedIndices) : int; } \ No newline at end of file diff --git a/Behaviors/BehaviorDependencies/ApplyAfterBehaviors.php b/Behaviors/BehaviorDependencies/ApplyAfterBehaviors.php index 995cc1f0a..7a38614c7 100644 --- a/Behaviors/BehaviorDependencies/ApplyAfterBehaviors.php +++ b/Behaviors/BehaviorDependencies/ApplyAfterBehaviors.php @@ -3,29 +3,49 @@ namespace exface\Core\Behaviors\BehaviorDependencies; use exface\Core\Interfaces\Events\EventManagerInterface; +use exface\Core\Interfaces\Model\BehaviorInterface; +/** + * Make sure the subject is applied AFTER a given list of behaviors. + */ class ApplyAfterBehaviors extends AbstractBehaviorOrderDependency { - protected function comparePriorities(int $self, int $other): int + /** + * @inheritDoc + */ + protected function comparePriorities(BehaviorInterface $self, BehaviorInterface $other): bool { - return min($self, $other); + return ($self->getPriority() ?? 0) < ($other->getPriority() ?? 0); } - protected function processTargetPriority(int $currentPriority, int $targetPriority): int|true + /** + * @inheritDoc + */ + protected function isInOrder(int $currentPriority, int $targetPriority): bool { if($targetPriority > EventManagerInterface::PRIORITY_MIN) { if($currentPriority <= $targetPriority) { return true; } - - $targetPriority -= 1; } - return $targetPriority; + return false; } - protected function shiftOccupiedPriority(int $priority): int + /** + * @inheritDoc + */ + protected function getNextPriority(int $priority): int { return $priority + 1; } + + /** + * @inheritDoc + */ + protected function getShiftDelta(array $shiftedIndices) : int + { + $maxPriority = max($shiftedIndices); + return EventManagerInterface::PRIORITY_MAX - $maxPriority; + } } \ No newline at end of file diff --git a/Behaviors/BehaviorDependencies/ApplyBeforeBehaviors.php b/Behaviors/BehaviorDependencies/ApplyBeforeBehaviors.php index 0de69fe62..9c7420f05 100644 --- a/Behaviors/BehaviorDependencies/ApplyBeforeBehaviors.php +++ b/Behaviors/BehaviorDependencies/ApplyBeforeBehaviors.php @@ -3,29 +3,49 @@ namespace exface\Core\Behaviors\BehaviorDependencies; use exface\Core\Interfaces\Events\EventManagerInterface; +use exface\Core\Interfaces\Model\BehaviorInterface; +/** + * Make sure the subject is applied BEFORE a given list of behaviors. + */ class ApplyBeforeBehaviors extends AbstractBehaviorOrderDependency { - protected function comparePriorities(int $self, int $other): int + /** + * @inheritDoc + */ + protected function comparePriorities(BehaviorInterface $self, BehaviorInterface $other): bool { - return max($self, $other); + return ($self->getPriority() ?? 0) > ($other->getPriority() ?? 0); } - protected function processTargetPriority(int $currentPriority, int $targetPriority): int|true + /** + * @inheritDoc + */ + protected function isInOrder(int $currentPriority, int $targetPriority) : bool { if($targetPriority < EventManagerInterface::PRIORITY_MAX) { if($currentPriority >= $targetPriority) { return true; } - - $targetPriority += 1; } - return $targetPriority; + return false; } - protected function shiftOccupiedPriority(int $priority): int + /** + * @inheritDoc + */ + protected function getNextPriority(int $priority): int { return $priority - 1; } + + /** + * @inheritDoc + */ + protected function getShiftDelta(array $shiftedIndices) : int + { + $minPriority = min($shiftedIndices); + return EventManagerInterface::PRIORITY_MIN - $minPriority; + } } \ No newline at end of file diff --git a/Behaviors/BehaviorDependencies/RequireBehaviors.php b/Behaviors/BehaviorDependencies/RequireBehaviors.php index ac13f9fbc..d350b406e 100644 --- a/Behaviors/BehaviorDependencies/RequireBehaviors.php +++ b/Behaviors/BehaviorDependencies/RequireBehaviors.php @@ -8,6 +8,9 @@ use exface\Core\Interfaces\Model\BehaviorInterface; use exface\Core\Interfaces\Model\BehaviorListInterface; +/** + * Ensures that a given list of behavior is PRESENT or ABSENT from the metaobject of the subject. + */ class RequireBehaviors implements BehaviorDependencyInterface { private array $requiredBehaviorClasses; @@ -24,17 +27,25 @@ function __construct(array $requiredBehaviorClasses = [], array $forbiddenBehavi } } } - - public function apply( - BehaviorInterface $toBehavior, - BehaviorListInterface $behaviors, - array $behaviorClasses + + /** + * @inheritDoc + */ + public function resolve( + BehaviorInterface $subjectBehavior, + BehaviorListInterface $otherBehaviors, + array $behaviorClasses ) : void { + $selfClass = get_class($subjectBehavior); $missingBehaviors = []; $conflictingBehaviors = []; foreach ($this->requiredBehaviorClasses as $requiredBehaviorClass) { + if($requiredBehaviorClass === $selfClass) { + continue; + } + if(!in_array($requiredBehaviorClass, $behaviorClasses)) { $missingBehaviors[] = StringDataType::substringAfter( $requiredBehaviorClass, @@ -47,6 +58,10 @@ public function apply( } foreach ($this->forbiddenBehaviorClasses as $forbiddenBehaviorClass) { + if($forbiddenBehaviorClass === $selfClass) { + continue; + } + if(in_array($forbiddenBehaviorClass, $behaviorClasses)) { $conflictingBehaviors[] = StringDataType::substringAfter( $forbiddenBehaviorClass, @@ -62,8 +77,8 @@ public function apply( return; } - $msg = 'Could not register behavior "' . $toBehavior->getAliasWithNamespace() . '" on object "' . - $toBehavior->getObject()->getAliasWithNamespace() . '".'; + $msg = 'Could not register behavior "' . $subjectBehavior->getAliasWithNamespace() . '" on object "' . + $subjectBehavior->getObject()->getAliasWithNamespace() . '".'; if(!empty($missingBehaviors)) { $msg .= ' Missing REQUIRED behaviors: ' . json_encode($missingBehaviors) . '.'; @@ -73,6 +88,6 @@ public function apply( $msg .= ' Detected CONFLICTING behaviors: ' . json_encode($conflictingBehaviors) . '.'; } - throw new BehaviorConfigurationError($toBehavior, $msg); + throw new BehaviorConfigurationError($subjectBehavior, $msg); } } \ No newline at end of file diff --git a/Behaviors/SoftDeleteBehavior.php b/Behaviors/SoftDeleteBehavior.php index 06b1f951d..0a80e5e42 100644 --- a/Behaviors/SoftDeleteBehavior.php +++ b/Behaviors/SoftDeleteBehavior.php @@ -1,6 +1,7 @@ bypassBehaviorsOnUpdate = $trueOrFalse; return $this; } + + /** + * @inheritDoc + */ + protected function getDependencies(): array + { + return array_merge(parent::getDependencies(), [ + new ApplyAfterBehaviors([ + UndeletableBehavior::class + ]) + ]); + } } \ No newline at end of file diff --git a/CommonLogic/Model/Behaviors/AbstractBehavior.php b/CommonLogic/Model/Behaviors/AbstractBehavior.php index 70fa00859..0c8a7e32a 100644 --- a/CommonLogic/Model/Behaviors/AbstractBehavior.php +++ b/CommonLogic/Model/Behaviors/AbstractBehavior.php @@ -46,7 +46,7 @@ abstract class AbstractBehavior implements BehaviorInterface private $name = null; protected bool $isInProgress = false; - + /** * Disable this behavior type for the specified object. * @@ -249,12 +249,19 @@ public function register() : BehaviorInterface { $behaviorList = $this->getObject()->getBehaviors(); $classList = []; - foreach ($behaviorList as $behavior) { - $classList[] = get_class($behavior); + foreach ($behaviorList as $key => $behavior) { + if($behavior->isDisabled()) { + $behaviorList->remove($behavior); + continue; + } + + $classList[$key] = get_class($behavior); } - foreach ($this->getDependencies() as $dependency) { - $dependency->apply($this, $behaviorList, $classList); + // TODO Doing this here ensures that dependencies are always resolved and up-to-date. However, whenever a + // TODO new behavior is registered, all dependencies must be re-resolved, which might be expensive. + foreach ($behaviorList as $behavior) { + $behavior->resolveDependencies($behaviorList, $classList); } $this->registerEventListeners(); @@ -440,9 +447,29 @@ protected function setName(string $name) : BehaviorInterface $this->name = $name; return $this; } - + + /** + * Returns an array with dependencies to be resolved. + * + * Override this method (and merge results with the parent) to + * add new dependencies. + * + * @return array + */ protected function getDependencies() : array { return []; } + + /** + * @param BehaviorListInterface $behaviorList + * @param array $classList + * @return void + */ + protected function resolveDependencies(BehaviorListInterface $behaviorList, array $classList) : void + { + foreach ($this->getDependencies() as $dependency) { + $dependency->resolve($this, $behaviorList, $classList); + } + } } \ No newline at end of file diff --git a/Interfaces/Model/BehaviorDependencyInterface.php b/Interfaces/Model/BehaviorDependencyInterface.php index 54a48642b..15a4d0a35 100644 --- a/Interfaces/Model/BehaviorDependencyInterface.php +++ b/Interfaces/Model/BehaviorDependencyInterface.php @@ -2,13 +2,25 @@ namespace exface\Core\Interfaces\Model; -use exface\Core\CommonLogic\Debugger\LogBooks\BehaviorLogBook; - +/** + * Basic interface for behavior dependencies. + * + * Behavior dependencies allow you to define certain conditions that behaviors must fulfill BEFORE they become + * active. This can be a simple validation check or even transformation logic. + */ interface BehaviorDependencyInterface { - public function apply( - BehaviorInterface $toBehavior, - BehaviorListInterface $behaviors, + /** + * Resolves this dependency for a given subject. + * + * @param BehaviorInterface $subjectBehavior + * @param BehaviorListInterface $otherBehaviors + * @param array $behaviorClasses + * @return void + */ + public function resolve( + BehaviorInterface $subjectBehavior, + BehaviorListInterface $otherBehaviors, array $behaviorClasses ) : void; } \ No newline at end of file