From 4dba6ec481f74dff45f91c5d2997d8f6f0964502 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 15 Feb 2022 10:35:16 -0800 Subject: [PATCH 1/4] Multi-Matrix block ownership --- src/db/Table.php | 2 + src/elements/MatrixBlock.php | 22 ++- src/elements/db/ElementQuery.php | 10 +- .../db/ElementRelationParamParser.php | 4 +- src/elements/db/MatrixBlockQuery.php | 166 +++++++++++++++--- src/fields/Matrix.php | 55 ++++-- src/gql/arguments/elements/MatrixBlock.php | 6 +- src/gql/interfaces/elements/MatrixBlock.php | 6 +- src/migrations/Install.php | 16 +- ...20213_015220_matrixblocks_owners_table.php | 58 ++++++ src/records/MatrixBlock.php | 9 +- src/services/Drafts.php | 85 +++++---- src/services/Matrix.php | 72 ++++++-- src/services/Sites.php | 2 +- 14 files changed, 398 insertions(+), 115 deletions(-) create mode 100644 src/migrations/m220213_015220_matrixblocks_owners_table.php diff --git a/src/db/Table.php b/src/db/Table.php index 50f313e626d..4fc0e7d45f6 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -60,6 +60,8 @@ abstract class Table public const GQLTOKENS = '{{%gqltokens}}'; public const INFO = '{{%info}}'; public const MATRIXBLOCKS = '{{%matrixblocks}}'; + /** @since 4.0.0 */ + public const MATRIXBLOCKS_OWNERS = '{{%matrixblocks_owners}}'; public const MATRIXBLOCKTYPES = '{{%matrixblocktypes}}'; public const MIGRATIONS = '{{%migrations}}'; /** @since 3.4.0 */ diff --git a/src/elements/MatrixBlock.php b/src/elements/MatrixBlock.php index 398a853ca34..eedf66f04a9 100644 --- a/src/elements/MatrixBlock.php +++ b/src/elements/MatrixBlock.php @@ -169,6 +169,12 @@ public static function gqlTypeNameByContext($context): string */ public ?int $fieldId = null; + /** + * @var int|null Primary owner ID + * @since 4.0.0 + */ + public ?int $primaryOwnerId = null; + /** * @var int|null Owner ID */ @@ -239,7 +245,7 @@ public function extraFields(): array protected function defineRules(): array { $rules = parent::defineRules(); - $rules[] = [['fieldId', 'ownerId', 'typeId', 'sortOrder'], 'number', 'integerOnly' => true]; + $rules[] = [['fieldId', 'primaryOwnerId', 'typeId', 'sortOrder'], 'number', 'integerOnly' => true]; return $rules; } @@ -269,9 +275,9 @@ public function getSupportedSites(): array public function getCacheTags(): array { return [ - "field-owner:$this->fieldId-$this->ownerId", + "field-owner:$this->fieldId-$this->primaryOwnerId", "field:$this->fieldId", - "owner:$this->ownerId", + "owner:$this->primaryOwnerId", ]; } @@ -328,6 +334,7 @@ public function getOwner(): ElementInterface public function setOwner(?ElementInterface $owner = null): void { $this->_owner = $owner; + $this->ownerId = $owner->id; } /** @@ -433,10 +440,15 @@ public function afterSave(bool $isNew): void } $record->fieldId = (int)$this->fieldId; - $record->ownerId = (int)$this->ownerId; + $record->primaryOwnerId = (int)$this->primaryOwnerId; $record->typeId = (int)$this->typeId; - $record->sortOrder = (int)$this->sortOrder ?: null; $record->save(false); + + Db::upsert(Table::MATRIXBLOCKS_OWNERS, [ + 'blockId' => $this->id, + 'ownerId' => $this->ownerId, + 'sortOrder' => $this->sortOrder ?? 0, + ]); } parent::afterSave($isNew); diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index 894cec27897..d92baea0fe4 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -1318,16 +1318,16 @@ public function prepare($builder): Query $this->query->withQueries = $this->withQueries; $this->subQuery = new Query(); - // Give other classes a chance to make changes up front - if (!$this->beforePrepare()) { - throw new QueryAbortedException(); - } - $this->query ->from(['subquery' => $this->subQuery]) ->innerJoin(['elements' => Table::ELEMENTS], '[[elements.id]] = [[subquery.elementsId]]') ->innerJoin(['elements_sites' => Table::ELEMENTS_SITES], '[[elements_sites.id]] = [[subquery.elementsSitesId]]'); + // Give other classes a chance to make changes up front + if (!$this->beforePrepare()) { + throw new QueryAbortedException(); + } + $this->subQuery ->addSelect([ 'elementsId' => 'elements.id', diff --git a/src/elements/db/ElementRelationParamParser.php b/src/elements/db/ElementRelationParamParser.php index 086fe38df14..f882897c810 100644 --- a/src/elements/db/ElementRelationParamParser.php +++ b/src/elements/db/ElementRelationParamParser.php @@ -370,7 +370,7 @@ private function _subparse($relCriteria) ->innerJoin([$targetMatrixBlocksAlias => Table::MATRIXBLOCKS], "[[$targetMatrixBlocksAlias.id]] = [[$sourcesAlias.sourceId]]") ->innerJoin([$targetMatrixElementsAlias => Table::ELEMENTS], "[[$targetMatrixElementsAlias.id]] = [[$targetMatrixBlocksAlias.id]]") ->where([ - "$targetMatrixBlocksAlias.ownerId" => $relElementIds, + "$targetMatrixBlocksAlias.primaryOwnerId" => $relElementIds, "$targetMatrixBlocksAlias.fieldId" => $fieldModel->id, "$targetMatrixElementsAlias.enabled" => true, "$targetMatrixElementsAlias.dateDeleted" => null, @@ -394,7 +394,7 @@ private function _subparse($relCriteria) $matrixBlockTargetsAlias = 'matrixblock_targets' . self::$_relateSourceMatrixBlocksCount; $subQuery = (new Query()) - ->select(["$sourceMatrixBlocksAlias.ownerId"]) + ->select(["$sourceMatrixBlocksAlias.primaryOwnerId"]) ->from([$sourceMatrixBlocksAlias => Table::MATRIXBLOCKS]) ->innerJoin([$sourceMatrixElementsAlias => Table::ELEMENTS], "[[$sourceMatrixElementsAlias.id]] = [[$sourceMatrixBlocksAlias.id]]") ->innerJoin([$matrixBlockTargetsAlias => Table::RELATIONS], "[[$matrixBlockTargetsAlias.sourceId]] = [[$sourceMatrixBlocksAlias.id]]") diff --git a/src/elements/db/MatrixBlockQuery.php b/src/elements/db/MatrixBlockQuery.php index 2e1394b167f..20e8f2531ce 100644 --- a/src/elements/db/MatrixBlockQuery.php +++ b/src/elements/db/MatrixBlockQuery.php @@ -18,6 +18,7 @@ use craft\helpers\ArrayHelper; use craft\helpers\Db; use craft\models\MatrixBlockType; +use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; use yii\db\Connection; @@ -25,6 +26,7 @@ * MatrixBlockQuery represents a SELECT SQL statement for global sets in a way that is independent of DBMS. * * @property-write ElementInterface $owner The owner element the Matrix blocks must belong to + * @property-write ElementInterface $primaryOwner The primary owner element the Matrix blocks must belong to * @property-write string|string[]|MatrixBlockType|null $type The block type(s) that resulting Matrix blocks must have * @property-write string|string[]|MatrixField|null $field The field the Matrix blocks must belong to * @method MatrixBlock[]|array all($db = null) @@ -46,7 +48,7 @@ class MatrixBlockQuery extends ElementQuery /** * @inheritdoc */ - protected array $defaultOrderBy = ['matrixblocks.sortOrder' => SORT_ASC]; + protected array $defaultOrderBy = ['matrixblocks_owners.sortOrder' => SORT_ASC]; // General parameters // ------------------------------------------------------------------------- @@ -57,6 +59,14 @@ class MatrixBlockQuery extends ElementQuery */ public $fieldId; + /** + * @var int|int[]|null The primary owner element ID(s) that the resulting Matrix blocks must belong to. + * @used-by primaryOwner() + * @used-by primaryOwnerId() + * @since 4.0.0 + */ + public $primaryOwnerId; + /** * @var int|int[]|null The owner element ID(s) that the resulting Matrix blocks must belong to. * @used-by owner() @@ -110,6 +120,9 @@ public function __set($name, $value) case 'owner': $this->owner($value); break; + case 'primaryOwner': + $this->primaryOwner($value); + break; case 'type': $this->type($value); break; @@ -224,6 +237,76 @@ public function fieldId($value): self return $this; } + /** + * Narrows the query results based on the primary owner element of the Matrix blocks, per the owners’ IDs. + * + * Possible values include: + * + * | Value | Fetches Matrix blocks… + * | - | - + * | `1` | created for an element with an ID of 1. + * | `'not 1'` | not created for an element with an ID of 1. + * | `[1, 2]` | created for an element with an ID of 1 or 2. + * | `['not', 1, 2]` | not created for an element with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch Matrix blocks created for an element with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .primaryOwnerId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch Matrix blocks created for an element with an ID of 1 + * ${elements-var} = {php-method} + * ->primaryOwnerId(1) + * ->all(); + * ``` + * + * @param int|int[]|null $value The property value + * @return self self reference + * @uses $primaryOwnerId + * @since 4.0.0 + */ + public function primaryOwnerId($value): self + { + $this->primaryOwnerId = $value; + return $this; + } + + /** + * Sets the [[primaryOwnerId()]] and [[siteId()]] parameters based on a given element. + * + * --- + * + * ```twig + * {# Fetch Matrix blocks created for this entry #} + * {% set {elements-var} = {twig-method} + * .primaryOwner(myEntry) + * .all() %} + * ``` + * + * ```php + * // Fetch Matrix blocks created for this entry + * ${elements-var} = {php-method} + * ->primaryOwner($myEntry) + * ->all(); + * ``` + * + * @param ElementInterface $primaryOwner The primary owner element + * @return self self reference + * @uses $primaryOwnerId + * @since 4.0.0 + */ + public function primaryOwner(ElementInterface $primaryOwner): self + { + $this->primaryOwnerId = [$primaryOwner->id]; + $this->siteId = $primaryOwner->siteId; + return $this; + } + /** * Narrows the query results based on the owner element of the Matrix blocks, per the owners’ IDs. * @@ -424,14 +507,36 @@ public function typeId($value): self /** * @inheritdoc + * @throws InvalidConfigException */ protected function beforePrepare(): bool { $this->_normalizeFieldId(); - $this->_normalizeOwnerId(); + + try { + $this->primaryOwnerId = $this->_normalizeOwnerId($this->primaryOwnerId); + } catch (InvalidArgumentException) { + throw new InvalidConfigException('Invalid ownerId param value'); + } + + try { + $this->ownerId = $this->_normalizeOwnerId($this->ownerId); + } catch (InvalidArgumentException) { + throw new InvalidConfigException('Invalid ownerId param value'); + } $this->joinElementTable('matrixblocks'); + // Join in the matrixblocks_owners table + $ownersCondition = [ + 'and', + '[[matrixblocks_owners.blockId]] = [[elements.id]]', + $this->ownerId ? ['matrixblocks_owners.ownerId' => $this->ownerId] : '[[matrixblocks_owners.ownerId]] = [[matrixblocks.primaryOwnerId]]', + ]; + + $this->query->innerJoin(['matrixblocks_owners' => Table::MATRIXBLOCKS_OWNERS], $ownersCondition); + $this->subQuery->innerJoin(['matrixblocks_owners' => Table::MATRIXBLOCKS_OWNERS], $ownersCondition); + // Figure out which content table to use $this->contentTable = null; if ($this->fieldId && count($this->fieldId) === 1) { @@ -442,19 +547,20 @@ protected function beforePrepare(): bool } } - $this->query->select([ + $this->query->addSelect([ 'matrixblocks.fieldId', - 'matrixblocks.ownerId', + 'matrixblocks.primaryOwnerId', 'matrixblocks.typeId', - 'matrixblocks.sortOrder', + 'matrixblocks_owners.ownerId', + 'matrixblocks_owners.sortOrder', ]); if ($this->fieldId) { $this->subQuery->andWhere(['matrixblocks.fieldId' => $this->fieldId]); } - if ($this->ownerId) { - $this->subQuery->andWhere(['matrixblocks.ownerId' => $this->ownerId]); + if ($this->primaryOwnerId) { + $this->subQuery->andWhere(['matrixblocks.primaryOwnerId' => $this->primaryOwnerId]); } if (isset($this->typeId)) { @@ -467,12 +573,15 @@ protected function beforePrepare(): bool } // Ignore revision/draft blocks by default - $allowOwnerDrafts = $this->allowOwnerDrafts ?? ($this->id || $this->ownerId); - $allowOwnerRevisions = $this->allowOwnerRevisions ?? ($this->id || $this->ownerId); + $allowOwnerDrafts = $this->allowOwnerDrafts ?? ($this->id || $this->primaryOwnerId || $this->ownerId); + $allowOwnerRevisions = $this->allowOwnerRevisions ?? ($this->id || $this->primaryOwnerId || $this->ownerId); if (!$allowOwnerDrafts || !$allowOwnerRevisions) { // todo: we will need to expand on this when Matrix blocks can be nested. - $this->subQuery->innerJoin(['owners' => Table::ELEMENTS], '[[owners.id]] = [[matrixblocks.ownerId]]'); + $this->subQuery->innerJoin( + ['owners' => Table::ELEMENTS], + $this->ownerId ? '[[owners.id]] = [[matrixblocks_owners.ownerId]]' : '[[owners.id]] = [[matrixblocks.primaryOwnerId]]' + ); if (!$allowOwnerDrafts) { $this->subQuery->andWhere(['owners.draftId' => null]); @@ -521,19 +630,24 @@ private function _normalizeFieldId(): void } /** - * Normalizes the ownerId param to an array of IDs or null + * Normalizes the primaryOwnerId param to an array of IDs or null * - * @throws InvalidConfigException + * @param mixed $value + * @return int[]|null + * @throws InvalidArgumentException */ - private function _normalizeOwnerId(): void + private function _normalizeOwnerId(mixed $value): ?array { - if (empty($this->ownerId)) { - $this->ownerId = null; - } else if (is_numeric($this->ownerId)) { - $this->ownerId = [$this->ownerId]; - } else if (!is_array($this->ownerId) || !ArrayHelper::isNumeric($this->ownerId)) { - throw new InvalidConfigException('Invalid ownerId param value'); + if (empty($value)) { + return null; + } + if (is_numeric($value)) { + return [$value]; + } + if (!is_array($value) || !ArrayHelper::isNumeric($value)) { + throw new InvalidArgumentException(); } + return $value; } /** @@ -563,11 +677,11 @@ protected function customFields(): array protected function cacheTags(): array { $tags = []; - // If both the field and owner are set, then only tag the combos - if ($this->fieldId && $this->ownerId) { + // If both the field and primary owner are set, then only tag the combos + if ($this->fieldId && $this->primaryOwnerId) { foreach ($this->fieldId as $fieldId) { - foreach ($this->ownerId as $ownerId) { - $tags[] = "field-owner:$fieldId-$ownerId"; + foreach ($this->primaryOwnerId as $primaryOwnerId) { + $tags[] = "field-owner:$fieldId-$primaryOwnerId"; } } } else { @@ -576,9 +690,9 @@ protected function cacheTags(): array $tags[] = "field:$fieldId"; } } - if ($this->ownerId) { - foreach ($this->ownerId as $ownerId) { - $tags[] = "owner:$ownerId"; + if ($this->primaryOwnerId) { + foreach ($this->primaryOwnerId as $primaryOwnerId) { + $tags[] = "owner:$primaryOwnerId"; } } } diff --git a/src/fields/Matrix.php b/src/fields/Matrix.php index f1f24940340..f0ace2aa3dc 100644 --- a/src/fields/Matrix.php +++ b/src/fields/Matrix.php @@ -578,7 +578,11 @@ public function modifyElementsQuery(ElementQueryInterface $query, $value): void $existsQuery = (new Query()) ->from(["matrixblocks_$ns" => DbTable::MATRIXBLOCKS]) ->innerJoin(["elements_$ns" => DbTable::ELEMENTS], "[[elements_$ns.id]] = [[matrixblocks_$ns.id]]") - ->where("[[matrixblocks_$ns.ownerId]] = [[elements.id]]") + ->innerJoin(["matrixblocks_owners_$ns" => DbTable::MATRIXBLOCKS_OWNERS], [ + 'and', + "[[matrixblocks_owners_$ns.blockId]] = [[elements_$ns.id]]", + "[[matrixblocks_owners_$ns.ownerId]] = [[elements.id]]" + ]) ->andWhere([ "matrixblocks_$ns.fieldId" => $this->id, "elements_$ns.enabled" => true, @@ -852,13 +856,18 @@ public function getEagerLoadingMap(array $sourceElements) // Return any relation data on these elements, defined with this field $map = (new Query()) - ->select(['ownerId as source', 'id as target']) - ->from([DbTable::MATRIXBLOCKS]) - ->where([ - 'fieldId' => $this->id, - 'ownerId' => $sourceElementIds, + ->select([ + 'source' => 'matrixblocks_owners.ownerId', + 'target' => 'matrixblocks.id', + ]) + ->from(['matrixblocks' => DbTable::MATRIXBLOCKS]) + ->innerJoin(['matrixblocks_owners' => DbTable::MATRIXBLOCKS_OWNERS], [ + 'and', + '[[matrixblocks_owners.blockId]] = [[matrixblocks.id]]', + ['matrixblocks_owners.ownerId' => $sourceElementIds], ]) - ->orderBy(['sortOrder' => SORT_ASC]) + ->where(['matrixblocks.fieldId' => $this->id]) + ->orderBy(['matrixblocks_owners.sortOrder' => SORT_ASC]) ->all(); return [ @@ -1011,7 +1020,12 @@ public function afterElementPropagate(ElementInterface $element, bool $isNew): v $resetValue = false; if ($element->duplicateOf !== null) { - $matrixService->duplicateBlocks($this, $element->duplicateOf, $element, true); + // If this is a draft, just duplicate the relations + if ($element->getIsDraft()) { + $matrixService->duplicateOwnership($this, $element->duplicateOf, $element); + } else { + $matrixService->duplicateBlocks($this, $element->duplicateOf, $element, true); + } $resetValue = true; } else if ($element->isFieldDirty($this->handle) || !empty($element->newSiteIds)) { $matrixService->saveField($this, $element); @@ -1040,13 +1054,13 @@ public function beforeElementDelete(ElementInterface $element): bool return false; } - // Delete any Matrix blocks that belong to this element(s) + // Delete any Matrix blocks that primarily belong to this element foreach (Craft::$app->getSites()->getAllSiteIds() as $siteId) { $elementsService = Craft::$app->getElements(); $matrixBlocks = MatrixBlock::find() ->status(null) ->siteId($siteId) - ->ownerId($element->id) + ->primaryOwnerId($element->id) ->all(); foreach ($matrixBlocks as $matrixBlock) { @@ -1069,7 +1083,7 @@ public function afterElementRestore(ElementInterface $element): void $blocks = MatrixBlock::find() ->status(null) ->siteId($siteInfo['siteId']) - ->ownerId($element->id) + ->primaryOwnerId($element->id) ->trashed() ->andWhere(['matrixblocks.deletedWithOwner' => true]) ->all(); @@ -1250,7 +1264,22 @@ private function _createBlocksFromSerializedData(array $value, ElementInterface // Existing block? if (isset($oldBlocksById[$blockId])) { $block = $oldBlocksById[$blockId]; - $block->dirty = !empty($blockData); + $dirty = !empty($blockData); + + // Is this a derivative element, and does the block primarily belong to the canonical? + if ($dirty && $element->getIsDerivative() && $block->primaryOwnerId === $element->getCanonicalId()) { + // Duplicate it as a draft. (We'll drop its draft status from `Matrix::saveField()`.) + $block = Craft::$app->getDrafts()->createDraft($block, Craft::$app->getUser()->getId(), null, null, [ + 'canonicalId' => $block->id, + 'primaryOwnerId' => $element->id, + 'owner' => $element, + 'siteId' => $element->siteId, + 'propagating' => false, + 'markAsSaved' => false, + ]); + } + + $block->dirty = $dirty; } else { // Make sure it's a valid block type if (!isset($blockData['type']) || !isset($blockTypes[$blockData['type']])) { @@ -1259,7 +1288,7 @@ private function _createBlocksFromSerializedData(array $value, ElementInterface $block = new MatrixBlock(); $block->fieldId = $this->id; $block->typeId = $blockTypes[$blockData['type']]->id; - $block->ownerId = $element->id; + $block->primaryOwnerId = $element->id; $block->siteId = $element->siteId; // Preserve the collapsed state, which the browser can't remember on its own for new blocks diff --git a/src/gql/arguments/elements/MatrixBlock.php b/src/gql/arguments/elements/MatrixBlock.php index c35b8f45747..676ff3d92d1 100644 --- a/src/gql/arguments/elements/MatrixBlock.php +++ b/src/gql/arguments/elements/MatrixBlock.php @@ -30,10 +30,10 @@ public static function getArguments(): array 'type' => Type::listOf(QueryArgument::getType()), 'description' => 'Narrows the query results based on the field the Matrix blocks belong to, per the fields’ IDs.', ], - 'ownerId' => [ - 'name' => 'ownerId', + 'primaryOwnerId' => [ + 'name' => 'primaryOwnerId', 'type' => Type::listOf(QueryArgument::getType()), - 'description' => ' Narrows the query results based on the owner element of the Matrix blocks, per the owners’ IDs.', + 'description' => ' Narrows the query results based on the primary owner element of the Matrix blocks, per the owners’ IDs.', ], 'typeId' => Type::listOf(QueryArgument::getType()), 'type' => [ diff --git a/src/gql/interfaces/elements/MatrixBlock.php b/src/gql/interfaces/elements/MatrixBlock.php index 14a6b3b40d3..f938b160ae1 100644 --- a/src/gql/interfaces/elements/MatrixBlock.php +++ b/src/gql/interfaces/elements/MatrixBlock.php @@ -71,10 +71,10 @@ public static function getFieldDefinitions(): array 'type' => Type::nonNull(Type::int()), 'description' => 'The ID of the field that owns the matrix block.', ], - 'ownerId' => [ - 'name' => 'ownerId', + 'primaryOwnerId' => [ + 'name' => 'primaryOwnerId', 'type' => Type::nonNull(Type::int()), - 'description' => 'The ID of the element that owns the matrix block.', + 'description' => 'The ID of the primary owner of the Matrix block.', ], 'typeId' => [ 'name' => 'typeId', diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 7d8889947f9..3d0a51734e6 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -451,15 +451,20 @@ public function createTables(): void ]); $this->createTable(Table::MATRIXBLOCKS, [ 'id' => $this->integer()->notNull(), - 'ownerId' => $this->integer()->notNull(), + 'primaryOwnerId' => $this->integer()->notNull(), 'fieldId' => $this->integer()->notNull(), 'typeId' => $this->integer()->notNull(), - 'sortOrder' => $this->smallInteger()->unsigned(), 'deletedWithOwner' => $this->boolean()->null(), 'dateCreated' => $this->dateTime()->notNull(), 'dateUpdated' => $this->dateTime()->notNull(), 'PRIMARY KEY([[id]])', ]); + $this->createTable(Table::MATRIXBLOCKS_OWNERS, [ + 'blockId' => $this->integer()->notNull(), + 'ownerId' => $this->integer()->notNull(), + 'sortOrder' => $this->smallInteger()->unsigned()->notNull(), + 'PRIMARY KEY([[blockId]], [[elementId]])', + ]); $this->createTable(Table::MATRIXBLOCKTYPES, [ 'id' => $this->primaryKey(), 'fieldId' => $this->integer()->notNull(), @@ -834,10 +839,9 @@ public function createIndexes(): void $this->createIndex(null, Table::IMAGETRANSFORMINDEX, ['assetId', 'transformString'], false); $this->createIndex(null, Table::IMAGETRANSFORMS, ['name']); $this->createIndex(null, Table::IMAGETRANSFORMS, ['handle']); - $this->createIndex(null, Table::MATRIXBLOCKS, ['ownerId'], false); + $this->createIndex(null, Table::MATRIXBLOCKS, ['primaryOwnerId'], false); $this->createIndex(null, Table::MATRIXBLOCKS, ['fieldId'], false); $this->createIndex(null, Table::MATRIXBLOCKS, ['typeId'], false); - $this->createIndex(null, Table::MATRIXBLOCKS, ['sortOrder'], false); $this->createIndex(null, Table::MATRIXBLOCKTYPES, ['name', 'fieldId']); $this->createIndex(null, Table::MATRIXBLOCKTYPES, ['handle', 'fieldId']); $this->createIndex(null, Table::MATRIXBLOCKTYPES, ['fieldId'], false); @@ -1004,8 +1008,10 @@ public function addForeignKeys(): void $this->addForeignKey(null, Table::GQLTOKENS, 'schemaId', Table::GQLSCHEMAS, 'id', 'SET NULL', null); $this->addForeignKey(null, Table::MATRIXBLOCKS, ['fieldId'], Table::FIELDS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::MATRIXBLOCKS, ['id'], Table::ELEMENTS, ['id'], 'CASCADE', null); - $this->addForeignKey(null, Table::MATRIXBLOCKS, ['ownerId'], Table::ELEMENTS, ['id'], 'CASCADE', null); + $this->addForeignKey(null, Table::MATRIXBLOCKS, ['primaryOwnerId'], Table::ELEMENTS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::MATRIXBLOCKS, ['typeId'], Table::MATRIXBLOCKTYPES, ['id'], 'CASCADE', null); + $this->addForeignKey(null, Table::MATRIXBLOCKS_OWNERS, ['blockId'], Table::MATRIXBLOCKS, ['id'], 'CASCADE', null); + $this->addForeignKey(null, Table::MATRIXBLOCKS_OWNERS, ['ownerId'], Table::ELEMENTS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::MATRIXBLOCKTYPES, ['fieldId'], Table::FIELDS, ['id'], 'CASCADE', null); $this->addForeignKey(null, Table::MATRIXBLOCKTYPES, ['fieldLayoutId'], Table::FIELDLAYOUTS, ['id'], 'SET NULL', null); $this->addForeignKey(null, Table::RELATIONS, ['fieldId'], Table::FIELDS, ['id'], 'CASCADE', null); diff --git a/src/migrations/m220213_015220_matrixblocks_owners_table.php b/src/migrations/m220213_015220_matrixblocks_owners_table.php new file mode 100644 index 00000000000..3984cda0241 --- /dev/null +++ b/src/migrations/m220213_015220_matrixblocks_owners_table.php @@ -0,0 +1,58 @@ +dropTableIfExists(Table::MATRIXBLOCKS_OWNERS); + + $this->createTable(Table::MATRIXBLOCKS_OWNERS, [ + 'blockId' => $this->integer()->notNull(), + 'ownerId' => $this->integer()->notNull(), + 'sortOrder' => $this->smallInteger()->unsigned()->notNull(), + 'PRIMARY KEY([[blockId]], [[ownerId]])', + ]); + + $this->addForeignKey(null, Table::MATRIXBLOCKS_OWNERS, ['blockId'], Table::MATRIXBLOCKS, ['id'], 'CASCADE', null); + $this->addForeignKey(null, Table::MATRIXBLOCKS_OWNERS, ['ownerId'], Table::ELEMENTS, ['id'], 'CASCADE', null); + + $blocksTable = Table::MATRIXBLOCKS; + $ownersTable = Table::MATRIXBLOCKS_OWNERS; + + $this->execute(<<dropIndexIfExists(Table::MATRIXBLOCKS, ['sortOrder'], false); + $this->dropColumn(Table::MATRIXBLOCKS, 'sortOrder'); + + // ownerId => primaryOwnerId + $this->renameColumn(Table::MATRIXBLOCKS, 'ownerId', 'primaryOwnerId'); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m220213_015220_matrixblocks_owners_table cannot be reverted.\n"; + return false; + } +} diff --git a/src/records/MatrixBlock.php b/src/records/MatrixBlock.php index 3bc212391a3..129b47c8338 100644 --- a/src/records/MatrixBlock.php +++ b/src/records/MatrixBlock.php @@ -15,10 +15,9 @@ * Class MatrixBlock record. * * @property int $id ID - * @property int $ownerId Owner ID + * @property int $primaryOwnerId Primary owner ID * @property int $fieldId Field ID * @property int $typeId Type ID - * @property int $sortOrder Sort order * @property Element $element Element * @property Element $owner Owner * @property Field $field Field @@ -49,13 +48,13 @@ public function getElement(): ActiveQueryInterface } /** - * Returns the matrix block’s owner. + * Returns the matrix block’s primary owner. * * @return ActiveQueryInterface The relational query object. */ - public function getOwner(): ActiveQueryInterface + public function getPrimaryOwner(): ActiveQueryInterface { - return $this->hasOne(Element::class, ['id' => 'ownerId']); + return $this->hasOne(Element::class, ['id' => 'primaryOwnerId']); } /** diff --git a/src/services/Drafts.php b/src/services/Drafts.php index 310b735b73f..5b597029379 100644 --- a/src/services/Drafts.php +++ b/src/services/Drafts.php @@ -16,6 +16,7 @@ use craft\db\Table; use craft\errors\InvalidElementException; use craft\events\DraftEvent; +use craft\helpers\ArrayHelper; use craft\helpers\DateTimeHelper; use craft\helpers\Db; use craft\helpers\ElementHelper; @@ -125,6 +126,8 @@ public function createDraft( throw new InvalidArgumentException('Cannot create a draft from another draft or revision.'); } + $markAsSaved = ArrayHelper::remove($newAttributes, 'markAsSaved') ?? true; + // Fire a 'beforeCreateDraft' event $event = new DraftEvent([ 'canonical' => $canonical, @@ -156,6 +159,7 @@ public function createDraft( 'draftName' => $name, 'draftNotes' => $notes, 'trackChanges' => $canonical::trackChanges(), + 'markAsSaved' => $markAsSaved, ]; $draft = Craft::$app->getElements()->duplicateElement($canonical, $newAttributes); @@ -307,40 +311,8 @@ public function applyDraft(ElementInterface $draft): ElementInterface $elementsService->deleteElement($draft, true); } else { // Just remove the draft data - $draftId = $draft->draftId; - $draft->draftId = null; - $draft->detachBehavior('draft'); $draft->setRevisionNotes($draftNotes); - $draft->firstSave = true; - - // We still need to validate so the SlugValidator gets run - $draft->setScenario(Element::SCENARIO_ESSENTIALS); - $draft->validate(); - - // If there are any errors on the URI, re-validate as disabled - if ($draft->hasErrors('uri') && $draft->enabled) { - $draft->enabled = false; - $draft->validate(); - } - - if ($draft->hasErrors()) { - throw new InvalidElementException($draft, 'Draft ' . $draft->id . ' could not be applied because it doesn\'t validate.'); - } - - try { - $elementsService->saveElement($draft, false); - Db::delete(Table::DRAFTS, [ - 'id' => $draftId, - ]); - } catch (Throwable $e) { - // Put everything back - $draft->draftId = $draftId; - $draft->attachBehavior('draft', $behavior); - $draft->firstSave = false; - throw $e; - } - - $draft->firstSave = false; + $this->removeDraftData($draft); $newCanonical = $draft; } @@ -370,6 +342,53 @@ public function applyDraft(ElementInterface $draft): ElementInterface return $newCanonical; } + /** + * Removes draft data from the given draft. + * + * @param ElementInterface $draft + * @throws InvalidElementException + * @since 4.0.0 + */ + public function removeDraftData(ElementInterface $draft): void + { + /** @var DraftBehavior $behavior */ + $behavior = $draft->getBehavior('draft'); + $draftId = $draft->draftId; + + $draft->draftId = null; + $draft->detachBehavior('draft'); + $draft->firstSave = true; + + // We still need to validate so the SlugValidator gets run + $draft->setScenario(Element::SCENARIO_ESSENTIALS); + $draft->validate(); + + // If there are any errors on the URI, re-validate as disabled + if ($draft->hasErrors('uri') && $draft->enabled) { + $draft->enabled = false; + $draft->validate(); + } + + if ($draft->hasErrors()) { + throw new InvalidElementException($draft, "Draft $draft->id could not be applied because it doesn't validate."); + } + + try { + Craft::$app->getElements()->saveElement($draft, false); + Db::delete(Table::DRAFTS, [ + 'id' => $draftId, + ]); + } catch (Throwable $e) { + // Put everything back + $draft->draftId = $draftId; + $draft->attachBehavior('draft', $behavior); + $draft->firstSave = false; + throw $e; + } + + $draft->firstSave = false; + } + /** * Deletes any unpublished drafts that were never formally saved. */ diff --git a/src/services/Matrix.php b/src/services/Matrix.php index f0c2e304d6c..f468b98c1ea 100644 --- a/src/services/Matrix.php +++ b/src/services/Matrix.php @@ -30,6 +30,7 @@ use Throwable; use yii\base\Component; use yii\base\Exception; +use yii\base\InvalidArgumentException; /** * The Matrix service provides APIs for managing Matrix fields. @@ -681,16 +682,27 @@ public function saveField(MatrixField $field, ElementInterface $owner): void foreach ($blocks as $block) { $sortOrder++; if ($saveAll || !$block->id || $block->dirty) { - $block->ownerId = $owner->id; + $block->primaryOwnerId = $owner->id; $block->sortOrder = $sortOrder; $elementsService->saveElement($block, false); + + // If this is a draft, we can shed the draft data now + if ($block->getIsDraft()) { + $canonicalBlockId = $block->getCanonicalId(); + Craft::$app->getDrafts()->removeDraftData($block); + Db::delete(Table::MATRIXBLOCKS_OWNERS, [ + 'blockId' => $canonicalBlockId, + 'ownerId' => $owner->id, + ]); + } } else if ((int)$block->sortOrder !== $sortOrder) { // Just update its sortOrder $block->sortOrder = $sortOrder; - Db::update(Table::MATRIXBLOCKS, [ + Db::update(Table::MATRIXBLOCKS_OWNERS, [ 'sortOrder' => $sortOrder, ], [ - 'id' => $block->id, + 'blockId' => $block->id, + 'ownerId' => $owner->id, ], [], false); } @@ -822,7 +834,7 @@ public function duplicateBlocks(MatrixField $field, ElementInterface $source, El $newAttributes = [ // Only set the canonicalId if the target owner element is a derivative 'canonicalId' => $target->getIsDerivative() ? $block->id : null, - 'ownerId' => $target->id, + 'primaryOwnerId' => $target->id, 'owner' => $target, 'siteId' => $target->siteId, 'propagating' => false, @@ -834,16 +846,18 @@ public function duplicateBlocks(MatrixField $field, ElementInterface $source, El !empty($target->newSiteIds) || $source->isFieldModified($field->handle, true) ) { - /** @var MatrixBlock $newBlock */ - $newBlock = $elementsService->updateCanonicalElement($block, $newAttributes); - $newBlockId = $newBlock->id; + $newBlockId = $elementsService->updateCanonicalElement($block, $newAttributes)->id; } else { $newBlockId = $block->getCanonicalId(); } } else { - /** @var MatrixBlock $newBlock */ - $newBlock = $elementsService->duplicateElement($block, $newAttributes); - $newBlockId = $newBlock->id; + // If the block’s primary owner is equal to the target element ID, no need to do anything + if ($block->primaryOwnerId !== $target->id) { + /** @var MatrixBlock $newBlock */ + $newBlockId = $elementsService->duplicateElement($block, $newAttributes)->id; + } else { + $newBlockId = $block->id; + } } $newBlockIds[] = $newBlockId; @@ -910,6 +924,36 @@ public function duplicateBlocks(MatrixField $field, ElementInterface $source, El } } + /** + * Duplicates block ownership relations for a new draft element. + * + * @param MatrixField $field The Matrix field + * @param ElementInterface $canonical The canonical element + * @param ElementInterface $draft The draft element + * @since 4.0.0 + */ + public function duplicateOwnership(MatrixField $field, ElementInterface $canonical, ElementInterface $draft): void + { + if (!$canonical->getIsCanonical()) { + throw new InvalidArgumentException('The source element must be canonical.'); + } + + if (!$draft->getIsDraft()) { + throw new InvalidArgumentException('The target element must be a draft.'); + } + + $blocksTable = Table::MATRIXBLOCKS; + $ownersTable = Table::MATRIXBLOCKS_OWNERS; + + Craft::$app->getDb()->createCommand(<<id', [[o.sortOrder]] +FROM $ownersTable AS [[o]] +INNER JOIN $blocksTable AS [[b]] ON [[b.id]] = [[o.blockId]] AND [[b.primaryOwnerId]] = '$canonical->id' AND [[b.fieldId]] = '$field->id' +WHERE [[o.ownerId]] = '$canonical->id' +SQL)->execute(); + } + /** * Merges recent canonical Matrix block changes into the given Matrix field’s blocks. * @@ -951,7 +995,7 @@ public function mergeCanonicalChanges(MatrixField $field, ElementInterface $owne // Get all the canonical owner’s blocks, including soft-deleted ones $canonicalBlocks = MatrixBlock::find() ->fieldId($field->id) - ->ownerId($canonicalOwner->id) + ->primaryOwnerId($canonicalOwner->id) ->siteId($canonicalOwner->siteId) ->status(null) ->trashed(null) @@ -961,7 +1005,7 @@ public function mergeCanonicalChanges(MatrixField $field, ElementInterface $owne // Get all the derivative owner’s blocks, so we can compare $derivativeBlocks = MatrixBlock::find() ->fieldId($field->id) - ->ownerId($owner->id) + ->primaryOwnerId($owner->id) ->siteId($canonicalOwner->siteId) ->status(null) ->trashed(null) @@ -987,7 +1031,7 @@ public function mergeCanonicalChanges(MatrixField $field, ElementInterface $owne // This is a new block, so duplicate it into the derivative owner $elementsService->duplicateElement($canonicalBlock, [ 'canonicalId' => $canonicalBlock->id, - 'ownerId' => $owner->id, + 'primaryOwnerId' => $owner->id, 'owner' => $localizedOwners[$canonicalBlock->siteId], 'siteId' => $canonicalBlock->siteId, 'propagating' => false, @@ -1142,7 +1186,7 @@ private function _deleteOtherBlocks(MatrixField $field, ElementInterface $owner, { $deleteBlocks = MatrixBlock::find() ->status(null) - ->ownerId($owner->id) + ->primaryOwnerId($owner->id) ->fieldId($field->id) ->siteId($owner->siteId) ->andWhere(['not', ['elements.id' => $except]]) diff --git a/src/services/Sites.php b/src/services/Sites.php index 755496169f6..9c86df341ea 100644 --- a/src/services/Sites.php +++ b/src/services/Sites.php @@ -982,7 +982,7 @@ public function deleteSite(Site $site, ?int $transferContentTo = null): bool $blockIds = (new Query()) ->select(['id']) ->from([Table::MATRIXBLOCKS]) - ->where(['ownerId' => $entryIds]) + ->where(['primaryOwnerId' => $entryIds]) ->column(); if (!empty($blockIds)) { From 2cabdf25cd463464cbfffb33430e46740bcb4433 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 15 Feb 2022 16:33:17 -0800 Subject: [PATCH 2/4] Update the changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5567c525a9d..ba5fc6386bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - It’s now possible to edit images’ focal points from their preview modals. ([#8489](https://github.com/craftcms/cms/discussions/8489)) - Added the `|money` Twig filter. - Added the `assetUploaders` user query param. +- Added the `primaryOwner` and `primaryOwnerId` Matrix block query params. - Added the `authors` user query param. - Added the `hasAlt` asset query param. - Added the `button`, `submitButton`, `fs`, and `fsField` macros to the `_includes/forms` control panel template. @@ -91,6 +92,7 @@ - Added `craft\db\Table::ASSETINDEXINGSESSIONS`. - Added `craft\db\Table::IMAGETRANSFORMINDEX`. - Added `craft\db\Table::IMAGETRANSFORMS`. +- Added `craft\db\Table::MATRIXBLOCKS_OWNERS`. - Added `craft\elements\Asset::$alt`. - Added `craft\elements\Asset::getFs()`. - Added `craft\elements\Asset::setFilename()`. @@ -135,6 +137,7 @@ - Added `craft\elements\conditions\users\LastNameConditionRule`. - Added `craft\elements\conditions\users\UserCondition`. - Added `craft\elements\conditions\users\UsernameConditionRule`. +- Added `craft\elements\MatrixBlock::$primaryOwnerId`. - Added `craft\elements\User::$active`. - Added `craft\elements\User::canAssignUserGroups()`. - Added `craft\elements\User::getIsCredentialed()`. @@ -253,11 +256,13 @@ - Added `craft\services\Conditions`. - Added `craft\services\Config::CATEGORY_CUSTOM`. - Added `craft\services\Config::getCustom()`. +- Added `craft\services\Drafts::removeDraftData()`. - Added `craft\services\ElementSources`, which replaces `craft\services\ElementIndexes`. - Added `craft\services\Fields::createLayout()`. - Added `craft\services\Fs`. - Added `craft\services\Gql::prepareFieldDefinitions()`. - Added `craft\services\ImageTransforms`. +- Added `craft\services\Matrix::duplicateOwnership()`. - Added `craft\services\ProjectConfig::applyExternalChanges()`. - Added `craft\services\ProjectConfig::ASSOC_KEY`. - Added `craft\services\ProjectConfig::getDoesExternalConfigExist()`. @@ -342,6 +347,7 @@ - Craft now requires PHP 8.0 or later. - Craft now requires MySQL 5.7.8 / MariaDB 10.2.7 / PostgreSQL 10.0 or later. - Craft now requires the [Intl](https://php.net/manual/en/book.intl.php) PHP extension. +- Improved draft creation/application performance. ([#10577](https://github.com/craftcms/cms/pull/10577)) - The “What’ New” HUD now displays an icon and label above each announcement, identifying where it came from (Craft CMS or a plugin). ([#9747](https://github.com/craftcms/cms/discussions/9747)) - The control panel now keeps track of the currently-edited site on a per-tab basis by adding a `site` query string param to all control panel URLs. ([#8920](https://github.com/craftcms/cms/discussions/8920)) - Users are no longer required to have a username or email. From 04e19dda7521b0dcaa852aeee416bb22b75f9948 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 15 Feb 2022 17:16:40 -0800 Subject: [PATCH 3/4] Fix installer --- src/migrations/Install.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 3d0a51734e6..2db7311fdd0 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -463,7 +463,7 @@ public function createTables(): void 'blockId' => $this->integer()->notNull(), 'ownerId' => $this->integer()->notNull(), 'sortOrder' => $this->smallInteger()->unsigned()->notNull(), - 'PRIMARY KEY([[blockId]], [[elementId]])', + 'PRIMARY KEY([[blockId]], [[ownerId]])', ]); $this->createTable(Table::MATRIXBLOCKTYPES, [ 'id' => $this->primaryKey(), From 037f80fa9b504a7384927c6ad32637709e35216d Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 15 Feb 2022 17:41:17 -0800 Subject: [PATCH 4/4] Fix old school Matrix field saving --- src/fields/Matrix.php | 2 +- src/services/Matrix.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fields/Matrix.php b/src/fields/Matrix.php index f0ace2aa3dc..94524c41e43 100644 --- a/src/fields/Matrix.php +++ b/src/fields/Matrix.php @@ -1288,7 +1288,7 @@ private function _createBlocksFromSerializedData(array $value, ElementInterface $block = new MatrixBlock(); $block->fieldId = $this->id; $block->typeId = $blockTypes[$blockData['type']]->id; - $block->primaryOwnerId = $element->id; + $block->primaryOwnerId = $block->ownerId = $element->id; $block->siteId = $element->siteId; // Preserve the collapsed state, which the browser can't remember on its own for new blocks diff --git a/src/services/Matrix.php b/src/services/Matrix.php index f468b98c1ea..f88e2f85343 100644 --- a/src/services/Matrix.php +++ b/src/services/Matrix.php @@ -682,7 +682,7 @@ public function saveField(MatrixField $field, ElementInterface $owner): void foreach ($blocks as $block) { $sortOrder++; if ($saveAll || !$block->id || $block->dirty) { - $block->primaryOwnerId = $owner->id; + $block->primaryOwnerId = $block->ownerId = $owner->id; $block->sortOrder = $sortOrder; $elementsService->saveElement($block, false);