diff --git a/CHANGELOG-v3.md b/CHANGELOG-v3.md index 96295816e79..4f8ae51fbd6 100644 --- a/CHANGELOG-v3.md +++ b/CHANGELOG-v3.md @@ -4,6 +4,10 @@ ### Added - Table fields can now have Dropdown columns. ([#811](https://github.com/craftcms/cms/issues/811)) +- Added `craft\services\Elements::resaveElements()` along with `EVENT_BEFORE_RESAVE_ELEMENTS`, `EVENT_AFTER_RESAVE_ELEMENTS`, `EVENT_BEFORE_RESAVE_ELEMENT`, and `EVENT_AFTER_RELAVE_ELEMENT` events. ([#3482](https://github.com/craftcms/cms/issues/3482)) + +### Removed +- Removed the `--batch-size` option from `resave/*` actions. ### Fixed - Fixed a bug where Control Panel pages that didn’t have a dedicated controller action weren’t ensuring that a user was logged in. diff --git a/src/console/controllers/ResaveController.php b/src/console/controllers/ResaveController.php index abce84e2dc3..e00156990de 100644 --- a/src/console/controllers/ResaveController.php +++ b/src/console/controllers/ResaveController.php @@ -17,6 +17,8 @@ use craft\elements\Entry; use craft\elements\Tag; use craft\elements\User; +use craft\events\ResaveElementEvent; +use craft\services\Elements; use yii\console\Controller; use yii\console\ExitCode; use yii\helpers\Console; @@ -59,11 +61,6 @@ class ResaveController extends Controller */ public $limit; - /** - * @var int The batch size to query elements in. - */ - public $batchSize = 100; - /** * @var bool Whether to save the elements across all their enabled sites. */ @@ -101,7 +98,6 @@ public function options($actionID) $options[] = 'status'; $options[] = 'offset'; $options[] = 'limit'; - $options[] = 'batchSize'; $options[] = 'propagate'; switch ($actionID) { @@ -250,26 +246,37 @@ private function _saveElements(ElementQueryInterface $query): int $elementsService = Craft::$app->getElements(); $fail = false; - foreach ($query->each($this->batchSize) as $element) { - /** @var Element $element */ - $this->stdout(" - Resaving {$element} ({$element->id}) ... "); - $element->setScenario(Element::SCENARIO_ESSENTIALS); - $element->resaving = true; - try { - if (!$elementsService->saveElement($element)) { + $beforeCallback = function(ResaveElementEvent $e) use ($query) { + if ($e->query === $query) { + /** @var Element $element */ + $element = $e->element; + $this->stdout(" - Resaving {$element} ({$element->id}) ... "); + } + }; + + $afterCallback = function(ResaveElementEvent $e) use ($query, &$fail) { + if ($e->query === $query) { + /** @var Element $element */ + $element = $e->element; + if ($e->exception) { + $this->stderr('error: ' . $e->exception->getMessage() . PHP_EOL, Console::FG_RED); + $fail = true; + } else if ($element->hasErrors()) { $this->stderr('failed: ' . implode(', ', $element->getErrorSummary(true)) . PHP_EOL, Console::FG_RED); $fail = true; - continue; + } else { + $this->stdout('done' . PHP_EOL, Console::FG_GREEN); } - } catch (\Throwable $e) { - Craft::$app->getErrorHandler()->logException($e); - $this->stderr('error: ' . $e->getMessage() . PHP_EOL, Console::FG_RED); - $fail = true; - continue; } + }; - $this->stdout('done' . PHP_EOL, Console::FG_GREEN); - } + $elementsService->on(Elements::EVENT_BEFORE_RESAVE_ELEMENT, $beforeCallback); + $elementsService->on(Elements::EVENT_AFTER_RESAVE_ELEMENT, $afterCallback); + + $elementsService->resaveElements($query, true); + + $elementsService->off(Elements::EVENT_BEFORE_RESAVE_ELEMENT, $beforeCallback); + $elementsService->off(Elements::EVENT_AFTER_RESAVE_ELEMENT, $afterCallback); $this->stdout("Done resaving {$elementsText}." . PHP_EOL . PHP_EOL, Console::FG_YELLOW); return $fail ? ExitCode::UNSPECIFIED_ERROR : ExitCode::OK; diff --git a/src/events/ResaveElementEvent.php b/src/events/ResaveElementEvent.php new file mode 100644 index 00000000000..2e1cb994f39 --- /dev/null +++ b/src/events/ResaveElementEvent.php @@ -0,0 +1,39 @@ + + * @since 3.2.0 + */ +class ResaveElementEvent extends ResaveElementsEvent +{ + // Properties + // ========================================================================= + + /** + * @var ElementInterface The element being resaved + */ + public $element; + + /** + * @var int The element's position in the query (1-indexed) + */ + public $position; + + /** + * @var \Throwable|null $exception The exception that was thrown if any + */ + public $exception; +} diff --git a/src/events/ResaveElementsEvent.php b/src/events/ResaveElementsEvent.php new file mode 100644 index 00000000000..ad29d6957f7 --- /dev/null +++ b/src/events/ResaveElementsEvent.php @@ -0,0 +1,29 @@ + + * @since 3.2.0 + */ +class ResaveElementsEvent extends Event +{ + // Properties + // ========================================================================= + + /** + * @var ElementQueryInterface The element query the elements will be pulled from. + */ + public $query; +} diff --git a/src/queue/jobs/ResaveElements.php b/src/queue/jobs/ResaveElements.php index d2a2669f290..c3ed5e78c62 100644 --- a/src/queue/jobs/ResaveElements.php +++ b/src/queue/jobs/ResaveElements.php @@ -8,13 +8,12 @@ namespace craft\queue\jobs; use Craft; -use craft\base\Element; use craft\base\ElementInterface; -use craft\db\QueryAbortedException; use craft\elements\db\ElementQuery; use craft\elements\db\ElementQueryInterface; +use craft\events\ResaveElementEvent; use craft\queue\BaseJob; -use yii\base\Exception; +use craft\services\Elements; /** * ResaveElements job @@ -50,24 +49,18 @@ public function execute($queue) /** @var ElementQuery $query */ $query = $this->_query(); - $totalElements = $query->count(); + $count = $query->count(); $elementsService = Craft::$app->getElements(); - $currentElement = 0; - - try { - foreach ($query->each() as $element) { - $this->setProgress($queue, $currentElement++ / $totalElements); - - /** @var Element $element */ - $element->setScenario(Element::SCENARIO_ESSENTIALS); - $element->resaving = true; - if (!$elementsService->saveElement($element)) { - throw new Exception('Couldn’t save element ' . $element->id . ' (' . get_class($element) . ') due to validation errors.'); - } + + $callback = function(ResaveElementEvent $e) use ($queue, $query, $count) { + if ($e->query === $query) { + $this->setProgress($queue, $e->position / $count); } - } catch (QueryAbortedException $e) { - // Fail silently - } + }; + + $elementsService->on(Elements::EVENT_AFTER_RESAVE_ELEMENT, $callback); + $elementsService->resaveElements($query); + $elementsService->off(Elements::EVENT_AFTER_RESAVE_ELEMENT, $callback); } // Protected Methods diff --git a/src/services/Elements.php b/src/services/Elements.php index 02d1bf62adc..37629472c7c 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -14,10 +14,12 @@ use craft\base\ElementInterface; use craft\base\Field; use craft\db\Query; +use craft\db\QueryAbortedException; use craft\db\Table; use craft\elements\Asset; use craft\elements\Category; use craft\elements\db\ElementQuery; +use craft\elements\db\ElementQueryInterface; use craft\elements\Entry; use craft\elements\GlobalSet; use craft\elements\MatrixBlock; @@ -29,6 +31,8 @@ use craft\events\ElementEvent; use craft\events\MergeElementsEvent; use craft\events\RegisterComponentTypesEvent; +use craft\events\ResaveElementEvent; +use craft\events\ResaveElementsEvent; use craft\helpers\App; use craft\helpers\ArrayHelper; use craft\helpers\Component as ComponentHelper; @@ -114,6 +118,26 @@ class Elements extends Component */ const EVENT_AFTER_SAVE_ELEMENT = 'afterSaveElement'; + /** + * @event ElementEvent The event that is triggered before resaving a batch of elements. + */ + const EVENT_BEFORE_RESAVE_ELEMENTS = 'beforeResaveElements'; + + /** + * @event ElementEvent The event that is triggered after resaving a batch of elements. + */ + const EVENT_AFTER_RESAVE_ELEMENTS = 'afterResaveElements'; + + /** + * @event ElementEvent The event that is triggered before an element is resaved. + */ + const EVENT_BEFORE_RESAVE_ELEMENT = 'beforeResaveElement'; + + /** + * @event ElementEvent The event that is triggered after an element is resaved. + */ + const EVENT_AFTER_RESAVE_ELEMENT = 'afterResaveElement'; + /** * @event ElementEvent The event that is triggered before an element’s slug and URI are updated, usually following a Structure move. */ @@ -582,6 +606,75 @@ public function saveElement(ElementInterface $element, bool $runValidation = tru return true; } + /** + * Resaves all elements that match a given element query. + * + * @param ElementQueryInterface $query The element query to fetch elements with + * @param bool $continueOnError Whether to continue going if an error occurs + * @throws \Throwable if reasons + * @since 3.2.0 + */ + public function resaveElements(ElementQueryInterface $query, bool $continueOnError = false) + { + // Fire a 'beforeSaveElements' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_RESAVE_ELEMENTS)) { + $this->trigger(self::EVENT_BEFORE_RESAVE_ELEMENTS, new ResaveElementsEvent([ + 'query' => $query, + ])); + } + + $position = 0; + + try { + /** @var ElementQuery $query */ + foreach ($query->each() as $element) { + $position++; + + /** @var Element $element */ + $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->resaving = true; + + // Fire a 'beforeSaveElement' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_RESAVE_ELEMENT)) { + $this->trigger(self::EVENT_BEFORE_RESAVE_ELEMENT, new ResaveElementEvent([ + 'query' => $query, + 'element' => $element, + 'position' => $position, + ])); + } + + $e = null; + try { + $this->saveElement($element); + } catch (\Throwable $e) { + if (!$continueOnError) { + throw $e; + } + Craft::$app->getErrorHandler()->logException($e); + } + + // Fire an 'afterSaveElement' event + if ($this->hasEventHandlers(self::EVENT_AFTER_RESAVE_ELEMENT)) { + $this->trigger(self::EVENT_AFTER_RESAVE_ELEMENT, new ResaveElementEvent([ + 'query' => $query, + 'element' => $element, + 'position' => $position, + 'exception' => $e, + ])); + } + } + } catch (QueryAbortedException $e) { + // Fail silently + } + + // Fire an 'afterSaveElements' event + if ($this->hasEventHandlers(self::EVENT_AFTER_RESAVE_ELEMENTS)) { + $this->trigger(self::EVENT_AFTER_RESAVE_ELEMENTS, new ResaveElementsEvent([ + 'query' => $query, + ])); + } + } + /** * Duplicates an element. *