diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index c009b6c5c23..d02e9d83634 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -61,6 +61,7 @@ - Entry type names and handles must now be unique globally, rather than just within a single section. Existing entry type names and handles will be renamed automatically where needed, to ensure uniqueness. - Assets, categories, entries, and tags now support eager-loading paths prefixed with a field layout provider’s handle (e.g. `myEntryType:myField`). - Element queries now have an `eagerly` param, which can be used to lazily eager-load the resulting elements for all peer elements, when `all()`, `collect()`, `one()`, `nth()`, or `count()` is called. +- Element queries now have an `inBulkOp` param, which limits the results to elements which were involved in a bulk operation. ([#14032](https://github.com/craftcms/cms/pull/14032)) - Address queries now have `addressLine1`, `addressLine2`, `administrativeArea`, `countryCode`, `dependentLocality`, `firstName`, `fullName`, `lastName`, `locality`, `organizationTaxId`, `organization`, `postalCode`, and `sortingCode` params. - Entry queries now have `field`, `fieldId`, `primaryOwner`, `primaryOwnerId`, `owner`, `ownerId`, `allowOwnerDrafts`, and `allowOwnerRevisions` params. - Entries’ GraphQL type names are now formatted as `_Entry`, and are no longer prefixed with their section’s handle. (That goes for Matrix-nested entries as well.) @@ -171,6 +172,7 @@ - Added `craft\enums\PropagationMethod`. - Added `craft\enums\TimePeriod`. - Added `craft\events\BulkElementsEvent`. +- Added `craft\events\BulkOpEvent`. ([#14032](https://github.com/craftcms/cms/pull/14032)) - Added `craft\events\DefineEntryTypesForFieldEvent`. - Added `craft\events\DefineFieldHtmlEvent::$inline`. - Added `craft\fieldlayoutelements\BaseField::$includeInCards`. @@ -226,6 +228,11 @@ - Added `craft\models\Section::getCpEditUrl()`. - Added `craft\models\Volume::getSubpath()`. - Added `craft\models\Volume::setSubpath()`. +- Added `craft\queue\BaseBatchedElementJob`. ([#14032](https://github.com/craftcms/cms/pull/14032)) +- Added `craft\queue\BaseBatchedJob::after()`. +- Added `craft\queue\BaseBatchedJob::afterBatch()`. +- Added `craft\queue\BaseBatchedJob::before()`. +- Added `craft\queue\BaseBatchedJob::beforeBatch()`. - Added `craft\services\Auth`. - Added `craft\services\Entries::refreshEntryTypes()`. - Added `craft\services\Fields::$fieldContext`, which replaces `craft\services\Content::$fieldContext`. diff --git a/src/config/app.php b/src/config/app.php index 363e9605e39..073d8242846 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -4,7 +4,7 @@ 'id' => 'CraftCMS', 'name' => 'Craft CMS', 'version' => '5.0.0-alpha', - 'schemaVersion' => '5.0.0.10', + 'schemaVersion' => '5.0.0.11', 'minVersionRequired' => '4.4.0', 'basePath' => dirname(__DIR__), // Defines the @app alias 'runtimePath' => '@storage/runtime', // Defines the @runtime alias diff --git a/src/db/Table.php b/src/db/Table.php index 44365a87b9c..e340b6bdf9e 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -46,6 +46,8 @@ abstract class Table public const ELEMENTACTIVITY = '{{%elementactivity}}'; public const ELEMENTS = '{{%elements}}'; /** @since 5.0.0 */ + public const ELEMENTS_BULKOPS = '{{%elements_bulkops}}'; + /** @since 5.0.0 */ public const ELEMENTS_OWNERS = '{{%elements_owners}}'; public const ELEMENTS_SITES = '{{%elements_sites}}'; public const RESOURCEPATHS = '{{%resourcepaths}}'; diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index 3f677bcd99d..6d7a43b50dd 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -101,6 +101,9 @@ class ElementQuery extends Query implements ElementQueryInterface */ public const EVENT_AFTER_POPULATE_ELEMENTS = 'afterPopulateElements'; + // Base config attributes + // ------------------------------------------------------------------------- + /** * @var string The name of the [[ElementInterface]] class. * @phpstan-var class-string @@ -343,6 +346,14 @@ class ElementQuery extends Query implements ElementQueryInterface */ public mixed $search = null; + /** + * @var string|null The bulk element operation key that the resulting elements were involved in. + * + * @used-by ElementQuery::inBulkOp() + * @since 5.0.0 + */ + public ?string $inBulkOp = null; + /** * @var mixed The reference code(s) used to identify the element(s). * @@ -1075,6 +1086,16 @@ public function search($value): static return $this; } + /** + * @inheritdoc + * @uses $inBulkOp + */ + public function inBulkOp(?string $value): static + { + $this->inBulkOp = $value; + return $this; + } + /** * @inheritdoc * @uses $ref @@ -1531,6 +1552,7 @@ public function prepare($builder): Query $this->_applyStructureParams($class); $this->_applyRevisionParams(); $this->_applySearchParam($db); + $this->_applyInBulkOpParam(); $this->_applyOrderByParams($db); $this->_applySelectParam(); $this->_applyJoinParams(); @@ -2843,6 +2865,18 @@ private function _applySearchParam(Connection $db): void } } + /** + * Applies the 'inBulkOp' param to the query being prepared. + */ + private function _applyInBulkOpParam(): void + { + if ($this->inBulkOp) { + $this->subQuery + ->innerJoin(['elements_bulkops' => Table::ELEMENTS_BULKOPS], '[[elements_bulkops.elementId]] = [[elements.id]]') + ->andWhere(['elements_bulkops.key' => $this->inBulkOp]); + } + } + /** * Applies the 'fixedOrder' and 'orderBy' params to the query being prepared. * diff --git a/src/elements/db/ElementQueryInterface.php b/src/elements/db/ElementQueryInterface.php index e14fba0fad7..6e432916600 100644 --- a/src/elements/db/ElementQueryInterface.php +++ b/src/elements/db/ElementQueryInterface.php @@ -999,6 +999,15 @@ public function uri(mixed $value): static; */ public function search(mixed $value): static; + /** + * Narrows the query results to only {elements} that were involved in a bulk element operation. + * + * @param string|null $value The property value + * @return static self reference + * @since 5.0.0 + */ + public function inBulkOp(?string $value): static; + /** * Narrows the query results based on a reference string. * diff --git a/src/events/BulkOpEvent.php b/src/events/BulkOpEvent.php new file mode 100644 index 00000000000..8323c07c626 --- /dev/null +++ b/src/events/BulkOpEvent.php @@ -0,0 +1,22 @@ + + * @since 5.0.0 + */ +class BulkOpEvent extends ElementQueryEvent +{ + /** + * @var string The bulk operation key. + */ + public string $key; +} diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 59b3f3f3bac..ff8f04bc52d 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -323,6 +323,12 @@ public function createTables(): void 'deletedWithOwner' => $this->boolean()->null(), 'uid' => $this->uid(), ]); + $this->createTable(Table::ELEMENTS_BULKOPS, [ + 'elementId' => $this->integer(), + 'key' => $this->char(10)->notNull(), + 'timestamp' => $this->dateTime()->notNull(), + 'PRIMARY KEY([[elementId]], [[key]])', + ]); $this->createTable(Table::ELEMENTS_OWNERS, [ 'elementId' => $this->integer()->notNull(), 'ownerId' => $this->integer()->notNull(), @@ -811,6 +817,7 @@ public function createIndexes(): void $this->createIndex(null, Table::ELEMENTS, ['archived', 'dateCreated'], false); $this->createIndex(null, Table::ELEMENTS, ['archived', 'dateDeleted', 'draftId', 'revisionId', 'canonicalId'], false); $this->createIndex(null, Table::ELEMENTS, ['archived', 'dateDeleted', 'draftId', 'revisionId', 'canonicalId', 'enabled'], false); + $this->createIndex(null, Table::ELEMENTS_BULKOPS, ['timestamp'], false); $this->createIndex(null, Table::ELEMENTS_SITES, ['elementId', 'siteId'], true); $this->createIndex(null, Table::ELEMENTS_SITES, ['siteId'], false); $this->createIndex(null, Table::ELEMENTS_SITES, ['title', 'siteId'], false); @@ -986,6 +993,7 @@ public function addForeignKeys(): void $this->addForeignKey(null, Table::ELEMENTS, ['draftId'], Table::DRAFTS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::ELEMENTS, ['revisionId'], Table::REVISIONS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::ELEMENTS, ['fieldLayoutId'], Table::FIELDLAYOUTS, ['id'], 'SET NULL', null); + $this->addForeignKey(null, Table::ELEMENTS_BULKOPS, ['elementId'], Table::ELEMENTS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::ELEMENTS_OWNERS, ['elementId'], Table::ELEMENTS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::ELEMENTS_OWNERS, ['ownerId'], Table::ELEMENTS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::ELEMENTS_SITES, ['elementId'], Table::ELEMENTS, ['id'], 'CASCADE', null); diff --git a/src/migrations/m231213_030600_element_bulk_ops.php b/src/migrations/m231213_030600_element_bulk_ops.php new file mode 100644 index 00000000000..462e61addaf --- /dev/null +++ b/src/migrations/m231213_030600_element_bulk_ops.php @@ -0,0 +1,37 @@ +createTable(Table::ELEMENTS_BULKOPS, [ + 'elementId' => $this->integer(), + 'key' => $this->char(10)->notNull(), + 'timestamp' => $this->dateTime()->notNull(), + 'PRIMARY KEY([[elementId]], [[key]])', + ]); + $this->addForeignKey(null, Table::ELEMENTS_BULKOPS, ['elementId'], Table::ELEMENTS, ['id'], 'CASCADE', null); + $this->createIndex(null, Table::ELEMENTS_BULKOPS, ['timestamp'], false); + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m231213_030600_element_bulk_ops cannot be reverted.\n"; + return false; + } +} diff --git a/src/queue/BaseBatchedElementJob.php b/src/queue/BaseBatchedElementJob.php new file mode 100644 index 00000000000..3a1dda150ae --- /dev/null +++ b/src/queue/BaseBatchedElementJob.php @@ -0,0 +1,47 @@ + + * @since 5.0.0 + */ +abstract class BaseBatchedElementJob extends BaseBatchedJob +{ + /** @internal */ + public string $bulkOpKey; + + /** + * @inheritdoc + */ + protected function before(): void + { + $this->bulkOpKey = Craft::$app->getElements()->beginBulkOp(); + } + + /** + * @inheritdoc + */ + protected function beforeBatch(): void + { + Craft::$app->getElements()->resumeBulkOp($this->bulkOpKey); + } + + /** + * @inheritdoc + */ + protected function after(): void + { + Craft::$app->getElements()->endBulkOp($this->bulkOpKey); + } +} diff --git a/src/queue/BaseBatchedJob.php b/src/queue/BaseBatchedJob.php index 80bbb80175b..513750f3831 100644 --- a/src/queue/BaseBatchedJob.php +++ b/src/queue/BaseBatchedJob.php @@ -120,6 +120,12 @@ public function execute($queue): void $memoryLimit = ConfigHelper::sizeInBytes(ini_get('memory_limit')); $startMemory = $memoryLimit != -1 ? memory_get_usage() : null; + if ($this->itemOffset === 0) { + $this->before(); + } + + $this->beforeBatch(); + $i = 0; foreach ($items as $item) { @@ -141,11 +147,15 @@ public function execute($queue): void } } + $this->afterBatch(); + // Spawn another job if there are more items if ($this->itemOffset < $this->totalItems()) { $nextJob = clone $this; $nextJob->batchIndex++; QueueHelper::push($nextJob, $this->priority, 0, $this->ttr, $queue); + } else { + $this->after(); } } @@ -156,6 +166,42 @@ public function execute($queue): void */ abstract protected function processItem(mixed $item): void; + /** + * Does things before the first item of the first batch. + * + * @since 5.0.0 + */ + protected function before(): void + { + } + + /** + * Does things after the last item of the last batch. + * + * @since 5.0.0 + */ + protected function after(): void + { + } + + /** + * Does things before the first item of the current batch. + * + * @since 5.0.0 + */ + protected function beforeBatch(): void + { + } + + /** + * Does things after the last item of the current batch. + * + * @since 5.0.0 + */ + protected function afterBatch(): void + { + } + /** * @inheritdoc */ diff --git a/src/queue/jobs/ApplyNewPropagationMethod.php b/src/queue/jobs/ApplyNewPropagationMethod.php index 224c7eabd74..f56ba8f5b93 100644 --- a/src/queue/jobs/ApplyNewPropagationMethod.php +++ b/src/queue/jobs/ApplyNewPropagationMethod.php @@ -18,7 +18,7 @@ use craft\helpers\Db; use craft\helpers\ElementHelper; use craft\i18n\Translation; -use craft\queue\BaseBatchedJob; +use craft\queue\BaseBatchedElementJob; use craft\services\Structures; use Throwable; @@ -30,7 +30,7 @@ * @author Pixel & Tonic, Inc. * @since 3.4.8 */ -class ApplyNewPropagationMethod extends BaseBatchedJob +class ApplyNewPropagationMethod extends BaseBatchedElementJob { /** * @var string The element type to use diff --git a/src/queue/jobs/PropagateElements.php b/src/queue/jobs/PropagateElements.php index 9362197f66d..2be20062362 100644 --- a/src/queue/jobs/PropagateElements.php +++ b/src/queue/jobs/PropagateElements.php @@ -14,7 +14,7 @@ use craft\db\QueryBatcher; use craft\helpers\ElementHelper; use craft\i18n\Translation; -use craft\queue\BaseBatchedJob; +use craft\queue\BaseBatchedElementJob; /** * PropagateElements job @@ -22,7 +22,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.13 */ -class PropagateElements extends BaseBatchedJob +class PropagateElements extends BaseBatchedElementJob { /** * @var string The element type that should be propagated diff --git a/src/queue/jobs/ResaveElements.php b/src/queue/jobs/ResaveElements.php index d97d7041816..60e217540fe 100644 --- a/src/queue/jobs/ResaveElements.php +++ b/src/queue/jobs/ResaveElements.php @@ -15,7 +15,7 @@ use craft\db\QueryBatcher; use craft\helpers\ElementHelper; use craft\i18n\Translation; -use craft\queue\BaseBatchedJob; +use craft\queue\BaseBatchedElementJob; use Throwable; /** @@ -24,7 +24,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class ResaveElements extends BaseBatchedJob +class ResaveElements extends BaseBatchedElementJob { /** * @var string The element type that should be resaved diff --git a/src/services/Elements.php b/src/services/Elements.php index 220d9ef1073..6003e2d2694 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -37,6 +37,7 @@ use craft\errors\SiteNotFoundException; use craft\errors\UnsupportedSiteException; use craft\events\AuthorizationCheckEvent; +use craft\events\BulkOpEvent; use craft\events\DeleteElementEvent; use craft\events\EagerLoadElementsEvent; use craft\events\ElementEvent; @@ -114,6 +115,24 @@ class Elements extends Component */ public const EVENT_BEFORE_EAGER_LOAD_ELEMENTS = 'beforeEagerLoadElements'; + /** + * @event BulkOpEvent The event that is triggered before a bulk element operation has started. + * + * Note that this won’t necessarily fire from the same request as [[EVENT_AFTER_BULK_OP]]. + * + * @since 5.0.0 + */ + public const EVENT_BEFORE_BULK_OP = 'beforeBulkOp'; + + /** + * @event BulkOpEvent The event that is triggered after a bulk element operation is completed. + * + * Note that this won’t necessarily fire from the same request as [[EVENT_BEFORE_BULK_OP]]. + * + * @since 5.0.0 + */ + public const EVENT_AFTER_BULK_OP = 'afterBulkOp'; + /** * @event MergeElementsEvent The event that is triggered after two elements are merged together. */ @@ -1036,6 +1055,99 @@ public function getEnabledSiteIdsForElement(int $elementId): array ->column(); } + // Bulk ops + // ------------------------------------------------------------------------- + + private array $bulkKeys = []; + + /** + * Begins tracking element saves and deletes as part of a bulk operation, identified by a unique key. + * + * @return string The bulk operation key + * @since 5.0.0 + */ + public function beginBulkOp(): string + { + $key = StringHelper::randomString(10); + + if ($this->hasEventHandlers(self::EVENT_BEFORE_BULK_OP)) { + $this->trigger(self::EVENT_BEFORE_BULK_OP, new BulkOpEvent([ + 'key' => $key, + ])); + } + + $this->resumeBulkOp($key); + return $key; + } + + /** + * Resumes tracking element saves and deletes as part of a bulk operation. + * + * @param string $key The bulk operation key returned by [[beginBulkOp()]]. + * @since 5.0.0 + */ + public function resumeBulkOp(string $key): void + { + $this->bulkKeys[$key] = true; + } + + /** + * Finishes tracking element saves and deletes as part of a bulk operation. + * + * @param string $key The bulk operation key returned by [[beginBulkOp()]]. + * @since 5.0.0 + */ + public function endBulkOp(string $key): void + { + unset($this->bulkKeys[$key]); + + if ($this->hasEventHandlers(self::EVENT_AFTER_BULK_OP)) { + $this->trigger(self::EVENT_AFTER_BULK_OP, new BulkOpEvent([ + 'key' => $key, + ])); + } + + Db::delete(Table::ELEMENTS_BULKOPS, ['key' => $key]); + } + + /** + * Tracks an element as being affected by any active bulk operations. + * + * @param ElementInterface $element + * @since 5.0.0 + */ + public function trackElementInBulkOps(ElementInterface $element): void + { + if (empty($this->bulkKeys)) { + return; + } + + $timestamp = Db::prepareDateForDb(DateTimeHelper::now()); + + foreach (array_keys($this->bulkKeys) as $key) { + Db::upsert(Table::ELEMENTS_BULKOPS, [ + 'elementId' => $element->id, + 'key' => $key, + 'timestamp' => $timestamp, + ]); + } + } + + private function ensureBulkOp(callable $callback): void + { + if (empty($this->bulkKeys)) { + $bulkKey = $this->beginBulkOp(); + } + + try { + $callback(); + } finally { + if (isset($bulkKey)) { + $this->endBulkOp($bulkKey); + } + } + } + // Saving Elements // ------------------------------------------------------------------------- @@ -1169,38 +1281,40 @@ public function mergeCanonicalChanges(ElementInterface $element): void ])); } - Craft::$app->getDb()->transaction(function() use ($element, $supportedSites) { - // Start with the other sites (if any), so we don't update dateLastMerged until the end - $otherSiteIds = ArrayHelper::withoutValue(array_keys($supportedSites), $element->siteId); - if (!empty($otherSiteIds)) { - $siteElements = $this->_localizedElementQuery($element) - ->siteId($otherSiteIds) - ->status(null) - ->all(); - } else { - $siteElements = []; - } + $this->ensureBulkOp(function() use ($element, $supportedSites) { + Craft::$app->getDb()->transaction(function() use ($element, $supportedSites) { + // Start with the other sites (if any), so we don't update dateLastMerged until the end + $otherSiteIds = ArrayHelper::withoutValue(array_keys($supportedSites), $element->siteId); + if (!empty($otherSiteIds)) { + $siteElements = $this->_localizedElementQuery($element) + ->siteId($otherSiteIds) + ->status(null) + ->all(); + } else { + $siteElements = []; + } - foreach ($siteElements as $siteElement) { - $siteElement->mergeCanonicalChanges(); - $siteElement->mergingCanonicalChanges = true; - $this->_saveElementInternal($siteElement, false, false, null, $supportedSites); - } + foreach ($siteElements as $siteElement) { + $siteElement->mergeCanonicalChanges(); + $siteElement->mergingCanonicalChanges = true; + $this->_saveElementInternal($siteElement, false, false, null, $supportedSites); + } - // Now the $element’s site - $element->mergeCanonicalChanges(); - $duplicateOf = $element->duplicateOf; - $element->duplicateOf = null; - $element->dateLastMerged = DateTimeHelper::now(); - $element->mergingCanonicalChanges = true; - $this->_saveElementInternal($element, false, false, null, $supportedSites); - $element->duplicateOf = $duplicateOf; + // Now the $element’s site + $element->mergeCanonicalChanges(); + $duplicateOf = $element->duplicateOf; + $element->duplicateOf = null; + $element->dateLastMerged = DateTimeHelper::now(); + $element->mergingCanonicalChanges = true; + $this->_saveElementInternal($element, false, false, null, $supportedSites); + $element->duplicateOf = $duplicateOf; - // It's now fully merged and propagated - $element->afterPropagate(false); - }); + // It's now fully merged and propagated + $element->afterPropagate(false); + }); - $element->mergingCanonicalChanges = false; + $element->mergingCanonicalChanges = false; + }); // Fire an 'afterMergeCanonical' event if ($this->hasEventHandlers(self::EVENT_AFTER_MERGE_CANONICAL_CHANGES)) { @@ -1258,8 +1372,6 @@ public function updateCanonicalElement(ElementInterface $element, array $newAttr ->where(['elementId' => $element->id]) ->all(); - $fieldsService = Craft::$app->getFields(); - $newAttributes += [ 'id' => $canonical->id, 'uid' => $canonical->uid, @@ -1356,64 +1468,66 @@ public function resaveElements( ])); } - $position = 0; + $this->ensureBulkOp(function() use ($query, $skipRevisions, $touch, $updateSearchIndex, $continueOnError) { + $position = 0; - try { - foreach (Db::each($query) as $element) { - /** @var ElementInterface $element */ - $position++; + try { + foreach (Db::each($query) as $element) { + /** @var ElementInterface $element */ + $position++; - $element->setScenario(Element::SCENARIO_ESSENTIALS); - $element->resaving = true; + $element->setScenario(Element::SCENARIO_ESSENTIALS); + $element->resaving = true; - $e = null; - try { - // Fire a 'beforeResaveElement' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_RESAVE_ELEMENT)) { - $this->trigger(self::EVENT_BEFORE_RESAVE_ELEMENT, new MultiElementActionEvent([ - 'query' => $query, - 'element' => $element, - 'position' => $position, - ])); - } + $e = null; + try { + // Fire a 'beforeResaveElement' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_RESAVE_ELEMENT)) { + $this->trigger(self::EVENT_BEFORE_RESAVE_ELEMENT, new MultiElementActionEvent([ + 'query' => $query, + 'element' => $element, + 'position' => $position, + ])); + } - // Make sure this isn't a revision - if ($skipRevisions) { - try { - if (ElementHelper::isRevision($element)) { - throw new InvalidElementException($element, "Skipped resaving {$element->getUiLabel()} ($element->id) because it's a revision."); + // Make sure this isn't a revision + if ($skipRevisions) { + try { + if (ElementHelper::isRevision($element)) { + throw new InvalidElementException($element, "Skipped resaving {$element->getUiLabel()} ($element->id) because it's a revision."); + } + } catch (Throwable $rootException) { + throw new InvalidElementException($element, "Skipped resaving {$element->getUiLabel()} ($element->id) due to an error obtaining its root element: " . $rootException->getMessage()); } - } catch (Throwable $rootException) { - throw new InvalidElementException($element, "Skipped resaving {$element->getUiLabel()} ($element->id) due to an error obtaining its root element: " . $rootException->getMessage()); } + } catch (InvalidElementException $e) { } - } catch (InvalidElementException $e) { - } - if ($e === null) { - try { - $this->_saveElementInternal($element, true, true, $updateSearchIndex, forceTouch: $touch); - } catch (Throwable $e) { - if (!$continueOnError) { - throw $e; + if ($e === null) { + try { + $this->_saveElementInternal($element, true, true, $updateSearchIndex, forceTouch: $touch); + } catch (Throwable $e) { + if (!$continueOnError) { + throw $e; + } + Craft::$app->getErrorHandler()->logException($e); } - Craft::$app->getErrorHandler()->logException($e); } - } - // Fire an 'afterResaveElement' event - if ($this->hasEventHandlers(self::EVENT_AFTER_RESAVE_ELEMENT)) { - $this->trigger(self::EVENT_AFTER_RESAVE_ELEMENT, new MultiElementActionEvent([ - 'query' => $query, - 'element' => $element, - 'position' => $position, - 'exception' => $e, - ])); + // Fire an 'afterResaveElement' event + if ($this->hasEventHandlers(self::EVENT_AFTER_RESAVE_ELEMENT)) { + $this->trigger(self::EVENT_AFTER_RESAVE_ELEMENT, new MultiElementActionEvent([ + 'query' => $query, + 'element' => $element, + 'position' => $position, + 'exception' => $e, + ])); + } } + } catch (QueryAbortedException) { + // Fail silently } - } catch (QueryAbortedException) { - // Fail silently - } + }); // Fire an 'afterResaveElements' event if ($this->hasEventHandlers(self::EVENT_AFTER_RESAVE_ELEMENTS)) { @@ -1450,70 +1564,75 @@ public function propagateElements(ElementQueryInterface $query, array|int $siteI }, (array)$siteIds); } - $position = 0; + $this->ensureBulkOp(function() use ($query, $siteIds, $continueOnError) { + $position = 0; - try { - foreach (Db::each($query) as $element) { - /** @var ElementInterface $element */ - $position++; - - // Fire a 'beforePropagateElement' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_PROPAGATE_ELEMENT)) { - $this->trigger(self::EVENT_BEFORE_PROPAGATE_ELEMENT, new MultiElementActionEvent([ - 'query' => $query, - 'element' => $element, - 'position' => $position, - ])); - } + try { + foreach (Db::each($query) as $element) { + /** @var ElementInterface $element */ + $position++; - $element->setScenario(Element::SCENARIO_ESSENTIALS); - $supportedSites = ArrayHelper::index(ElementHelper::supportedSitesForElement($element), 'siteId'); - $supportedSiteIds = array_keys($supportedSites); - $elementSiteIds = $siteIds !== null ? array_intersect($siteIds, $supportedSiteIds) : $supportedSiteIds; - /** @var string|ElementInterface $elementType */ - $elementType = get_class($element); + // Fire a 'beforePropagateElement' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_PROPAGATE_ELEMENT)) { + $this->trigger(self::EVENT_BEFORE_PROPAGATE_ELEMENT, new MultiElementActionEvent([ + 'query' => $query, + 'element' => $element, + 'position' => $position, + ])); + } - $e = null; - try { - $element->newSiteIds = []; - - foreach ($elementSiteIds as $siteId) { - if ($siteId != $element->siteId) { - // Make sure the site element wasn't updated more recently than the main one - $siteElement = $this->getElementById($element->id, $elementType, $siteId); - if ($siteElement === null || $siteElement->dateUpdated < $element->dateUpdated) { - $siteElement ??= false; - $this->_propagateElement($element, $supportedSites, $siteId, $siteElement); + $element->setScenario(Element::SCENARIO_ESSENTIALS); + $supportedSites = ArrayHelper::index(ElementHelper::supportedSitesForElement($element), 'siteId'); + $supportedSiteIds = array_keys($supportedSites); + $elementSiteIds = $siteIds !== null ? array_intersect($siteIds, $supportedSiteIds) : $supportedSiteIds; + /** @var string|ElementInterface $elementType */ + $elementType = get_class($element); + + $e = null; + try { + $element->newSiteIds = []; + + foreach ($elementSiteIds as $siteId) { + if ($siteId != $element->siteId) { + // Make sure the site element wasn't updated more recently than the main one + $siteElement = $this->getElementById($element->id, $elementType, $siteId); + if ($siteElement === null || $siteElement->dateUpdated < $element->dateUpdated) { + $siteElement ??= false; + $this->_propagateElement($element, $supportedSites, $siteId, $siteElement); + } } } + + // It's now fully duplicated and propagated + $element->markAsDirty(); + $element->afterPropagate(false); + } catch (Throwable $e) { + if (!$continueOnError) { + throw $e; + } + Craft::$app->getErrorHandler()->logException($e); } - // It's now fully duplicated and propagated - $element->markAsDirty(); - $element->afterPropagate(false); - } catch (Throwable $e) { - if (!$continueOnError) { - throw $e; + // Fire an 'afterPropagateElement' event + if ($this->hasEventHandlers(self::EVENT_AFTER_PROPAGATE_ELEMENT)) { + $this->trigger(self::EVENT_AFTER_PROPAGATE_ELEMENT, new MultiElementActionEvent([ + 'query' => $query, + 'element' => $element, + 'position' => $position, + 'exception' => $e, + ])); } - Craft::$app->getErrorHandler()->logException($e); - } - // Fire an 'afterPropagateElement' event - if ($this->hasEventHandlers(self::EVENT_AFTER_PROPAGATE_ELEMENT)) { - $this->trigger(self::EVENT_AFTER_PROPAGATE_ELEMENT, new MultiElementActionEvent([ - 'query' => $query, - 'element' => $element, - 'position' => $position, - 'exception' => $e, - ])); - } + // Track this element in bulk operations + $this->trackElementInBulkOps($element); - // Clear caches - $this->invalidateCachesForElement($element); + // Clear caches + $this->invalidateCachesForElement($element); + } + } catch (QueryAbortedException) { + // Fail silently } - } catch (QueryAbortedException) { - // Fail silently - } + }); // Fire an 'afterPropagateElements' event if ($this->hasEventHandlers(self::EVENT_AFTER_PROPAGATE_ELEMENTS)) { @@ -1639,119 +1758,129 @@ public function duplicateElement( throw new InvalidElementException($mainClone, 'Element ' . $element->id . ' could not be duplicated because it doesn\'t validate.'); } - $transaction = Craft::$app->getDb()->beginTransaction(); - try { - // Start with $element’s site - if (!$this->_saveElementInternal($mainClone, false, false, null, $supportedSites)) { - throw new InvalidElementException($mainClone, 'Element ' . $element->id . ' could not be duplicated for site ' . $element->siteId); - } + $this->ensureBulkOp(function() use ( + $mainClone, + $supportedSites, + $element, + $placeInStructure, + $newAttributes, + $behaviors, + $siteAttributes, + ) { + $transaction = Craft::$app->getDb()->beginTransaction(); + try { + // Start with $element’s site + if (!$this->_saveElementInternal($mainClone, false, false, null, $supportedSites)) { + throw new InvalidElementException($mainClone, 'Element ' . $element->id . ' could not be duplicated for site ' . $element->siteId); + } - // Should we add the clone to the source element’s structure? - if ( - $placeInStructure && - $mainClone->getIsCanonical() && - !$mainClone->root && - (!$mainClone->structureId || !$element->structureId || $mainClone->structureId == $element->structureId) - ) { - $canonical = $element->getCanonical(true); - if ($canonical->structureId && $canonical->root) { - $mode = isset($newAttributes['id']) ? Structures::MODE_AUTO : Structures::MODE_INSERT; - Craft::$app->getStructures()->moveAfter($canonical->structureId, $mainClone, $canonical, $mode); + // Should we add the clone to the source element’s structure? + if ( + $placeInStructure && + $mainClone->getIsCanonical() && + !$mainClone->root && + (!$mainClone->structureId || !$element->structureId || $mainClone->structureId == $element->structureId) + ) { + $canonical = $element->getCanonical(true); + if ($canonical->structureId && $canonical->root) { + $mode = isset($newAttributes['id']) ? Structures::MODE_AUTO : Structures::MODE_INSERT; + Craft::$app->getStructures()->moveAfter($canonical->structureId, $mainClone, $canonical, $mode); + } } - } - $mainClone->newSiteIds = []; + $mainClone->newSiteIds = []; - // Propagate it - $otherSiteIds = ArrayHelper::withoutValue(array_keys($supportedSites), $mainClone->siteId); - if ($element->id && !empty($otherSiteIds)) { - $siteElements = $this->_localizedElementQuery($element) - ->siteId($otherSiteIds) - ->status(null) - ->all(); + // Propagate it + $otherSiteIds = ArrayHelper::withoutValue(array_keys($supportedSites), $mainClone->siteId); + if ($element->id && !empty($otherSiteIds)) { + $siteElements = $this->_localizedElementQuery($element) + ->siteId($otherSiteIds) + ->status(null) + ->all(); - foreach ($siteElements as $siteElement) { - // Ensure all fields have been normalized - $siteElement->getFieldValues(); - - $siteClone = clone $siteElement; - $siteClone->duplicateOf = $siteElement; - $siteClone->propagating = true; - $siteClone->id = $mainClone->id; - $siteClone->uid = $mainClone->uid; - $siteClone->structureId = $mainClone->structureId; - $siteClone->root = $mainClone->root; - $siteClone->lft = $mainClone->lft; - $siteClone->rgt = $mainClone->rgt; - $siteClone->level = $mainClone->level; - $siteClone->enabled = $mainClone->enabled; - $siteClone->siteSettingsId = null; - $siteClone->dateCreated = $mainClone->dateCreated; - $siteClone->dateUpdated = $mainClone->dateUpdated; - $siteClone->dateLastMerged = null; - $siteClone->setCanonicalId(null); - - // Attach behaviors - foreach ($behaviors as $name => $behavior) { - if ($behavior instanceof Behavior) { - $behavior = clone $behavior; + foreach ($siteElements as $siteElement) { + // Ensure all fields have been normalized + $siteElement->getFieldValues(); + + $siteClone = clone $siteElement; + $siteClone->duplicateOf = $siteElement; + $siteClone->propagating = true; + $siteClone->id = $mainClone->id; + $siteClone->uid = $mainClone->uid; + $siteClone->structureId = $mainClone->structureId; + $siteClone->root = $mainClone->root; + $siteClone->lft = $mainClone->lft; + $siteClone->rgt = $mainClone->rgt; + $siteClone->level = $mainClone->level; + $siteClone->enabled = $mainClone->enabled; + $siteClone->siteSettingsId = null; + $siteClone->dateCreated = $mainClone->dateCreated; + $siteClone->dateUpdated = $mainClone->dateUpdated; + $siteClone->dateLastMerged = null; + $siteClone->setCanonicalId(null); + + // Attach behaviors + foreach ($behaviors as $name => $behavior) { + if ($behavior instanceof Behavior) { + $behavior = clone $behavior; + } + $siteClone->attachBehavior($name, $behavior); } - $siteClone->attachBehavior($name, $behavior); - } - // Note: must use Craft::configure() rather than setAttributes() here, - // so we're not limited to whatever attributes() returns - Craft::configure($siteClone, ArrayHelper::merge( - $newAttributes, - $siteAttributes[$siteElement->siteId] ?? [], - )); - $siteClone->siteId = $siteElement->siteId; - - // Clone any field values that are objects (without affecting the dirty fields) - $dirtyFields = $siteClone->getDirtyFields(); - foreach ($siteClone->getFieldValues() as $handle => $value) { - if (is_object($value) && !$value instanceof UnitEnum) { - $siteClone->setFieldValue($handle, clone $value); + // Note: must use Craft::configure() rather than setAttributes() here, + // so we're not limited to whatever attributes() returns + Craft::configure($siteClone, ArrayHelper::merge( + $newAttributes, + $siteAttributes[$siteElement->siteId] ?? [], + )); + $siteClone->siteId = $siteElement->siteId; + + // Clone any field values that are objects (without affecting the dirty fields) + $dirtyFields = $siteClone->getDirtyFields(); + foreach ($siteClone->getFieldValues() as $handle => $value) { + if (is_object($value) && !$value instanceof UnitEnum) { + $siteClone->setFieldValue($handle, clone $value); + } } - } - $siteClone->setDirtyFields($dirtyFields, false); + $siteClone->setDirtyFields($dirtyFields, false); - if ($element::hasUris()) { - // Make sure it has a valid slug - (new SlugValidator())->validateAttribute($siteClone, 'slug'); - if ($siteClone->hasErrors('slug')) { - throw new InvalidElementException($siteClone, "Element $element->id could not be duplicated for site $siteElement->siteId: " . $siteClone->getFirstError('slug')); - } + if ($element::hasUris()) { + // Make sure it has a valid slug + (new SlugValidator())->validateAttribute($siteClone, 'slug'); + if ($siteClone->hasErrors('slug')) { + throw new InvalidElementException($siteClone, "Element $element->id could not be duplicated for site $siteElement->siteId: " . $siteClone->getFirstError('slug')); + } - // Set a unique URI on the site clone - try { - $this->setElementUri($siteClone); - } catch (OperationAbortedException) { - // Oh well, not worth bailing over + // Set a unique URI on the site clone + try { + $this->setElementUri($siteClone); + } catch (OperationAbortedException) { + // Oh well, not worth bailing over + } } - } - if (!$this->_saveElementInternal($siteClone, false, false, supportedSites: $supportedSites)) { - throw new InvalidElementException($siteClone, "Element $element->id could not be duplicated for site $siteElement->siteId: " . implode(', ', $siteClone->getFirstErrors())); - } + if (!$this->_saveElementInternal($siteClone, false, false, supportedSites: $supportedSites)) { + throw new InvalidElementException($siteClone, "Element $element->id could not be duplicated for site $siteElement->siteId: " . implode(', ', $siteClone->getFirstErrors())); + } - if ($siteClone->isNewForSite) { - $mainClone->newSiteIds[] = $siteClone->siteId; + if ($siteClone->isNewForSite) { + $mainClone->newSiteIds[] = $siteClone->siteId; + } } } - } - // It's now fully duplicated and propagated - $mainClone->afterPropagate(empty($newAttributes['id'])); + // It's now fully duplicated and propagated + $mainClone->afterPropagate(empty($newAttributes['id'])); - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } + $transaction->commit(); + } catch (Throwable $e) { + $transaction->rollBack(); + throw $e; + } - // Clean up our tracks - $mainClone->duplicateOf = null; + // Clean up our tracks + $mainClone->duplicateOf = null; + }); return $mainClone; } @@ -2086,55 +2215,62 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals return false; } - $db = Craft::$app->getDb(); - $transaction = $db->beginTransaction(); - try { - // First delete any structure nodes with this element, so NestedSetBehavior can do its thing. - while (($record = StructureElementRecord::findOne(['elementId' => $element->id])) !== null) { - // If this element still has any children, move them up before the one getting deleted. - while (($child = $record->children(1)->one()) !== null) { - /** @var StructureElementRecord $child */ - $child->insertBefore($record); - // Re-fetch the record since its lft and rgt attributes just changed - $record = StructureElementRecord::findOne($record->id); + $this->ensureBulkOp(function() use ($element) { + $db = Craft::$app->getDb(); + $transaction = $db->beginTransaction(); + try { + // First delete any structure nodes with this element, so NestedSetBehavior can do its thing. + while (($record = StructureElementRecord::findOne(['elementId' => $element->id])) !== null) { + // If this element still has any children, move them up before the one getting deleted. + while (($child = $record->children(1)->one()) !== null) { + /** @var StructureElementRecord $child */ + $child->insertBefore($record); + // Re-fetch the record since its lft and rgt attributes just changed + $record = StructureElementRecord::findOne($record->id); + } + // Delete this element’s node + $record->deleteWithChildren(); } - // Delete this element’s node - $record->deleteWithChildren(); - } - // Invalidate any caches involving this element - $this->invalidateCachesForElement($element); + // Invalidate any caches involving this element + $this->invalidateCachesForElement($element); - DateTimeHelper::pause(); + DateTimeHelper::pause(); - if ($element->hardDelete) { - Db::delete(Table::ELEMENTS, [ - 'id' => $element->id, - ]); - Db::delete(Table::SEARCHINDEX, [ - 'elementId' => $element->id, - ]); - } else { - // Soft delete the elements table row - Db::update(Table::ELEMENTS, [ - 'dateDeleted' => Db::prepareDateForDb(new DateTime()), - 'deletedWithOwner' => $element->deletedWithOwner, - ], ['id' => $element->id]); + if ($element->hardDelete) { + Db::delete(Table::ELEMENTS, [ + 'id' => $element->id, + ]); + Db::delete(Table::SEARCHINDEX, [ + 'elementId' => $element->id, + ]); + } else { + // Soft delete the elements table row + Db::update(Table::ELEMENTS, [ + 'dateDeleted' => Db::prepareDateForDb(new DateTime()), + 'deletedWithOwner' => $element->deletedWithOwner, + ], ['id' => $element->id]); + + // Also soft delete the element’s drafts & revisions + $this->_cascadeDeleteDraftsAndRevisions($element->id); + } - // Also soft delete the element’s drafts & revisions - $this->_cascadeDeleteDraftsAndRevisions($element->id); - } + $element->dateDeleted = DateTimeHelper::now(); + $element->afterDelete(); - $element->dateDeleted = DateTimeHelper::now(); - $element->afterDelete(); + if (!$element->hardDelete) { + // Track this element in bulk operations + $this->trackElementInBulkOps($element); + } - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; - } finally { - DateTimeHelper::resume(); - } + $transaction->commit(); + } catch (Throwable $e) { + $transaction->rollBack(); + throw $e; + } finally { + DateTimeHelper::resume(); + } + }); // Fire an 'afterDeleteElement' event if ($this->hasEventHandlers(self::EVENT_AFTER_DELETE_ELEMENT)) { @@ -3143,7 +3279,13 @@ public function propagateElement( ElementInterface|false|null $siteElement = null, ): ElementInterface { $supportedSites = ArrayHelper::index(ElementHelper::supportedSitesForElement($element), 'siteId'); - $this->_propagateElement($element, $supportedSites, $siteId, $siteElement); + + $this->ensureBulkOp(function() use ($element, $supportedSites, $siteId, $siteElement) { + $this->_propagateElement($element, $supportedSites, $siteId, $siteElement); + + // Track this element in bulk operations + $this->trackElementInBulkOps($element); + }); // Clear caches $this->invalidateCachesForElement($element); @@ -3278,306 +3420,327 @@ private function _saveElementInternal( } } - // Figure out whether we will be updating the search index (and memoize that for nested element saves) - $oldUpdateSearchIndex = $this->_updateSearchIndex; - $updateSearchIndex = $this->_updateSearchIndex = $updateSearchIndex ?? $this->_updateSearchIndex ?? true; + $this->ensureBulkOp(function() use ( + $element, + $isNewElement, + $originalFirstSave, + $originalPropagateAll, + $forceTouch, + $trackChanges, + $dirtyAttributes, + $updateSearchIndex, + $fieldLayout, + $propagate, + $supportedSites, + $crossSiteValidate, + $runValidation, + $originalDateUpdated, + $dirtyFields, + ) { + // Figure out whether we will be updating the search index (and memoize that for nested element saves) + $oldUpdateSearchIndex = $this->_updateSearchIndex; + $updateSearchIndex = $this->_updateSearchIndex = $updateSearchIndex ?? $this->_updateSearchIndex ?? true; - $newSiteIds = $element->newSiteIds; - $element->newSiteIds = []; + $newSiteIds = $element->newSiteIds; + $element->newSiteIds = []; - $transaction = Craft::$app->getDb()->beginTransaction(); + $transaction = Craft::$app->getDb()->beginTransaction(); - try { - // No need to save the element record multiple times - if (!$element->propagating) { - // Get the element record - if (!$isNewElement) { - $elementRecord = ElementRecord::findOne($element->id); + try { + // No need to save the element record multiple times + if (!$element->propagating) { + // Get the element record + if (!$isNewElement) { + $elementRecord = ElementRecord::findOne($element->id); - if (!$elementRecord) { - $element->firstSave = $originalFirstSave; - $element->propagateAll = $originalPropagateAll; - throw new ElementNotFoundException("No element exists with the ID '$element->id'"); + if (!$elementRecord) { + $element->firstSave = $originalFirstSave; + $element->propagateAll = $originalPropagateAll; + throw new ElementNotFoundException("No element exists with the ID '$element->id'"); + } + } else { + $elementRecord = new ElementRecord(); + $elementRecord->type = get_class($element); } - } else { - $elementRecord = new ElementRecord(); - $elementRecord->type = get_class($element); - } - - // Set the attributes - $elementRecord->uid = $element->uid; - $elementRecord->canonicalId = $element->getIsDerivative() ? $element->getCanonicalId() : null; - $elementRecord->draftId = (int)$element->draftId ?: null; - $elementRecord->revisionId = (int)$element->revisionId ?: null; - $elementRecord->fieldLayoutId = $element->fieldLayoutId = (int)($element->fieldLayoutId ?? $fieldLayout?->id ?? 0) ?: null; - $elementRecord->enabled = (bool)$element->enabled; - $elementRecord->archived = (bool)$element->archived; - $elementRecord->dateLastMerged = Db::prepareDateForDb($element->dateLastMerged); - $elementRecord->dateDeleted = Db::prepareDateForDb($element->dateDeleted); - if ($isNewElement) { - if (isset($element->dateCreated)) { - $elementRecord->dateCreated = Db::prepareValueForDb($element->dateCreated); - } - if (isset($element->dateUpdated)) { - $elementRecord->dateUpdated = Db::prepareValueForDb($element->dateUpdated); + // Set the attributes + $elementRecord->uid = $element->uid; + $elementRecord->canonicalId = $element->getIsDerivative() ? $element->getCanonicalId() : null; + $elementRecord->draftId = (int)$element->draftId ?: null; + $elementRecord->revisionId = (int)$element->revisionId ?: null; + $elementRecord->fieldLayoutId = $element->fieldLayoutId = (int)($element->fieldLayoutId ?? $fieldLayout?->id ?? 0) ?: null; + $elementRecord->enabled = (bool)$element->enabled; + $elementRecord->archived = (bool)$element->archived; + $elementRecord->dateLastMerged = Db::prepareDateForDb($element->dateLastMerged); + $elementRecord->dateDeleted = Db::prepareDateForDb($element->dateDeleted); + + if ($isNewElement) { + if (isset($element->dateCreated)) { + $elementRecord->dateCreated = Db::prepareValueForDb($element->dateCreated); + } + if (isset($element->dateUpdated)) { + $elementRecord->dateUpdated = Db::prepareValueForDb($element->dateUpdated); + } + } elseif ($element->resaving && !$forceTouch) { + // Prevent ActiveRecord::prepareForDb() from changing the dateUpdated + $elementRecord->markAttributeDirty('dateUpdated'); + } else { + // Force a new dateUpdated value + $elementRecord->dateUpdated = Db::prepareValueForDb(DateTimeHelper::now()); } - } elseif ($element->resaving && !$forceTouch) { - // Prevent ActiveRecord::prepareForDb() from changing the dateUpdated - $elementRecord->markAttributeDirty('dateUpdated'); - } else { - // Force a new dateUpdated value - $elementRecord->dateUpdated = Db::prepareValueForDb(DateTimeHelper::now()); - } - // Update our list of dirty attributes - if ($trackChanges) { - array_push($dirtyAttributes, ...array_keys($elementRecord->getDirtyAttributes([ - 'fieldLayoutId', - 'enabled', - 'archived', - ]))); - } + // Update our list of dirty attributes + if ($trackChanges) { + array_push($dirtyAttributes, ...array_keys($elementRecord->getDirtyAttributes([ + 'fieldLayoutId', + 'enabled', + 'archived', + ]))); + } - // Save the element record - $elementRecord->save(false); + // Save the element record + $elementRecord->save(false); - $dateCreated = DateTimeHelper::toDateTime($elementRecord->dateCreated); + $dateCreated = DateTimeHelper::toDateTime($elementRecord->dateCreated); - if ($dateCreated === false) { - $element->firstSave = $originalFirstSave; - $element->propagateAll = $originalPropagateAll; - throw new Exception('There was a problem calculating dateCreated.'); - } + if ($dateCreated === false) { + $element->firstSave = $originalFirstSave; + $element->propagateAll = $originalPropagateAll; + throw new Exception('There was a problem calculating dateCreated.'); + } - $dateUpdated = DateTimeHelper::toDateTime($elementRecord->dateUpdated); + $dateUpdated = DateTimeHelper::toDateTime($elementRecord->dateUpdated); - if ($dateUpdated === false) { - throw new Exception('There was a problem calculating dateUpdated.'); - } + if ($dateUpdated === false) { + throw new Exception('There was a problem calculating dateUpdated.'); + } - // Save the new dateCreated and dateUpdated dates on the model - $element->dateCreated = $dateCreated; - $element->dateUpdated = $dateUpdated; + // Save the new dateCreated and dateUpdated dates on the model + $element->dateCreated = $dateCreated; + $element->dateUpdated = $dateUpdated; - if ($isNewElement) { - // Save the element ID on the element model - $element->id = $elementRecord->id; + if ($isNewElement) { + // Save the element ID on the element model + $element->id = $elementRecord->id; - // If there's a temp ID, update the URI - if ($element->tempId && $element->uri) { - $element->uri = str_replace($element->tempId, (string)$element->id, $element->uri); - $element->tempId = null; + // If there's a temp ID, update the URI + if ($element->tempId && $element->uri) { + $element->uri = str_replace($element->tempId, (string)$element->id, $element->uri); + $element->tempId = null; + } } } - } - // Save the element’s site settings record - if (!$isNewElement) { - $siteSettingsRecord = Element_SiteSettingsRecord::findOne([ - 'elementId' => $element->id, - 'siteId' => $element->siteId, - ]); - } + // Save the element’s site settings record + if (!$isNewElement) { + $siteSettingsRecord = Element_SiteSettingsRecord::findOne([ + 'elementId' => $element->id, + 'siteId' => $element->siteId, + ]); + } - if ($element->isNewForSite = empty($siteSettingsRecord)) { - // First time we've saved the element for this site - $siteSettingsRecord = new Element_SiteSettingsRecord(); - $siteSettingsRecord->elementId = $element->id; - $siteSettingsRecord->siteId = $element->siteId; - } + if ($element->isNewForSite = empty($siteSettingsRecord)) { + // First time we've saved the element for this site + $siteSettingsRecord = new Element_SiteSettingsRecord(); + $siteSettingsRecord->elementId = $element->id; + $siteSettingsRecord->siteId = $element->siteId; + } - $title = $element::hasTitles() ? $element->title : null; - $siteSettingsRecord->title = $title !== null && $title !== '' ? $title : null; - $siteSettingsRecord->slug = $element->slug; - $siteSettingsRecord->uri = $element->uri; + $title = $element::hasTitles() ? $element->title : null; + $siteSettingsRecord->title = $title !== null && $title !== '' ? $title : null; + $siteSettingsRecord->slug = $element->slug; + $siteSettingsRecord->uri = $element->uri; - // Avoid `enabled` getting marked as dirty if it’s not really changing - $enabledForSite = $element->getEnabledForSite(); - if ($siteSettingsRecord->getIsNewRecord() || $siteSettingsRecord->enabled != $enabledForSite) { - $siteSettingsRecord->enabled = $enabledForSite; - } + // Avoid `enabled` getting marked as dirty if it’s not really changing + $enabledForSite = $element->getEnabledForSite(); + if ($siteSettingsRecord->getIsNewRecord() || $siteSettingsRecord->enabled != $enabledForSite) { + $siteSettingsRecord->enabled = $enabledForSite; + } - // Update our list of dirty attributes - if ($trackChanges && !$element->isNewForSite) { - array_push($dirtyAttributes, ...array_keys($siteSettingsRecord->getDirtyAttributes([ - 'slug', - 'uri', - ]))); - if ($siteSettingsRecord->isAttributeChanged('enabled')) { - $dirtyAttributes[] = 'enabledForSite'; + // Update our list of dirty attributes + if ($trackChanges && !$element->isNewForSite) { + array_push($dirtyAttributes, ...array_keys($siteSettingsRecord->getDirtyAttributes([ + 'slug', + 'uri', + ]))); + if ($siteSettingsRecord->isAttributeChanged('enabled')) { + $dirtyAttributes[] = 'enabledForSite'; + } } - } - // Set the field values - $content = []; - if ($fieldLayout) { - foreach ($fieldLayout->getCustomFields() as $field) { - if ($field::dbType() !== null) { - $serializedValue = $field->serializeValue($element->getFieldValue($field->handle), $element); - if ($serializedValue !== null) { - $content[$field->layoutElement->uid] = $serializedValue; + // Set the field values + $content = []; + if ($fieldLayout) { + foreach ($fieldLayout->getCustomFields() as $field) { + if ($field::dbType() !== null) { + $serializedValue = $field->serializeValue($element->getFieldValue($field->handle), $element); + if ($serializedValue !== null) { + $content[$field->layoutElement->uid] = $serializedValue; + } } } } - } - $siteSettingsRecord->content = $content ?: null; + $siteSettingsRecord->content = $content ?: null; - // Save the site settings record - if (!$siteSettingsRecord->save(false)) { - $element->firstSave = $originalFirstSave; - $element->propagateAll = $originalPropagateAll; - throw new Exception('Couldn’t save elements’ site settings record.'); - } + // Save the site settings record + if (!$siteSettingsRecord->save(false)) { + $element->firstSave = $originalFirstSave; + $element->propagateAll = $originalPropagateAll; + throw new Exception('Couldn’t save elements’ site settings record.'); + } - $element->siteSettingsId = $siteSettingsRecord->id; + $element->siteSettingsId = $siteSettingsRecord->id; - // Set all of the dirty attributes on the element, in case an event listener wants to know - if ($trackChanges) { - array_push($dirtyAttributes, ...$element->getDirtyAttributes()); - $element->setDirtyAttributes($dirtyAttributes, false); - } + // Set all of the dirty attributes on the element, in case an event listener wants to know + if ($trackChanges) { + array_push($dirtyAttributes, ...$element->getDirtyAttributes()); + $element->setDirtyAttributes($dirtyAttributes, false); + } - // It is now officially saved - $element->afterSave($isNewElement); + // It is now officially saved + $element->afterSave($isNewElement); - // Update the element across the other sites? - if ($propagate) { - $otherSiteIds = ArrayHelper::withoutValue(array_keys($supportedSites), $element->siteId); + // Update the element across the other sites? + if ($propagate) { + $otherSiteIds = ArrayHelper::withoutValue(array_keys($supportedSites), $element->siteId); - if (!empty($otherSiteIds)) { - if (!$isNewElement) { - $siteElements = $this->_localizedElementQuery($element) - ->siteId($otherSiteIds) - ->status(null) - ->indexBy('siteId') - ->all(); - } else { - $siteElements = []; - } + if (!empty($otherSiteIds)) { + if (!$isNewElement) { + $siteElements = $this->_localizedElementQuery($element) + ->siteId($otherSiteIds) + ->status(null) + ->indexBy('siteId') + ->all(); + } else { + $siteElements = []; + } - foreach (array_keys($supportedSites) as $siteId) { - // Skip the initial site - if ($siteId != $element->siteId) { - $siteElement = $siteElements[$siteId] ?? false; - if (!$this->_propagateElement( - $element, - $supportedSites, - $siteId, - $siteElement, - crossSiteValidate: $runValidation && $crossSiteValidate, - )) { - throw new InvalidConfigException(); + foreach (array_keys($supportedSites) as $siteId) { + // Skip the initial site + if ($siteId != $element->siteId) { + $siteElement = $siteElements[$siteId] ?? false; + if (!$this->_propagateElement( + $element, + $supportedSites, + $siteId, + $siteElement, + crossSiteValidate: $runValidation && $crossSiteValidate, + )) { + throw new InvalidConfigException(); + } } } } } - } - // It's now fully saved and propagated - if ( - !$element->propagating && - !$element->duplicateOf && - !$element->mergingCanonicalChanges - ) { - $element->afterPropagate($isNewElement); - } + // It's now fully saved and propagated + if ( + !$element->propagating && + !$element->duplicateOf && + !$element->mergingCanonicalChanges + ) { + $element->afterPropagate($isNewElement); - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - $element->firstSave = $originalFirstSave; - $element->propagateAll = $originalPropagateAll; - $element->dateUpdated = $originalDateUpdated; - if ($e instanceof InvalidConfigException) { - return false; - } - throw $e; - } finally { - $this->_updateSearchIndex = $oldUpdateSearchIndex; - $element->newSiteIds = $newSiteIds; - } - - if (!$element->propagating) { - // Delete the rows that don't need to be there anymore - if (!$isNewElement) { - Db::deleteIfExists( - Table::ELEMENTS_SITES, - [ - 'and', - ['elementId' => $element->id], - ['not', ['siteId' => array_keys($supportedSites)]], - ] - ); + // Track this element in bulk operations + $this->trackElementInBulkOps($element); + } + + $transaction->commit(); + } catch (Throwable $e) { + $transaction->rollBack(); + $element->firstSave = $originalFirstSave; + $element->propagateAll = $originalPropagateAll; + $element->dateUpdated = $originalDateUpdated; + if ($e instanceof InvalidConfigException) { + return false; + } + throw $e; + } finally { + $this->_updateSearchIndex = $oldUpdateSearchIndex; + $element->newSiteIds = $newSiteIds; } - // Invalidate any caches involving this element - $this->invalidateCachesForElement($element); - } + if (!$element->propagating) { + // Delete the rows that don't need to be there anymore + if (!$isNewElement) { + Db::deleteIfExists( + Table::ELEMENTS_SITES, + [ + 'and', + ['elementId' => $element->id], + ['not', ['siteId' => array_keys($supportedSites)]], + ] + ); + } - // Update search index - if ($updateSearchIndex && !$element->getIsRevision() && !ElementHelper::isRevision($element)) { - $searchableDirtyFields = array_filter( - $dirtyFields, - fn(string $handle) => $fieldLayout?->getFieldByHandle($handle)?->searchable, - ); + // Invalidate any caches involving this element + $this->invalidateCachesForElement($element); + } - if ( - !$trackChanges || - !empty($searchableDirtyFields) || - !empty(array_intersect($dirtyAttributes, ElementHelper::searchableAttributes($element))) - ) { - $event = new ElementEvent([ - 'element' => $element, - ]); - $this->trigger(self::EVENT_BEFORE_UPDATE_SEARCH_INDEX, $event); - if ($event->isValid) { - if (Craft::$app->getRequest()->getIsConsoleRequest()) { - Craft::$app->getSearch()->indexElementAttributes($element, $searchableDirtyFields); - } else { - Queue::push(new UpdateSearchIndex([ - 'elementType' => get_class($element), - 'elementId' => $element->id, - 'siteId' => $propagate ? '*' : $element->siteId, - 'fieldHandles' => $searchableDirtyFields, - ]), 2048); + // Update search index + if ($updateSearchIndex && !$element->getIsRevision() && !ElementHelper::isRevision($element)) { + $searchableDirtyFields = array_filter( + $dirtyFields, + fn(string $handle) => $fieldLayout?->getFieldByHandle($handle)?->searchable, + ); + + if ( + !$trackChanges || + !empty($searchableDirtyFields) || + !empty(array_intersect($dirtyAttributes, ElementHelper::searchableAttributes($element))) + ) { + $event = new ElementEvent([ + 'element' => $element, + ]); + $this->trigger(self::EVENT_BEFORE_UPDATE_SEARCH_INDEX, $event); + if ($event->isValid) { + if (Craft::$app->getRequest()->getIsConsoleRequest()) { + Craft::$app->getSearch()->indexElementAttributes($element, $searchableDirtyFields); + } else { + Queue::push(new UpdateSearchIndex([ + 'elementType' => get_class($element), + 'elementId' => $element->id, + 'siteId' => $propagate ? '*' : $element->siteId, + 'fieldHandles' => $searchableDirtyFields, + ]), 2048); + } } } } - } - - // Update the changed attributes & fields - if ($trackChanges) { - $userId = Craft::$app->getUser()->getId(); - $timestamp = Db::prepareDateForDb(DateTimeHelper::now()); - foreach ($dirtyAttributes as $attributeName) { - Db::upsert(Table::CHANGEDATTRIBUTES, [ - 'elementId' => $element->id, - 'siteId' => $element->siteId, - 'attribute' => $attributeName, - 'dateUpdated' => $timestamp, - 'propagated' => $element->propagating, - 'userId' => $userId, - ]); - } + // Update the changed attributes & fields + if ($trackChanges) { + $userId = Craft::$app->getUser()->getId(); + $timestamp = Db::prepareDateForDb(DateTimeHelper::now()); + + foreach ($dirtyAttributes as $attributeName) { + Db::upsert(Table::CHANGEDATTRIBUTES, [ + 'elementId' => $element->id, + 'siteId' => $element->siteId, + 'attribute' => $attributeName, + 'dateUpdated' => $timestamp, + 'propagated' => $element->propagating, + 'userId' => $userId, + ]); + } - if ($fieldLayout) { - foreach ($dirtyFields as $fieldHandle) { - if (($field = $fieldLayout->getFieldByHandle($fieldHandle)) !== null) { - Db::upsert(Table::CHANGEDFIELDS, [ - 'elementId' => $element->id, - 'siteId' => $element->siteId, - 'fieldId' => $field->id, - 'layoutElementUid' => $field->layoutElement->uid, - 'dateUpdated' => $timestamp, - 'propagated' => $element->propagating, - 'userId' => $userId, - ]); + if ($fieldLayout) { + foreach ($dirtyFields as $fieldHandle) { + if (($field = $fieldLayout->getFieldByHandle($fieldHandle)) !== null) { + Db::upsert(Table::CHANGEDFIELDS, [ + 'elementId' => $element->id, + 'siteId' => $element->siteId, + 'fieldId' => $field->id, + 'layoutElementUid' => $field->layoutElement->uid, + 'dateUpdated' => $timestamp, + 'propagated' => $element->propagating, + 'userId' => $userId, + ]); + } } } } - } + }); // Fire an 'afterSaveElement' event if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_ELEMENT)) { diff --git a/src/services/Gc.php b/src/services/Gc.php index 770154138ce..bbae2d8f4c8 100644 --- a/src/services/Gc.php +++ b/src/services/Gc.php @@ -103,6 +103,7 @@ public function run(bool $force = false): void $this->_deleteStaleSessions(); $this->_deleteStaleAnnouncements(); $this->_deleteStaleElementActivity(); + $this->_deleteStaleBulkElementOps(); // elements should always go first $this->hardDeleteElements(); @@ -418,6 +419,16 @@ private function _deleteStaleElementActivity(): void $this->_stdout("done\n", Console::FG_GREEN); } + /** + * Deletes any stale bulk element operation records. + */ + private function _deleteStaleBulkElementOps(): void + { + $this->_stdout(' > deleting stale bulk element operation records ... '); + Db::delete(Table::ELEMENTS_BULKOPS, ['<', 'timestamp', Db::prepareDateForDb(new DateTime('2 weeks ago'))]); + $this->_stdout("done\n", Console::FG_GREEN); + } + /** * Deletes entries for sites that aren’t enabled by their section. *