From e717416b29661d262200be4557e5ccaba0cbe5f5 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 1 Jun 2026 19:42:28 +0200 Subject: [PATCH 1/2] Add field references + deletion blockers --- routes/actions.php | 2 + src/Cms.php | 2 +- ...01_000000_create_fieldreferences_table.php | 41 +++ src/Database/Migrations/Install.php | 15 + src/Database/Table.php | 2 + src/Element/Concerns/HasDeletionBlockers.php | 2 + .../FieldReferencesDeletionBlocker.php | 131 ++++++++ src/Element/Jobs/ReplaceReferences.php | 116 +++++++ .../TracksReferencesFieldInterface.php | 10 + .../Events/FieldElementDeletedForSite.php | 7 + src/Field/Events/FieldLayoutSaved.php | 1 + src/Field/Field.php | 6 +- src/Field/FieldReferences.php | 282 ++++++++++++++++++ src/Field/Fields.php | 4 +- src/Field/FieldsServiceProvider.php | 4 + .../FieldReferenceEventSubscriber.php | 115 +++++++ src/Field/Markdown.php | 3 +- .../Elements/DeleteElementsController.php | 88 +++++- .../Feature/Element/DeletionBlockersTest.php | 19 +- .../Element/Jobs/ReplaceReferencesTest.php | 201 +++++++++++++ tests/Feature/Field/FieldReferencesTest.php | 205 +++++++++++++ .../Elements/DeleteElementsControllerTest.php | 35 +++ 22 files changed, 1281 insertions(+), 10 deletions(-) create mode 100644 src/Database/Migrations/2026_06_01_000000_create_fieldreferences_table.php create mode 100644 src/Element/DeletionBlockers/FieldReferencesDeletionBlocker.php create mode 100644 src/Element/Jobs/ReplaceReferences.php create mode 100644 src/Field/Contracts/TracksReferencesFieldInterface.php create mode 100644 src/Field/Events/FieldElementDeletedForSite.php create mode 100644 src/Field/FieldReferences.php create mode 100644 src/Field/Listeners/FieldReferenceEventSubscriber.php create mode 100644 tests/Feature/Element/Jobs/ReplaceReferencesTest.php create mode 100644 tests/Feature/Field/FieldReferencesTest.php diff --git a/routes/actions.php b/routes/actions.php index 0771e5080a5..b91c1a7a6c5 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -263,6 +263,8 @@ Route::post('delete-elements/delete', [DeleteElementsController::class, 'destroy']); Route::any('delete-elements/replace-relations-modal', [DeleteElementsController::class, 'replaceRelationsModal']); Route::post('delete-elements/replace-relations', [DeleteElementsController::class, 'replaceRelations']); + Route::any('delete-elements/replace-references-modal', [DeleteElementsController::class, 'replaceReferencesModal']); + Route::post('delete-elements/replace-references', [DeleteElementsController::class, 'replaceReferences']); Route::post('elements/create', CreateElementController::class); Route::any('elements/edit', EditElementController::class); diff --git a/src/Cms.php b/src/Cms.php index b579992ef9b..62ad7a549f0 100644 --- a/src/Cms.php +++ b/src/Cms.php @@ -30,7 +30,7 @@ public const string VERSION = '6.0.0-alpha.5'; - public const string SCHEMA_VERSION = '6.0.0.2'; + public const string SCHEMA_VERSION = '6.0.0.3'; public const string MIN_VERSION_REQUIRED = '5.9.0'; diff --git a/src/Database/Migrations/2026_06_01_000000_create_fieldreferences_table.php b/src/Database/Migrations/2026_06_01_000000_create_fieldreferences_table.php new file mode 100644 index 00000000000..ccf8ffe0dd1 --- /dev/null +++ b/src/Database/Migrations/2026_06_01_000000_create_fieldreferences_table.php @@ -0,0 +1,41 @@ +integer('id', true); + $table->integer('fieldId'); + $table->uuid('fieldInstanceUid'); + $table->integer('sourceId'); + $table->integer('sourceSiteId')->nullable(); + $table->integer('targetId'); + }); + + Schema::createIndex(Table::FIELDREFERENCES, ['fieldId', 'fieldInstanceUid', 'sourceId', 'sourceSiteId', 'targetId'], unique: true); + Schema::createIndex(Table::FIELDREFERENCES, ['targetId']); + + Schema::table(Table::FIELDREFERENCES, function (Blueprint $table) { + $table->foreign('fieldId')->references('id')->on(Table::FIELDS)->cascadeOnDelete(); + $table->foreign('sourceId')->references('id')->on(Table::ELEMENTS)->cascadeOnDelete(); + $table->foreign('sourceSiteId')->references('id')->on(Table::SITES)->cascadeOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists(Table::FIELDREFERENCES); + } +}; diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index 2e0d53c5c59..9dc7706e103 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -602,6 +602,16 @@ public function createTables(?Logger $logger = null): void $table->char('uid', 36)->default('0'); }); + $logger?->subLabel('fieldreferences'); + Schema::create(Table::FIELDREFERENCES, function (Blueprint $table) { + $table->integer('id', true); + $table->integer('fieldId'); + $table->uuid('fieldInstanceUid'); + $table->integer('sourceId'); + $table->integer('sourceSiteId')->nullable(); + $table->integer('targetId'); + }); + $logger?->subLabel('gqltokens'); Schema::create('gqltokens', function (Blueprint $table) { $table->integer('id', true); @@ -1069,6 +1079,8 @@ public function createIndexes(): void Schema::createIndex(Table::ENTRYTYPES, ['dateDeleted']); Schema::createIndex(Table::FIELDLAYOUTS, ['dateDeleted']); Schema::createIndex(Table::FIELDLAYOUTS, ['type']); + Schema::createIndex(Table::FIELDREFERENCES, ['fieldId', 'fieldInstanceUid', 'sourceId', 'sourceSiteId', 'targetId'], unique: true); + Schema::createIndex(Table::FIELDREFERENCES, ['targetId']); Schema::createIndex(Table::FIELDS, ['handle', 'context']); Schema::createIndex(Table::FIELDS, ['context']); Schema::createIndex(Table::FIELDS, ['dateDeleted']); @@ -1213,6 +1225,9 @@ public function addForeignKeys(): void Schema::table(Table::ENTRIES, fn (Blueprint $table) => $table->foreign('fieldId')->references('id')->on(Table::FIELDS)->cascadeOnDelete()); Schema::table(Table::ENTRIES, fn (Blueprint $table) => $table->foreign('primaryOwnerId')->references('id')->on(Table::ELEMENTS)->cascadeOnDelete()); Schema::table(Table::ENTRYTYPES, fn (Blueprint $table) => $table->foreign('fieldLayoutId')->references('id')->on(Table::FIELDLAYOUTS)->nullOnDelete()); + Schema::table(Table::FIELDREFERENCES, fn (Blueprint $table) => $table->foreign('fieldId')->references('id')->on(Table::FIELDS)->cascadeOnDelete()); + Schema::table(Table::FIELDREFERENCES, fn (Blueprint $table) => $table->foreign('sourceId')->references('id')->on(Table::ELEMENTS)->cascadeOnDelete()); + Schema::table(Table::FIELDREFERENCES, fn (Blueprint $table) => $table->foreign('sourceSiteId')->references('id')->on(Table::SITES)->cascadeOnDelete()->cascadeOnUpdate()); Schema::table(Table::GQLTOKENS, fn (Blueprint $table) => $table->foreign('schemaId')->references('id')->on(Table::GQLSCHEMAS)->nullOnDelete()); Schema::table(Table::RELATIONS, fn (Blueprint $table) => $table->foreign('fieldId')->references('id')->on(Table::FIELDS)->cascadeOnDelete()); Schema::table(Table::RELATIONS, fn (Blueprint $table) => $table->foreign('sourceId')->references('id')->on(Table::ELEMENTS)->cascadeOnDelete()); diff --git a/src/Database/Table.php b/src/Database/Table.php index 6751cb581c2..f89d037ca53 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -55,6 +55,8 @@ public const string FIELDLAYOUTS = 'fieldlayouts'; + public const string FIELDREFERENCES = 'fieldreferences'; + public const string FIELDS = 'fields'; public const string GQLSCHEMAS = 'gqlschemas'; diff --git a/src/Element/Concerns/HasDeletionBlockers.php b/src/Element/Concerns/HasDeletionBlockers.php index 55367de8549..e993dda6c0e 100644 --- a/src/Element/Concerns/HasDeletionBlockers.php +++ b/src/Element/Concerns/HasDeletionBlockers.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Element\Concerns; +use CraftCms\Cms\Element\DeletionBlockers\FieldReferencesDeletionBlocker; use CraftCms\Cms\Element\DeletionBlockers\RelationDeletionBlocker; use CraftCms\Cms\Element\ElementCollection; use CraftCms\Cms\Element\Events\DefineDeletionBlockers; @@ -22,6 +23,7 @@ public static function deletionBlockers(ElementCollection $elements, bool $hardD 'defaultSort' => ['section', 'asc'], ], ]), + new FieldReferencesDeletionBlocker($elements, $hardDelete), ]; event($event = new DefineDeletionBlockers( diff --git a/src/Element/DeletionBlockers/FieldReferencesDeletionBlocker.php b/src/Element/DeletionBlockers/FieldReferencesDeletionBlocker.php new file mode 100644 index 00000000000..0b76fd291c4 --- /dev/null +++ b/src/Element/DeletionBlockers/FieldReferencesDeletionBlocker.php @@ -0,0 +1,131 @@ +referenceCount = $this->fieldReferences()->referenceCountForTargets($this->elements->ids()->all()); + } + + public function isActive(): bool + { + return $this->referenceCount !== 0; + } + + public function getSummary(): string + { + $targetElementType = $this->targetElementType(); + + return t('The {numTargets, plural, =1{{targetTypeSingular} is} other{{targetTypePlural} are}} referenced by fields in {numSources, number} other {numSources, plural, =1{element} other{elements}}.', [ + 'targetTypeSingular' => $targetElementType::lowerDisplayName(), + 'targetTypePlural' => $targetElementType::pluralLowerDisplayName(), + 'numSources' => $this->referenceCount, + 'numTargets' => $this->elements->count(), + ]); + } + + public function getDetails(): ?string + { + $groups = $this->fieldReferences()->referenceIdsByTypeForTargets($this->elements->ids()->all()); + + if ($groups->isEmpty()) { + return null; + } + + return $groups + ->map(function ($sourceIds, string $sourceElementType) { + /** @var class-string $sourceElementType */ + return Html::tag('h3', $sourceElementType::pluralDisplayName()). + app(ElementIndexHtml::class)->html($sourceElementType, [ + 'context' => 'pane', + 'sources' => false, + 'jsSettings' => [ + 'criteria' => [ + 'id' => $sourceIds->all(), + 'drafts' => null, + 'provisionalDrafts' => null, + 'revisions' => false, + 'status' => null, + ], + ], + ]); + }) + ->join(''); + } + + public function getActions(): array + { + $targetElementType = $this->targetElementType(); + + return [ + [ + 'icon' => 'swap', + 'label' => t('Replace {numReferences, plural, =1{reference} other{references}}', [ + 'numReferences' => $this->referenceCount, + ]), + 'callback' => Html::jsWithVars(fn ( + $targetElementType, + $targetIds, + $hardDelete, + ) => << { + resolve(ev.response.data.message); + }, + onCancel: () => { + reject(); + }, +}) +JS, [ + $targetElementType, + $this->elements->ids()->all(), + $this->hardDelete, + ]), + ], + [ + 'icon' => 'xmark', + 'label' => t('Ignore {numReferences, plural, =1{reference} other{references}}', [ + 'numReferences' => $this->referenceCount, + ]), + 'callback' => 'resolve();', + ], + ]; + } + + /** + * @return class-string + */ + private function targetElementType(): string + { + return $this->elements->first()::class; + } + + private function fieldReferences(): FieldReferences + { + return app(FieldReferences::class); + } +} diff --git a/src/Element/Jobs/ReplaceReferences.php b/src/Element/Jobs/ReplaceReferences.php new file mode 100644 index 00000000000..cb59b9b0d9d --- /dev/null +++ b/src/Element/Jobs/ReplaceReferences.php @@ -0,0 +1,116 @@ + */ + public string $sourceElementType, + + /** @var class-string */ + public string $targetElementType, + + public ?int $sourceSiteId, + + /** @var array */ + public array $refs, + + /** @var int[] */ + public array $oldTargetIds, + + public int $newTargetId, + ) { + parent::__construct(); + } + + #[Override] + protected function getQuery(): Builder + { + $query = $this->sourceElementType::find() + ->id(Collection::make($this->refs)->pluck('sourceId')->unique()->values()->all()) + ->drafts(null) + ->provisionalDrafts(null) + ->revisions(false) + ->trashed(false) + ->status(null) + ->orderBy('elements.id'); + + if ($this->sourceSiteId !== null) { + $query->siteId($this->sourceSiteId); + } else { + $query->siteId('*')->unique(); + } + + return $query; + } + + protected function processElement(ElementInterface $element): void + { + $fieldInstanceUids = Collection::make($this->refs) + ->filter(fn (array $ref) => $ref['sourceId'] === $element->id) + ->pluck('fieldInstanceUid') + ->unique(); + + if ($fieldInstanceUids->isEmpty()) { + return; + } + + $saveElement = false; + $fieldReferences = app(FieldReferences::class); + + foreach ($fieldInstanceUids as $fieldInstanceUid) { + $layoutElement = $element->getFieldLayout()?->getElementByUid($fieldInstanceUid); + + if (! $layoutElement instanceof CustomField) { + continue; + } + + $field = $layoutElement->getField(); + + if (! $field instanceof TracksReferencesFieldInterface) { + continue; + } + + if ($fieldReferences->replaceReferences($field, $element, $this->oldTargetIds, $this->newTargetId)) { + $saveElement = true; + } + } + + if (! $saveElement) { + return; + } + + $element->ruleset->useScenario(ElementRules::SCENARIO_ESSENTIALS); + $element->resaving = true; + + try { + Elements::saveElement(element: $element, runValidation: false, propagate: false); + } catch (Throwable $e) { + report($e); + } + } + + #[Override] + protected function defaultDescription(): string + { + return I18N::prep('Replacing {type} references', [ + 'type' => $this->targetElementType::lowerDisplayName(), + ]); + } +} diff --git a/src/Field/Contracts/TracksReferencesFieldInterface.php b/src/Field/Contracts/TracksReferencesFieldInterface.php new file mode 100644 index 00000000000..a925c0d190f --- /dev/null +++ b/src/Field/Contracts/TracksReferencesFieldInterface.php @@ -0,0 +1,10 @@ +id, $field->layoutElement->uid, $element->id, $element->siteId)) { + return; + } + + $targetIds = $this->targetIdsInValue($field->serializeValue( + $element->getFieldValue($field->handle), + $element, + )); + $sourceSiteId = $element->siteId; + + DB::transaction(function () use ($field, $element, $sourceSiteId, $targetIds) { + DB::table(Table::FIELDREFERENCES) + ->where('fieldId', $field->id) + ->where('fieldInstanceUid', $field->layoutElement->uid) + ->where('sourceId', $element->id) + ->where('sourceSiteId', $sourceSiteId) + ->delete(); + + if ($targetIds === []) { + return; + } + + DB::table(Table::FIELDREFERENCES)->insert(array_map(fn (int $targetId) => [ + 'fieldId' => $field->id, + 'fieldInstanceUid' => $field->layoutElement->uid, + 'sourceId' => $element->id, + 'sourceSiteId' => $sourceSiteId, + 'targetId' => $targetId, + ], $targetIds)); + }); + } + + public function deleteReferencesForSourceFieldSite(TracksReferencesFieldInterface $field, ElementInterface $element): void + { + if (! isset($field->id, $element->id, $element->siteId)) { + return; + } + + DB::table(Table::FIELDREFERENCES) + ->where('fieldId', $field->id) + ->where('sourceId', $element->id) + ->where('sourceSiteId', $element->siteId) + ->delete(); + } + + public function deleteReferencesForField(FieldInterface $field): void + { + if (! isset($field->id)) { + return; + } + + DB::table(Table::FIELDREFERENCES) + ->where('fieldId', $field->id) + ->delete(); + } + + public function deleteReferencesForRemovedInstances(?array $previousConfig, ?array $currentConfig): void + { + $removedUids = array_values(array_diff( + $this->customFieldUidsInConfig($previousConfig), + $this->customFieldUidsInConfig($currentConfig), + )); + + if ($removedUids === []) { + return; + } + + DB::table(Table::FIELDREFERENCES) + ->whereIn('fieldInstanceUid', $removedUids) + ->delete(); + } + + /** + * @param int[] $oldTargetIds + */ + public function replaceReferences(TracksReferencesFieldInterface $field, ElementInterface $element, array $oldTargetIds, int $newTargetId): bool + { + $value = $field->serializeValue($element->getFieldValue($field->handle), $element); + + if (! is_string($value)) { + return false; + } + + $newValue = $this->valueWithReplacedTargetIds($value, $oldTargetIds, $newTargetId); + + if ($newValue === $value) { + return false; + } + + $element->setFieldValue($field->handle, $newValue); + + return true; + } + + /** + * @param int[] $targetIds + * @return Collection> + */ + public function referenceIdsByTypeForTargets(array $targetIds): Collection + { + if ($targetIds === []) { + return Collection::make(); + } + + return $this->referencesToTargetsQuery($targetIds) + ->select(['e.type', 'fr.sourceId']) + ->distinct() + ->get() + ->groupBy('type') + ->map(fn (Collection $rows) => $rows->pluck('sourceId')->map(fn ($id) => (int) $id)->values()); + } + + /** + * @param int[] $targetIds + */ + public function referenceCountForTargets(array $targetIds): int + { + if ($targetIds === []) { + return 0; + } + + return $this->referencesToTargetsQuery($targetIds) + ->distinct() + ->count('fr.sourceId'); + } + + /** + * @param int[] $targetIds + * @return Collection>> + */ + public function replacementGroupsForTargets(array $targetIds): Collection + { + if ($targetIds === []) { + return Collection::make(); + } + + /** @var Collection>> $groups */ + $groups = $this->referencesToTargetsQuery($targetIds) + ->select(['fr.fieldInstanceUid', 'fr.sourceId', 'fr.sourceSiteId', 'e.type']) + ->distinct() + ->orderBy('e.type') + ->orderBy('fr.sourceSiteId') + ->orderBy('fr.sourceId') + ->get() + ->groupBy([ + 'type', + fn (object $row) => $row->sourceSiteId === null ? '*' : (string) $row->sourceSiteId, + ]); + + return $groups; + } + + /** + * @param int[] $targetIds + */ + private function referencesToTargetsQuery(array $targetIds): Builder + { + return DB::table(Table::FIELDREFERENCES, 'fr') + ->join(new Alias(Table::ELEMENTS, 'e'), 'e.id', 'fr.sourceId') + ->whereIn('fr.targetId', $targetIds) + ->whereNull('e.dateDeleted') + ->whereNull('e.revisionId'); + } + + private function targetIdsInValue(mixed $value): array + { + if (! is_string($value) || ! str_contains($value, '{')) { + return []; + } + + preg_match_all(Elements::REF_TAG_PATTERN, $value, $matches, PREG_SET_ORDER); + + if ($matches === []) { + return []; + } + + $idsByType = []; + + foreach ($matches as $match) { + if (! ctype_digit($match['ref'])) { + continue; + } + + $elementType = $this->elements->getElementTypeByRefHandle($match['elementType']); + + if ($elementType === null) { + continue; + } + + $idsByType[$elementType][] = (int) $match['ref']; + } + + if ($idsByType === []) { + return []; + } + + $validIds = []; + + foreach ($idsByType as $elementType => $ids) { + DB::table(Table::ELEMENTS) + ->whereIn('id', array_unique($ids)) + ->where('type', $elementType) + ->pluck('id') + ->each(function ($id) use (&$validIds) { + $validIds[(int) $id] = true; + }); + } + + return array_keys($validIds); + } + + /** + * @param int[] $oldTargetIds + */ + private function valueWithReplacedTargetIds(string $value, array $oldTargetIds, int $newTargetId): string + { + $oldTargetIds = array_flip(array_map(intval(...), $oldTargetIds)); + + return preg_replace_callback(Elements::REF_TAG_PATTERN, function (array $matches) use ($oldTargetIds, $newTargetId) { + if (! ctype_digit($matches['ref']) || ! isset($oldTargetIds[(int) $matches['ref']])) { + return $matches[0]; + } + + return substr_replace( + $matches[0], + (string) $newTargetId, + strlen($matches['elementType']) + 2, + strlen($matches['ref']), + ); + }, $value) ?? $value; + } + + private function customFieldUidsInConfig(?array $config): array + { + if ($config === null) { + return []; + } + + $uids = []; + + foreach ($config['tabs'] ?? [] as $tab) { + foreach ($tab['elements'] ?? [] as $element) { + if ( + is_array($element) && + ($element['type'] ?? null) === CustomField::class && + isset($element['uid']) + ) { + $uids[] = $element['uid']; + } + } + } + + return array_values(array_unique($uids)); + } +} diff --git a/src/Field/Fields.php b/src/Field/Fields.php index 2de3af02c46..afc86a0bca5 100644 --- a/src/Field/Fields.php +++ b/src/Field/Fields.php @@ -1037,8 +1037,10 @@ public function saveLayout(FieldLayout $layout, bool $runValidation = true): boo if (! $isNewLayout) { // Get the current layout $layoutModel = FieldLayoutModel::withTrashed()->findOrFail($layout->id); + $previousConfig = $layoutModel->config; } else { $layoutModel = new FieldLayoutModel; + $previousConfig = null; } // Save the layout @@ -1062,7 +1064,7 @@ public function saveLayout(FieldLayout $layout, bool $runValidation = true): boo $layout->uid = $layoutModel->uid; - event(new FieldLayoutSaved($layout, $isNewLayout)); + event(new FieldLayoutSaved($layout, $isNewLayout, $previousConfig)); // Clear caches $this->invalidateCaches(); diff --git a/src/Field/FieldsServiceProvider.php b/src/Field/FieldsServiceProvider.php index 8c7c5c0f8f1..40d6b9f4de2 100644 --- a/src/Field/FieldsServiceProvider.php +++ b/src/Field/FieldsServiceProvider.php @@ -8,7 +8,9 @@ use CraftCms\Cms\Field\Commands\FieldsAutoMergeCommand; use CraftCms\Cms\Field\Commands\FieldsMergeCommand; use CraftCms\Cms\Field\IdeHelper\CustomFieldIdeHelperGenerator; +use CraftCms\Cms\Field\Listeners\FieldReferenceEventSubscriber; use CraftCms\Cms\ProjectConfig\ProjectConfig; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class FieldsServiceProvider extends ServiceProvider @@ -21,6 +23,8 @@ public function boot(ProjectConfig $projectConfig, CustomFieldIdeHelperGenerator FieldsAutoMergeCommand::class, ]); + Event::subscribe(FieldReferenceEventSubscriber::class); + $this->registerIdeHelperListeners($projectConfig, $generator); } diff --git a/src/Field/Listeners/FieldReferenceEventSubscriber.php b/src/Field/Listeners/FieldReferenceEventSubscriber.php new file mode 100644 index 00000000000..6e8f9237ad5 --- /dev/null +++ b/src/Field/Listeners/FieldReferenceEventSubscriber.php @@ -0,0 +1,115 @@ +field instanceof TracksReferencesFieldInterface) { + return; + } + + if (! $this->shouldUpdateTrackedReferences($event)) { + return; + } + + $this->fieldReferences->updateReferences($event->field, $event->element); + } + + public function handleFieldElementDeletedForSite(FieldElementDeletedForSite $event): void + { + if (! $event->field instanceof TracksReferencesFieldInterface) { + return; + } + + $this->fieldReferences->deleteReferencesForSourceFieldSite($event->field, $event->element); + } + + public function handleFieldSaveApplying(FieldSaveApplying $event): void + { + if (! $event->field instanceof TracksReferencesFieldInterface) { + return; + } + + $newType = $event->config['type'] ?? null; + + if (is_string($newType) && is_a($newType, TracksReferencesFieldInterface::class, true)) { + return; + } + + $this->fieldReferences->deleteReferencesForField($event->field); + } + + public function handleFieldDeleted(FieldDeleted $event): void + { + if (! $event->field instanceof TracksReferencesFieldInterface) { + return; + } + + $this->fieldReferences->deleteReferencesForField($event->field); + } + + public function handleFieldLayoutSaved(FieldLayoutSaved $event): void + { + $this->fieldReferences->deleteReferencesForRemovedInstances( + $event->previousConfig, + $event->layout->getConfig(), + ); + } + + public function handleFieldLayoutDeleted(FieldLayoutDeleted $event): void + { + $this->fieldReferences->deleteReferencesForRemovedInstances( + $event->layout->getConfig(), + null, + ); + } + + /** + * @return array + */ + public function subscribe(): array + { + return [ + FieldElementSaved::class => 'handleFieldElementSaved', + FieldElementDeletedForSite::class => 'handleFieldElementDeletedForSite', + FieldSaveApplying::class => 'handleFieldSaveApplying', + FieldDeleted::class => 'handleFieldDeleted', + FieldLayoutSaved::class => 'handleFieldLayoutSaved', + FieldLayoutDeleted::class => 'handleFieldLayoutDeleted', + ]; + } + + private function shouldUpdateTrackedReferences(FieldElementSaved $event): bool + { + if ($event->element->getIsRevision()) { + return false; + } + + return $event->isNew || + $event->element->isNewForSite || + $event->element->duplicateOf !== null || + $event->element->isFieldDirty($event->field->handle); + } +} diff --git a/src/Field/Markdown.php b/src/Field/Markdown.php index 04cf7c79e59..31d955c9416 100644 --- a/src/Field/Markdown.php +++ b/src/Field/Markdown.php @@ -15,6 +15,7 @@ use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface; use CraftCms\Cms\Field\Contracts\MergeableFieldInterface; use CraftCms\Cms\Field\Contracts\SortableFieldInterface; +use CraftCms\Cms\Field\Contracts\TracksReferencesFieldInterface; use CraftCms\Cms\Field\Data\MarkdownData; use CraftCms\Cms\Gql\GqlHelper; use CraftCms\Cms\Markdown\Markdown as MarkdownService; @@ -38,7 +39,7 @@ use function CraftCms\Cms\t; use function CraftCms\Cms\template; -class Markdown extends Field implements CrossSiteCopyableFieldInterface, InlineEditableFieldInterface, MergeableFieldInterface, SortableFieldInterface +class Markdown extends Field implements CrossSiteCopyableFieldInterface, InlineEditableFieldInterface, MergeableFieldInterface, SortableFieldInterface, TracksReferencesFieldInterface { use PreservesElementRefs; use ProvidesLinkField; diff --git a/src/Http/Controllers/Elements/DeleteElementsController.php b/src/Http/Controllers/Elements/DeleteElementsController.php index 6b24a08d4da..9af7386df8c 100644 --- a/src/Http/Controllers/Elements/DeleteElementsController.php +++ b/src/Http/Controllers/Elements/DeleteElementsController.php @@ -13,8 +13,10 @@ use CraftCms\Cms\Element\Element; use CraftCms\Cms\Element\ElementCollection; use CraftCms\Cms\Element\Elements; +use CraftCms\Cms\Element\Jobs\ReplaceReferences; use CraftCms\Cms\Element\Jobs\ReplaceRelations; use CraftCms\Cms\Element\Queries\Contracts\NestedElementQueryInterface; +use CraftCms\Cms\Field\FieldReferences; use CraftCms\Cms\Http\Requests\ElementRequest; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpModalResponse; @@ -130,7 +132,7 @@ public function replaceRelationsModal(): CpModalResponse 'single' => true, ]). Html::hiddenInput('elementType', $this->elementType). - $targetElementIds->map(fn (int $id) => Html::hiddenInput('elementIds[]', (string) $id))->join(''). + $targetElementIds->map(fn (int $id) => (string) Html::hiddenInput('elementIds[]', (string) $id))->join(''). Html::hiddenInput('hardDelete', $this->hardDelete ? '1' : '0'). Html::hiddenInput('sourceElementType', $sourceElementType) ) @@ -178,6 +180,90 @@ public function replaceRelations(): Response ])); } + public function replaceReferencesModal(): CpModalResponse + { + $targetElementIds = $this->elements->ids(); + + return new CpModalResponse() + ->action('delete-elements/replace-references') + ->contentHtml(fn () => FormFields::elementSelectFieldHtml([ + 'label' => t('Choose a new {type}', [ + 'type' => $this->elementType::lowerDisplayName(), + ]), + 'name' => 'newTargetId', + 'elementType' => $this->elementType, + 'criteria' => [ + 'id' => $targetElementIds->map(fn (int $id) => "not $id")->all(), + ], + 'single' => true, + ]). + Html::hiddenInput('elementType', $this->elementType). + $targetElementIds->map(fn (int $id) => (string) Html::hiddenInput('elementIds[]', (string) $id))->join(''). + Html::hiddenInput('hardDelete', $this->hardDelete ? '1' : '0') + ) + ->submitButtonLabel(t('Replace')); + } + + public function replaceReferences(FieldReferences $fieldReferences): Response + { + $this->request->validate([ + 'newTargetId' => ['required', 'integer'], + ]); + + $newTargetId = $this->request->integer('newTargetId'); + + if (! $newTargetId) { + return $this->asFailure(t('No new {type} selected.', [ + 'type' => $this->elementType::lowerDisplayName(), + ])); + } + + $newTarget = $this->elementType::find() + ->id($newTargetId) + ->siteId('*') + ->unique() + ->drafts(false) + ->revisions(false) + ->trashed(false) + ->status(null) + ->one(); + + if (! $newTarget) { + return $this->asFailure(t('The selected {type} could not be found.', [ + 'type' => $this->elementType::lowerDisplayName(), + ])); + } + + $oldTargetIds = $this->elements->ids()->all(); + $refCount = $fieldReferences->referenceCountForTargets($oldTargetIds); + + foreach ($fieldReferences->replacementGroupsForTargets($oldTargetIds) as $sourceElementType => $typeRefs) { + foreach ($typeRefs as $sourceSiteId => $siteRefs) { + $refs = []; + + foreach ($siteRefs as $ref) { + $refs[] = [ + 'fieldInstanceUid' => $ref->fieldInstanceUid, + 'sourceId' => (int) $ref->sourceId, + ]; + } + + dispatch(new ReplaceReferences( + sourceElementType: $sourceElementType, + targetElementType: $this->elementType, + sourceSiteId: $sourceSiteId === '*' ? null : (int) $sourceSiteId, + refs: $refs, + oldTargetIds: $oldTargetIds, + newTargetId: $newTargetId, + )); + } + } + + return $this->asSuccess(t('{numReferences, plural, =1{Reference} other{References}} queued to be replaced.', [ + 'numReferences' => $refCount, + ])); + } + private function elements(): ElementCollection { $this->request->validate([ diff --git a/tests/Feature/Element/DeletionBlockersTest.php b/tests/Feature/Element/DeletionBlockersTest.php index 5c0193f0355..568a7034c3f 100644 --- a/tests/Feature/Element/DeletionBlockersTest.php +++ b/tests/Feature/Element/DeletionBlockersTest.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\DeletionBlockers\BaseDeletionBlocker; use CraftCms\Cms\Element\DeletionBlockers\EntryAuthorsBlocker; +use CraftCms\Cms\Element\DeletionBlockers\FieldReferencesDeletionBlocker; use CraftCms\Cms\Element\DeletionBlockers\RelationDeletionBlocker; use CraftCms\Cms\Element\ElementCollection; use CraftCms\Cms\Element\Events\DefineDeletionBlockers; @@ -37,17 +38,25 @@ expect($event->elementType)->toBe(Entry::class) ->and($event->elements->ids()->all())->toBe([$entry->id]) ->and($event->hardDelete)->toBeTrue() - ->and($event->blockers)->toHaveCount(1) - ->and($event->blockers[0])->toBeInstanceOf(RelationDeletionBlocker::class); + ->and($event->blockers)->toHaveCount(2); + + $blockerClasses = collect($event->blockers)->map(fn (object $blocker) => $blocker::class); + + expect($blockerClasses) + ->toContain(RelationDeletionBlocker::class) + ->toContain(FieldReferencesDeletionBlocker::class); $event->blockers[] = new TestDeletionBlocker(active: true); }); $blockers = Entry::deletionBlockers(ElementCollection::make([$entry]), true); + $blockerClasses = collect($blockers)->map(fn (object $blocker) => $blocker::class); - expect($blockers)->toHaveCount(2) - ->and($blockers[0])->toBeInstanceOf(RelationDeletionBlocker::class) - ->and($blockers[1])->toBeInstanceOf(TestDeletionBlocker::class); + expect($blockers)->toHaveCount(3) + ->and($blockerClasses) + ->toContain(RelationDeletionBlocker::class) + ->toContain(FieldReferencesDeletionBlocker::class) + ->toContain(TestDeletionBlocker::class); }); it('reports entry author blockers with details and actions', function () { diff --git a/tests/Feature/Element/Jobs/ReplaceReferencesTest.php b/tests/Feature/Element/Jobs/ReplaceReferencesTest.php new file mode 100644 index 00000000000..7b8d8b2bdb8 --- /dev/null +++ b/tests/Feature/Element/Jobs/ReplaceReferencesTest.php @@ -0,0 +1,201 @@ +withField('body', MarkdownField::class, value: $value) + ->createElementWithFields(); +} + +function replaceReferencesFieldInstanceUid(ElementInterface $element): string +{ + return $element->getFieldLayout()->getFieldByHandle('body')->layoutElement->uid; +} + +it('queries the configured source ids for a source site', function () { + $excluded = EntryModel::factory()->createElement(); + $included = EntryModel::factory(2)->create()->pluck('id')->all(); + + $job = new TestReplaceReferences( + sourceElementType: EntryElement::class, + targetElementType: EntryElement::class, + sourceSiteId: 1, + refs: [ + ['fieldInstanceUid' => fake()->uuid(), 'sourceId' => $included[0]], + ['fieldInstanceUid' => fake()->uuid(), 'sourceId' => $included[1]], + ['fieldInstanceUid' => fake()->uuid(), 'sourceId' => $included[1]], + ], + oldTargetIds: [$excluded->id], + newTargetId: $included[0], + ); + + $ids = $job->replacementQuery() + ->collect() + ->pluck('id') + ->all(); + + expect($ids)->toBe($included) + ->not->toContain($excluded->id); +}); + +it('replaces markdown references while preserving tag syntax', function () { + $oldTargets = EntryModel::factory(2)->create()->pluck('id')->all(); + $newTarget = EntryModel::factory()->createElement(); + $result = replaceReferencesMarkdownFixture("{entry:{$oldTargets[0]}@1:url} {entry:{$oldTargets[1]}:title || Fallback} {entry:{$oldTargets[0]}.5:url} {entry:news/post:url}"); + $source = $result->element; + + $job = new TestReplaceReferences( + sourceElementType: EntryElement::class, + targetElementType: EntryElement::class, + sourceSiteId: $source->siteId, + refs: [ + [ + 'fieldInstanceUid' => replaceReferencesFieldInstanceUid($source), + 'sourceId' => $source->id, + ], + ], + oldTargetIds: $oldTargets, + newTargetId: $newTarget->id, + ); + + $job->process($source); + + expect($source->getFieldValue('body')->getRaw()) + ->toBe("{entry:$newTarget->id@1:url} {entry:$newTarget->id:title || Fallback} {entry:{$oldTargets[0]}.5:url} {entry:news/post:url}"); +}); + +it('refreshes tracked reference rows after saving changed content', function () { + $oldTarget = EntryModel::factory()->createElement(); + $newTarget = EntryModel::factory()->createElement(); + $result = replaceReferencesMarkdownFixture("{entry:$oldTarget->id:url}"); + $source = $result->element; + + $job = new TestReplaceReferences( + sourceElementType: EntryElement::class, + targetElementType: EntryElement::class, + sourceSiteId: $source->siteId, + refs: [ + [ + 'fieldInstanceUid' => replaceReferencesFieldInstanceUid($source), + 'sourceId' => $source->id, + ], + ], + oldTargetIds: [$oldTarget->id], + newTargetId: $newTarget->id, + ); + + $job->handle(); + + expect(DB::table(Table::FIELDREFERENCES)->pluck('targetId')->all())->toBe([$newTarget->id]); +}); + +it('does nothing when the source element has no matching refs', function () { + $oldTarget = EntryModel::factory()->createElement(); + $newTarget = EntryModel::factory()->createElement(); + $result = replaceReferencesMarkdownFixture("{entry:$oldTarget->id:url}"); + $source = $result->element; + + $job = new TestReplaceReferences( + sourceElementType: EntryElement::class, + targetElementType: EntryElement::class, + sourceSiteId: $source->siteId, + refs: [ + [ + 'fieldInstanceUid' => replaceReferencesFieldInstanceUid($source), + 'sourceId' => $source->id + 1, + ], + ], + oldTargetIds: [$oldTarget->id], + newTargetId: $newTarget->id, + ); + + $job->process($source); + + expect($source->getFieldValue('body')->getRaw())->toBe("{entry:$oldTarget->id:url}"); +}); + +it('does nothing when the field instance no longer exists', function () { + $oldTarget = EntryModel::factory()->createElement(); + $newTarget = EntryModel::factory()->createElement(); + $result = replaceReferencesMarkdownFixture("{entry:$oldTarget->id:url}"); + $source = $result->element; + + $job = new TestReplaceReferences( + sourceElementType: EntryElement::class, + targetElementType: EntryElement::class, + sourceSiteId: $source->siteId, + refs: [ + [ + 'fieldInstanceUid' => fake()->uuid(), + 'sourceId' => $source->id, + ], + ], + oldTargetIds: [$oldTarget->id], + newTargetId: $newTarget->id, + ); + + $job->process($source); + + expect($source->getFieldValue('body')->getRaw())->toBe("{entry:$oldTarget->id:url}"); +}); + +it('continues when saving a changed element fails', function () { + $oldTarget = EntryModel::factory()->createElement(); + $newTarget = EntryModel::factory()->createElement(); + $result = replaceReferencesMarkdownFixture("{entry:$oldTarget->id:url}"); + $source = $result->element; + + Elements::shouldReceive('saveElement') + ->once() + ->andThrow(new RuntimeException('Save failed')); + + $job = new TestReplaceReferences( + sourceElementType: EntryElement::class, + targetElementType: EntryElement::class, + sourceSiteId: $source->siteId, + refs: [ + [ + 'fieldInstanceUid' => replaceReferencesFieldInstanceUid($source), + 'sourceId' => $source->id, + ], + ], + oldTargetIds: [$oldTarget->id], + newTargetId: $newTarget->id, + ); + + $job->process($source); + + expect($source->getFieldValue('body')->getRaw())->toBe("{entry:$newTarget->id:url}"); +}); + +class TestReplaceReferences extends ReplaceReferences +{ + public function replacementQuery(): Builder + { + return $this->getQuery(); + } + + public function process(ElementInterface $element): void + { + $this->processElement($element); + } +} diff --git a/tests/Feature/Field/FieldReferencesTest.php b/tests/Feature/Field/FieldReferencesTest.php new file mode 100644 index 00000000000..dbd93fa27db --- /dev/null +++ b/tests/Feature/Field/FieldReferencesTest.php @@ -0,0 +1,205 @@ +select(['fieldId', 'fieldInstanceUid', 'sourceId', 'sourceSiteId', 'targetId']) + ->orderBy('sourceId') + ->orderBy('sourceSiteId') + ->orderBy('fieldId') + ->orderBy('fieldInstanceUid') + ->orderBy('targetId') + ->get() + ->map(fn (object $row) => (array) $row) + ->all(); +} + +function markdownReferenceFixture(string $value) +{ + return EntryModel::factory() + ->withField('body', MarkdownField::class, value: $value) + ->createElementWithFields(); +} + +it('tracks valid numeric markdown reference tags', function () { + $target = EntryModel::factory()->createElement(); + + $result = markdownReferenceFixture("Valid {entry:$target->id:url}\nSlug {entry:news/post:url}\nDecimal {entry:{$target->id}.5:url}\nWrong {asset:$target->id:url}\nMissing {entry:999999:url}"); + $field = $result->field('body'); + $source = $result->element; + + expect(fieldReferenceRows())->toMatchArray([ + [ + 'fieldId' => $field->id, + 'fieldInstanceUid' => $source->getFieldLayout()->getFieldByHandle('body')->layoutElement->uid, + 'sourceId' => $source->id, + 'sourceSiteId' => $source->siteId, + 'targetId' => $target->id, + ], + ]); +}); + +it('updates and removes markdown reference rows when content changes', function () { + $oldTarget = EntryModel::factory()->createElement(); + $newTarget = EntryModel::factory()->createElement(); + $result = markdownReferenceFixture("{entry:$oldTarget->id:url}"); + $source = $result->element; + + $source->setFieldValue('body', "{entry:$newTarget->id:url}"); + Elements::saveElement($source); + + expect(DB::table(Table::FIELDREFERENCES)->pluck('targetId')->all())->toBe([$newTarget->id]); + + $source->setFieldValue('body', 'No references'); + Elements::saveElement($source); + + expect(DB::table(Table::FIELDREFERENCES)->count())->toBe(0); +}); + +it('tracks drafts but not revisions', function () { + $target = EntryModel::factory()->createElement(); + $result = markdownReferenceFixture('No references'); + $source = $result->element; + + $draft = app(Drafts::class)->createDraft($source, User::findOne()->id); + $draft->setFieldValue('body', "{entry:$target->id:url}"); + Elements::saveElement($draft); + + $source->setFieldValue('body', "{entry:$target->id:url}"); + Elements::saveElement($source); + $revisionId = app(Revisions::class)->createRevision($source, force: true); + + expect(DB::table(Table::FIELDREFERENCES)->where('sourceId', $draft->id)->exists())->toBeTrue() + ->and(DB::table(Table::FIELDREFERENCES)->where('sourceId', $revisionId)->exists())->toBeFalse(); +}); + +it('ignores soft-deleted source elements in blockers', function () { + $target = EntryModel::factory()->createElement(); + $result = markdownReferenceFixture("{entry:$target->id:url}"); + + expect(new FieldReferencesDeletionBlocker(ElementCollection::make([$target]), false)->isActive())->toBeTrue(); + + Elements::deleteElement($result->element); + + expect(DB::table(Table::FIELDREFERENCES)->count())->toBe(1) + ->and(new FieldReferencesDeletionBlocker(ElementCollection::make([$target]), false)->isActive())->toBeFalse(); +}); + +it('cleans references when tracking fields are deleted', function () { + $target = EntryModel::factory()->createElement(); + $field = Fields::createField([ + 'type' => MarkdownField::class, + 'name' => 'Body', + 'handle' => 'body', + ]); + + Fields::saveField($field); + + $source = EntryModel::factory() + ->withFieldLayout(FieldLayout::factory()->forField($field)) + ->createElement(); + $source->setFieldValue('body', "{entry:$target->id:url}"); + Elements::saveElement($source); + + expect(DB::table(Table::FIELDREFERENCES)->count())->toBe(1); + + Fields::deleteField($field); + + expect(DB::table(Table::FIELDREFERENCES)->count())->toBe(0); +}); + +it('cleans references when tracking fields are converted to non-tracking fields', function () { + $target = EntryModel::factory()->createElement(); + $field = Fields::createField([ + 'type' => MarkdownField::class, + 'name' => 'Body', + 'handle' => 'body', + ]); + + Fields::saveField($field); + + $source = EntryModel::factory() + ->withFieldLayout(FieldLayout::factory()->forField($field)) + ->createElement(); + $source->setFieldValue('body', "{entry:$target->id:url}"); + Elements::saveElement($source); + + expect(DB::table(Table::FIELDREFERENCES)->count())->toBe(1); + + Fields::saveField(Fields::createField([ + 'id' => $field->id, + 'uid' => $field->uid, + 'type' => PlainText::class, + 'name' => 'Body', + 'handle' => 'body', + ])); + + expect(DB::table(Table::FIELDREFERENCES)->count())->toBe(0); +}); + +it('cleans references when field layout instances are removed', function () { + $target = EntryModel::factory()->createElement(); + $result = markdownReferenceFixture("{entry:$target->id:url}"); + $layout = $result->element->getFieldLayout(); + + expect(DB::table(Table::FIELDREFERENCES)->count())->toBe(1); + + $layout->setTabs([]); + Fields::saveLayout($layout); + + expect(DB::table(Table::FIELDREFERENCES)->count())->toBe(0); +}); + +it('queues reference replacement from the delete elements controller', function () { + Queue::fake(); + + $oldTarget = EntryModel::factory()->createElement(); + $newTarget = EntryModel::factory()->createElement(); + $result = markdownReferenceFixture("{entry:$oldTarget->id:url}"); + $source = $result->element; + + postJson(action([DeleteElementsController::class, 'replaceReferences']), [ + 'elementType' => EntryElement::class, + 'elementIds' => [$oldTarget->id], + 'newTargetId' => $newTarget->id, + ]) + ->assertOk() + ->assertJsonPath('message', 'Reference queued to be replaced.'); + + Queue::assertPushed(ReplaceReferences::class, fn (ReplaceReferences $job) => $job->sourceElementType === EntryElement::class && + $job->targetElementType === EntryElement::class && + $job->sourceSiteId === $source->siteId && + $job->refs === [[ + 'fieldInstanceUid' => $source->getFieldLayout()->getFieldByHandle('body')->layoutElement->uid, + 'sourceId' => $source->id, + ]] && + $job->oldTargetIds === [$oldTarget->id] && + $job->newTargetId === $newTarget->id); +}); diff --git a/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php b/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php index 2d60a0cc6b5..8e3fb81b885 100644 --- a/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php @@ -240,6 +240,7 @@ public function deleteElement(ElementInterface $element, bool $hard = false): bo expect($response->json('content'))->toContain( 'elementType', + 'elementIds', 'hardDelete', 'sourceElementType', 'delete-elements/replace-relations', @@ -247,6 +248,27 @@ public function deleteElement(ElementInterface $element, bool $hard = false): bo }); }); +describe('replaceReferencesModal', function () { + it('returns modal content with the selected target ids', function () { + $entry = EntryModel::factory()->createElement(); + + $response = post(action([DeleteElementsController::class, 'replaceReferencesModal']), [ + 'elementType' => Entry::class, + 'elementIds' => [$entry->id], + 'hardDelete' => true, + ])->assertOk() + ->assertJsonPath('action', 'delete-elements/replace-references') + ->assertJsonPath('submitButtonLabel', 'Replace'); + + expect($response->json('content'))->toContain( + 'elementType', + 'elementIds', + 'hardDelete', + 'delete-elements/replace-references', + ); + }); +}); + describe('replaceRelations', function () { it('requires a source element type and new target id', function () { $entry = EntryModel::factory()->createElement(); @@ -321,6 +343,19 @@ public function deleteElement(ElementInterface $element, bool $hard = false): bo }); }); +describe('replaceReferences', function () { + it('returns failure when the replacement target does not exist', function () { + $entry = EntryModel::factory()->createElement(); + + postJson(action([DeleteElementsController::class, 'replaceReferences']), [ + 'elementType' => Entry::class, + 'elementIds' => [$entry->id], + 'newTargetId' => 999999, + ])->assertStatus(400) + ->assertJsonPath('message', 'The selected entry could not be found.'); + }); +}); + function createDeleteElementsControllerNestedElementFixture(): array { $field = Field::factory()->create([ From 3f21ac500e1a8d6134fd4b837486df499ed09731 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 1 Jun 2026 20:05:56 +0200 Subject: [PATCH 2/2] Add support for slug/ref element refs, not just numeric ids --- src/Element/Operations/ElementRefs.php | 146 +++++++++++++-- src/Field/FieldReferences.php | 80 +------- .../Element/Jobs/ReplaceReferencesTest.php | 20 +- tests/Feature/Field/FieldReferencesTest.php | 13 +- .../Element/ElementRefs/ParseRefsTest.php | 176 +++++++++++------- 5 files changed, 263 insertions(+), 172 deletions(-) diff --git a/src/Element/Operations/ElementRefs.php b/src/Element/Operations/ElementRefs.php index 463e3adff00..023036fd94b 100644 --- a/src/Element/Operations/ElementRefs.php +++ b/src/Element/Operations/ElementRefs.php @@ -46,26 +46,10 @@ function (array $matches) use ($defaultSiteId, &$allRefTagTokens) { return $fallback; } - if (! empty($siteId)) { - if (is_numeric($siteId)) { - $siteId = (int) $siteId; - } else { - try { - $site = Str::isUuid($siteId) - ? $this->sites->getSiteByUid($siteId) - : $this->sites->getSiteByHandle($siteId); - } catch (SiteNotFoundException) { - $site = null; - } - - if (! $site) { - return $fallback; - } + $siteId = $this->siteIdForReference($siteId, $defaultSiteId); - $siteId = $site->id; - } - } else { - $siteId = $defaultSiteId; + if ($siteId === false) { + return $fallback; } $refType = is_numeric($ref) ? 'id' : 'ref'; @@ -125,6 +109,130 @@ function (array $matches) use ($defaultSiteId, &$allRefTagTokens) { return str_replace($search, $replace, $str); } + /** + * @return int[] + */ + public function targetIds(string $str, ?int $defaultSiteId = null): array + { + if (! str_contains($str, '{')) { + return []; + } + + preg_match_all(Elements::REF_TAG_PATTERN, $str, $matches, PREG_SET_ORDER); + + $targetIds = []; + $resolvedRefs = []; + + foreach ($matches as $match) { + $targetId = $this->targetIdForRefTag($match, $defaultSiteId, $resolvedRefs); + + if ($targetId !== null) { + $targetIds[$targetId] = true; + } + } + + return array_keys($targetIds); + } + + /** + * @param int[] $oldTargetIds + */ + public function replaceTargetRefs(string $str, array $oldTargetIds, int $newTargetId, ?int $defaultSiteId = null): string + { + $oldTargetIds = array_flip(array_map(intval(...), $oldTargetIds)); + + return preg_replace_callback(Elements::REF_TAG_PATTERN, function (array $matches) use ($oldTargetIds, $newTargetId, $defaultSiteId) { + $targetId = $this->targetIdForRefTag($matches, $defaultSiteId); + + if ($targetId === null || ! isset($oldTargetIds[$targetId])) { + return $matches[0]; + } + + return substr_replace( + $matches[0], + (string) $newTargetId, + strlen($matches['elementType']) + 2, + strlen($matches['ref']), + ); + }, $str) ?? $str; + } + + private function targetIdForRefTag(array $matches, ?int $defaultSiteId, array &$resolvedRefs = []): ?int + { + $elementType = $this->elements->getElementTypeByRefHandle($matches['elementType']); + + if ($elementType === null) { + return null; + } + + $siteId = $this->siteIdForReference($matches['site'] ?? null, $defaultSiteId); + + if ($siteId === false) { + return null; + } + + $ref = $matches['ref']; + + if (! ctype_digit((string) $ref)) { + $cacheKey = sprintf('%s:%s:%s', $elementType, $siteId ?? '*', $ref); + + if (isset($resolvedRefs[$cacheKey])) { + return $resolvedRefs[$cacheKey]; + } + } + + $query = $this->elements->createElementQuery($elementType) + ->siteId($siteId) + ->status(null); + + if (ctype_digit((string) $ref)) { + return ($query->id((int) $ref)->all()[0] ?? null)?->id; + } + + if (method_exists($query, 'ref')) { + $query->ref($ref); + } + + foreach ($query->all() as $element) { + $elementRef = $element->getRef(); + + if ($elementRef === null) { + continue; + } + + if ($elementRef === $ref) { + return $resolvedRefs[$cacheKey] = $element->id; + } + + if (($slash = strrpos($elementRef, '/')) !== false && substr($elementRef, $slash + 1) === $ref) { + return $resolvedRefs[$cacheKey] = $element->id; + } + } + + return null; + } + + private function siteIdForReference(?string $site, ?int $defaultSiteId): int|false|null + { + if ($site === null || $site === '') { + return $defaultSiteId; + } + + if (is_numeric($site)) { + return (int) $site; + } + + try { + $site = Str::isUuid($site) + ? $this->sites->getSiteByUid($site) + : $this->sites->getSiteByHandle($site); + } catch (SiteNotFoundException) { + return false; + } + + return $site === null ? false : $site->id; + } + private function getRefTokenReplacement( ?ElementInterface $element, ?string $attribute, diff --git a/src/Field/FieldReferences.php b/src/Field/FieldReferences.php index 3b3a832394d..252eea203f0 100644 --- a/src/Field/FieldReferences.php +++ b/src/Field/FieldReferences.php @@ -6,7 +6,7 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\Element\Elements; +use CraftCms\Cms\Element\Operations\ElementRefs; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Field\Contracts\TracksReferencesFieldInterface; use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; @@ -20,7 +20,7 @@ readonly class FieldReferences { public function __construct( - private Elements $elements, + private ElementRefs $elementRefs, ) {} public function updateReferences(TracksReferencesFieldInterface $field, ElementInterface $element): void @@ -29,10 +29,8 @@ public function updateReferences(TracksReferencesFieldInterface $field, ElementI return; } - $targetIds = $this->targetIdsInValue($field->serializeValue( - $element->getFieldValue($field->handle), - $element, - )); + $value = $field->serializeValue($element->getFieldValue($field->handle), $element); + $targetIds = is_string($value) ? $this->elementRefs->targetIds($value, $element->siteId) : []; $sourceSiteId = $element->siteId; DB::transaction(function () use ($field, $element, $sourceSiteId, $targetIds) { @@ -108,7 +106,7 @@ public function replaceReferences(TracksReferencesFieldInterface $field, Element return false; } - $newValue = $this->valueWithReplacedTargetIds($value, $oldTargetIds, $newTargetId); + $newValue = $this->elementRefs->replaceTargetRefs($value, $oldTargetIds, $newTargetId, $element->siteId); if ($newValue === $value) { return false; @@ -189,74 +187,6 @@ private function referencesToTargetsQuery(array $targetIds): Builder ->whereNull('e.revisionId'); } - private function targetIdsInValue(mixed $value): array - { - if (! is_string($value) || ! str_contains($value, '{')) { - return []; - } - - preg_match_all(Elements::REF_TAG_PATTERN, $value, $matches, PREG_SET_ORDER); - - if ($matches === []) { - return []; - } - - $idsByType = []; - - foreach ($matches as $match) { - if (! ctype_digit($match['ref'])) { - continue; - } - - $elementType = $this->elements->getElementTypeByRefHandle($match['elementType']); - - if ($elementType === null) { - continue; - } - - $idsByType[$elementType][] = (int) $match['ref']; - } - - if ($idsByType === []) { - return []; - } - - $validIds = []; - - foreach ($idsByType as $elementType => $ids) { - DB::table(Table::ELEMENTS) - ->whereIn('id', array_unique($ids)) - ->where('type', $elementType) - ->pluck('id') - ->each(function ($id) use (&$validIds) { - $validIds[(int) $id] = true; - }); - } - - return array_keys($validIds); - } - - /** - * @param int[] $oldTargetIds - */ - private function valueWithReplacedTargetIds(string $value, array $oldTargetIds, int $newTargetId): string - { - $oldTargetIds = array_flip(array_map(intval(...), $oldTargetIds)); - - return preg_replace_callback(Elements::REF_TAG_PATTERN, function (array $matches) use ($oldTargetIds, $newTargetId) { - if (! ctype_digit($matches['ref']) || ! isset($oldTargetIds[(int) $matches['ref']])) { - return $matches[0]; - } - - return substr_replace( - $matches[0], - (string) $newTargetId, - strlen($matches['elementType']) + 2, - strlen($matches['ref']), - ); - }, $value) ?? $value; - } - private function customFieldUidsInConfig(?array $config): array { if ($config === null) { diff --git a/tests/Feature/Element/Jobs/ReplaceReferencesTest.php b/tests/Feature/Element/Jobs/ReplaceReferencesTest.php index 7b8d8b2bdb8..cba9ae6d607 100644 --- a/tests/Feature/Element/Jobs/ReplaceReferencesTest.php +++ b/tests/Feature/Element/Jobs/ReplaceReferencesTest.php @@ -4,6 +4,7 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Element\Elements as ElementsService; use CraftCms\Cms\Element\Jobs\ReplaceReferences; use CraftCms\Cms\Entry\Elements\Entry as EntryElement; use CraftCms\Cms\Entry\Models\Entry as EntryModel; @@ -59,8 +60,10 @@ function replaceReferencesFieldInstanceUid(ElementInterface $element): string it('replaces markdown references while preserving tag syntax', function () { $oldTargets = EntryModel::factory(2)->create()->pluck('id')->all(); + $slugTarget = EntryModel::factory()->createElement(['slug' => 'slug-target']); + $slugRef = $slugTarget->getSection()->handle.'/'.$slugTarget->slug; $newTarget = EntryModel::factory()->createElement(); - $result = replaceReferencesMarkdownFixture("{entry:{$oldTargets[0]}@1:url} {entry:{$oldTargets[1]}:title || Fallback} {entry:{$oldTargets[0]}.5:url} {entry:news/post:url}"); + $result = replaceReferencesMarkdownFixture("{entry:{$oldTargets[0]}@1:url} {entry:{$oldTargets[1]}:title || Fallback} {entry:$slugRef:uri} {entry:{$oldTargets[0]}.5:url} {entry:news/post:url}"); $source = $result->element; $job = new TestReplaceReferences( @@ -73,14 +76,14 @@ function replaceReferencesFieldInstanceUid(ElementInterface $element): string 'sourceId' => $source->id, ], ], - oldTargetIds: $oldTargets, + oldTargetIds: [...$oldTargets, $slugTarget->id], newTargetId: $newTarget->id, ); $job->process($source); expect($source->getFieldValue('body')->getRaw()) - ->toBe("{entry:$newTarget->id@1:url} {entry:$newTarget->id:title || Fallback} {entry:{$oldTargets[0]}.5:url} {entry:news/post:url}"); + ->toBe("{entry:$newTarget->id@1:url} {entry:$newTarget->id:title || Fallback} {entry:$newTarget->id:uri} {entry:{$oldTargets[0]}.5:url} {entry:news/post:url}"); }); it('refreshes tracked reference rows after saving changed content', function () { @@ -164,9 +167,12 @@ function replaceReferencesFieldInstanceUid(ElementInterface $element): string $result = replaceReferencesMarkdownFixture("{entry:$oldTarget->id:url}"); $source = $result->element; - Elements::shouldReceive('saveElement') + $realElements = app(ElementsService::class); + $elements = Mockery::mock($realElements)->makePartial(); + $elements->shouldReceive('saveElement') ->once() ->andThrow(new RuntimeException('Save failed')); + Elements::swap($elements); $job = new TestReplaceReferences( sourceElementType: EntryElement::class, @@ -182,7 +188,11 @@ function replaceReferencesFieldInstanceUid(ElementInterface $element): string newTargetId: $newTarget->id, ); - $job->process($source); + try { + $job->process($source); + } finally { + Elements::swap($realElements); + } expect($source->getFieldValue('body')->getRaw())->toBe("{entry:$newTarget->id:url}"); }); diff --git a/tests/Feature/Field/FieldReferencesTest.php b/tests/Feature/Field/FieldReferencesTest.php index dbd93fa27db..9768bff265d 100644 --- a/tests/Feature/Field/FieldReferencesTest.php +++ b/tests/Feature/Field/FieldReferencesTest.php @@ -48,10 +48,12 @@ function markdownReferenceFixture(string $value) ->createElementWithFields(); } -it('tracks valid numeric markdown reference tags', function () { +it('tracks valid numeric and named markdown reference tags', function () { $target = EntryModel::factory()->createElement(); + $slugTarget = EntryModel::factory()->createElement(['slug' => 'slug-target']); + $slugRef = $slugTarget->getSection()->handle.'/'.$slugTarget->slug; - $result = markdownReferenceFixture("Valid {entry:$target->id:url}\nSlug {entry:news/post:url}\nDecimal {entry:{$target->id}.5:url}\nWrong {asset:$target->id:url}\nMissing {entry:999999:url}"); + $result = markdownReferenceFixture("Valid {entry:$target->id:url}\nSlug {entry:$slugRef:url}\nDecimal {entry:{$target->id}.5:url}\nWrong {asset:$target->id:url}\nMissing {entry:999999:url}"); $field = $result->field('body'); $source = $result->element; @@ -63,6 +65,13 @@ function markdownReferenceFixture(string $value) 'sourceSiteId' => $source->siteId, 'targetId' => $target->id, ], + [ + 'fieldId' => $field->id, + 'fieldInstanceUid' => $source->getFieldLayout()->getFieldByHandle('body')->layoutElement->uid, + 'sourceId' => $source->id, + 'sourceSiteId' => $source->siteId, + 'targetId' => $slugTarget->id, + ], ]); }); diff --git a/tests/Unit/Element/ElementRefs/ParseRefsTest.php b/tests/Unit/Element/ElementRefs/ParseRefsTest.php index ee39b85b61f..44f76e5936a 100644 --- a/tests/Unit/Element/ElementRefs/ParseRefsTest.php +++ b/tests/Unit/Element/ElementRefs/ParseRefsTest.php @@ -134,6 +134,22 @@ function createParseRefsElement( return $element; } +function expectParseRefsElementType(int $times = 1): void +{ + test()->elements->expects(test()->exactly($times)) + ->method('getElementTypeByRefHandle') + ->with('test') + ->willReturn(TestParseRefsElement::class); +} + +function expectParseRefsQueries(TestParseRefsQuery ...$queries): void +{ + test()->elements->expects(test()->exactly(count($queries))) + ->method('createElementQuery') + ->with(TestParseRefsElement::class) + ->willReturnOnConsecutiveCalls(...$queries); +} + it('returns the original string when it does not contain reference syntax', function () { $this->elements->expects(test()->never()) ->method('createElementQuery'); @@ -168,10 +184,7 @@ function createParseRefsElement( }); it('uses the original tag as the fallback when a site handle cannot be resolved', function () { - $this->elements->expects(test()->once()) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); + expectParseRefsElementType(); $this->sites->expects(test()->once()) ->method('getSiteByHandle') @@ -190,15 +203,8 @@ function createParseRefsElement( createParseRefsElement(id: 1, url: 'https://example.test/id-1'), ]); - $this->elements->expects(test()->once()) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); - - $this->elements->expects(test()->once()) - ->method('createElementQuery') - ->with(TestParseRefsElement::class) - ->willReturn($query); + expectParseRefsElementType(); + expectParseRefsQueries($query); expect($this->action->parseRefs('Link: {test:1}', 7)) ->toBe('Link: https://example.test/id-1') @@ -212,11 +218,6 @@ function createParseRefsElement( createParseRefsElement(ref: 'entry', url: 'https://example.test/handle-site'), ]); - $this->elements->expects(test()->once()) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); - $this->sites->expects(test()->once()) ->method('getSiteByHandle') ->with('secondary') @@ -227,10 +228,8 @@ function createParseRefsElement( $site->language = 'en-US'; })); - $this->elements->expects(test()->once()) - ->method('createElementQuery') - ->with(TestParseRefsElement::class) - ->willReturn($query); + expectParseRefsElementType(); + expectParseRefsQueries($query); expect($this->action->parseRefs('{test:entry@secondary}')) ->toBe('https://example.test/handle-site') @@ -245,21 +244,14 @@ function createParseRefsElement( createParseRefsElement(ref: 'entry', url: 'https://example.test/numeric-site'), ]); - $this->elements->expects(test()->once()) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); - $this->sites->expects(test()->never()) ->method('getSiteByHandle'); $this->sites->expects(test()->never()) ->method('getSiteByUid'); - $this->elements->expects(test()->once()) - ->method('createElementQuery') - ->with(TestParseRefsElement::class) - ->willReturn($query); + expectParseRefsElementType(); + expectParseRefsQueries($query); expect($this->action->parseRefs('{test:entry@4}')) ->toBe('https://example.test/numeric-site') @@ -271,10 +263,7 @@ function createParseRefsElement( it('falls back when resolving a site uid throws an exception', function () { $uuid = '550e8400-e29b-41d4-a716-446655440000'; - $this->elements->expects(test()->once()) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); + expectParseRefsElementType(); $this->sites->expects(test()->once()) ->method('getSiteByUid') @@ -293,15 +282,8 @@ function createParseRefsElement( createParseRefsElement(ref: 'section/slug', url: 'https://example.test/slug-match'), ]); - $this->elements->expects(test()->once()) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); - - $this->elements->expects(test()->once()) - ->method('createElementQuery') - ->with(TestParseRefsElement::class) - ->willReturn($query); + expectParseRefsElementType(); + expectParseRefsQueries($query); expect($this->action->parseRefs('{test:slug:summary}')) ->toBe('https://example.test/slug-match') @@ -319,15 +301,8 @@ function createParseRefsElement( createParseRefsElement(ref: 'nested-ref', title: 'Nested title'), ]); - $this->elements->expects(test()->exactly(2)) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); - - $this->elements->expects(test()->exactly(2)) - ->method('createElementQuery') - ->with(TestParseRefsElement::class) - ->willReturnOnConsecutiveCalls($primaryQuery, $nestedQuery); + expectParseRefsElementType(2); + expectParseRefsQueries($primaryQuery, $nestedQuery); expect($this->action->parseRefs('Start {test:primary-ref:body} end')) ->toBe('Start See Nested title end') @@ -338,15 +313,8 @@ function createParseRefsElement( it('uses the fallback when no matching element is found', function () { $query = new TestParseRefsRefQuery([]); - $this->elements->expects(test()->once()) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); - - $this->elements->expects(test()->once()) - ->method('createElementQuery') - ->with(TestParseRefsElement::class) - ->willReturn($query); + expectParseRefsElementType(); + expectParseRefsQueries($query); expect($this->action->parseRefs('{test:missing||fallback}')) ->toBe('fallback') @@ -364,17 +332,83 @@ function createParseRefsElement( && str_contains($message, 'could not be converted to string') && $context === ['CraftCms\\Cms\\Element\\Operations\\ElementRefs::getRefTokenReplacement']); - $this->elements->expects(test()->once()) - ->method('getElementTypeByRefHandle') - ->with('test') - ->willReturn(TestParseRefsElement::class); - - $this->elements->expects(test()->once()) - ->method('createElementQuery') - ->with(TestParseRefsElement::class) - ->willReturn($query); + expectParseRefsElementType(); + expectParseRefsQueries($query); expect($this->action->parseRefs('{test:error:problem || fallback}')) ->toBe('fallback') ->and($query->recordedRef)->toBe(['error']); }); + +it('returns target ids for numeric id refs', function () { + $matchingQuery = new TestParseRefsQuery([ + createParseRefsElement(id: 3), + ]); + $missingQuery = new TestParseRefsQuery([]); + + expectParseRefsElementType(2); + expectParseRefsQueries($matchingQuery, $missingQuery); + + expect($this->action->targetIds('{test:3:url} {test:999}', 7)) + ->toBe([3]) + ->and($matchingQuery->recordedSiteId)->toBe(7) + ->and($matchingQuery->recordedStatus)->toBeNull() + ->and($matchingQuery->recordedId)->toBe(3) + ->and($missingQuery->recordedSiteId)->toBe(7) + ->and($missingQuery->recordedStatus)->toBeNull() + ->and($missingQuery->recordedId)->toBe(999); +}); + +it('returns target ids for named refs and suffix refs', function () { + $suffixQuery = new TestParseRefsRefQuery([createParseRefsElement(id: 4, ref: 'section/slug')]); + $directQuery = new TestParseRefsRefQuery([createParseRefsElement(id: 5, ref: 'direct-ref')]); + $missingQuery = new TestParseRefsRefQuery([]); + + expectParseRefsElementType(3); + expectParseRefsQueries($suffixQuery, $directQuery, $missingQuery); + + expect($this->action->targetIds('{test:slug} {test:direct-ref} {test:missing}')) + ->toBe([4, 5]) + ->and($suffixQuery->recordedSiteId)->toBeNull() + ->and($suffixQuery->recordedStatus)->toBeNull() + ->and($suffixQuery->recordedRef)->toBe('slug') + ->and($directQuery->recordedSiteId)->toBeNull() + ->and($directQuery->recordedStatus)->toBeNull() + ->and($directQuery->recordedRef)->toBe('direct-ref') + ->and($missingQuery->recordedSiteId)->toBeNull() + ->and($missingQuery->recordedStatus)->toBeNull() + ->and($missingQuery->recordedRef)->toBe('missing'); +}); + +it('returns no target ids when the site cannot be resolved', function () { + expectParseRefsElementType(); + + $this->sites->expects(test()->once()) + ->method('getSiteByHandle') + ->with('missing') + ->willReturn(null); + + $this->elements->expects(test()->never()) + ->method('createElementQuery'); + + expect($this->action->targetIds('{test:entry@missing}'))->toBe([]); +}); + +it('replaces numeric and named refs that resolve to old targets', function () { + $numericQuery = new TestParseRefsQuery([createParseRefsElement(id: 1)]); + $matchingRefQuery = new TestParseRefsRefQuery([createParseRefsElement(id: 4, ref: 'section/slug')]); + $keptRefQuery = new TestParseRefsRefQuery([createParseRefsElement(id: 5, ref: 'kept-ref')]); + + expectParseRefsElementType(3); + expectParseRefsQueries($numericQuery, $matchingRefQuery, $keptRefQuery); + + expect($this->action->replaceTargetRefs( + '{test:1@2:url} {test:section/slug:title || fallback} {test:kept-ref}', + [1, 4], + 9, + ))->toBe('{test:9@2:url} {test:9:title || fallback} {test:kept-ref}') + ->and($numericQuery->recordedSiteId)->toBe(2) + ->and($numericQuery->recordedId)->toBe(1) + ->and($matchingRefQuery->recordedRef)->toBe('section/slug') + ->and($keptRefQuery->recordedRef)->toBe('kept-ref'); +});