craftcms / cms

Build bespoke content experiences with Craft.
https://craftcms.com
Other
3.22k stars 626 forks source link

[4.x]: Unable to Save Entry to Multiple Sites in Craft CMS (>=4.11.0) #15614

Closed sqp-alpaca closed 3 weeks ago

sqp-alpaca commented 3 weeks ago

What happened?

Description

I am experiencing an issue with the propagation method "Let each entry choose which sites it should be saved to" in a section of my Craft CMS setup. Previously, I implemented a feature in the Control Panel that allows users to save an entry across all sites by clicking a button. This is achieved by running a job in the ProjectExtention that saves the entry to each site. Below is the code I use:

public function execute($queue): void {
    $entryId = Craft::$app->cache->get("activate-all-entry-sites");
    $entry = Craft::$app->getEntries()->getEntryById($entryId);

        if (!$entry) {
        return;
    }

    $allSites = array_values(array_filter($entry->getSupportedSites(), fn($site) => $site["propagate"] === false));
    $totalSites = count($allSites);

    foreach ($allSites as $i => $site) {
        $this->setProgress(
            $queue,
            (int)$i / $totalSites,
            Craft::t("site", "{step, number} of {total, number}", [
                "step" => (int)$i + 1,
                "total" => $totalSites,
            ])
        );

        try {
            $entry->siteId = $site["siteId"];
            Craft::$app->getElements()->saveElement($entry);
        } catch (Exception $e) {
            Craft::warning("Something went wrong: {$e->getMessage()}", __METHOD__);
        }
    }

    Craft::$app->cache->delete("activate-all-entry-sites");
}

However, since updating to Craft CMS 4.11.0, this code fails during the save operation within the try-catch block, throwing the following error:

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '380243-1' for key 'idx_idrfffijatjsyeopnretaxgeijzqqbmpkuyz'
The SQL being executed was: INSERT INTO `elements_sites` (`elementId`, `siteId`, `slug`, `uri`, `enabled`, `dateCreated`, `dateUpdated`, `uid`) VALUES (380243, 1, 'example-1', 'apo-blueprint/example-1', 1, '2024-08-26 13:03:14', '2024-08-26 13:03:14', '4f3385ba-517d-4bcc-a69f-ece8fe8d05e9')

It appears that I can no longer directly overwrite the siteId on an existing entry.

Temporary Fix

I have found a workaround by creating a new copy of the entry and setting the siteId on this copy. Since I’m reusing the same id and uid, it’s essentially the same entry from a technical perspective. Here’s the adjusted code:

try {
    $copiedEntry = new Entry();

    $copiedEntry->id = $entry->id;
    $copiedEntry->uid = $entry->uid;
    $copiedEntry->sectionId = $entry->sectionId;
    $copiedEntry->typeId = $entry->typeId;
    $copiedEntry->authorId = $entry->authorId;
    $copiedEntry->enabled = $entry->enabled;
    $copiedEntry->title = $entry->title;
    $copiedEntry->siteId = $site['siteId'];

    Craft::$app->getElements()->saveElement($copiedEntry);
} catch (Exception $e) {
    Craft::warning("Something went wrong: {$e->getMessage()}", __METHOD__);
}

Question

Given that technically this is the same entry (since the id and uid remain unchanged), why is it necessary to overwrite all these additional attributes when all I want to change is the siteId? Is there an updated method or best practice in Craft CMS 4.11.0 for changing the siteId of an existing entry, or are there any new checks in place that might be preventing my code from saving entries across different sites? Any guidance on how to address this issue would be greatly appreciated.

Thank you for your support!

Craft CMS version

4.11.0

PHP version

8.2

Operating system and version

Linux (Cyon-Hosting)

Database type and version

10.6.14-MariaDB-cll-lve-log - MariaDB Server

Image driver and version

No response

Installed plugins and versions

Snippet from the composer.json:

"craftcms/ckeditor": "3.9.0",
"craftcms/cms": "4.11.5",
"craftcms/contact-form": "^3.1.0",
"craftcms/contact-form-honeypot": "2.1.0",
"craftcms/redactor": "^3.0",
"doublesecretagency/craft-cpbodyclasses": "^2.3",
"doublesecretagency/craft-cpcss": "2.6.0",
"eastslopestudio/craft-sites-field": "1.0.6",
"flipboxfactory/saml-sp": "4.3.0",
"nystudio107/craft-retour": "4.1.19",
"nystudio107/craft-seomatic": "4.1.2",
"pennebaker/craft-architect": "^4.0",
"putyourlightson/craft-blitz": "4.23.0",
"spicyweb/craft-neo": "4.2.11",
"verbb/formie": "2.1.26",
"verbb/super-table": "3.0.14",
"vlucas/phpdotenv": "^5.4.0",
i-just commented 3 weeks ago

Hi, thanks for getting in touch! Which version did you update from?

sqp-alpaca commented 3 weeks ago

I updated from 4.10.8 to 4.11.0

brandonkelly commented 3 weeks ago

The reason it’s breaking is because you aren’t unsetting the entry’s contentId value, which stores the ID of the entry/site’s row in the content table. So initially when saveElement() is called, the original content row will get reassigned to the new site here:

https://github.com/craftcms/cms/blob/72fcd3673c554c2e38e8bbdd5631caafce046885/src/services/Content.php#L156-L162

When saveElement() goes to propagate the entry to its other sites, it won’t be able to fetch the entry in the original site anymore (because the data is now incomplete for it, so not all of the joins won’t resolve), so it will assume the entry doesn’t exist at all for the original site and try to recreate it there—resulting in the SQL error because the entry does partially exist there still (in the elements_sites table).

@i-just found that this change is what ended up causing this to break in 4.11, but I’m surprised it worked to begin with.

The proper way to add an entry to additional sites is via its enabledForSites property:

use craft\helpers\ElementHelper;

$entry = Craft::$app->getEntries()->getEntryById(24);

// see if we need to add the entry to any sites
$addSites = array_values(array_filter($entry->getSupportedSites(), fn($site) => $site['propagate'] === false));

if ($addSites) {
    // get the current site statuses and merge in the missing ones
    $siteStatuses = ElementHelper::siteStatusesForElement($entry);
    foreach ($addSites as $site) {
        $siteStatuses[$site['siteId']] = true;
    }
    $entry->setEnabledForSite($siteStatuses);

    // save the entry
    Craft::$app->getElements()->saveElement($entry);
}