Thanks to visit codestin.com
Credit goes to github.com

Skip to content

belongsToMany through Table does not support composite keys #18885

@nook24

Description

@nook24

Description

This will going to be a long one and I'm sorry for this upfront. I talked about this issue in the Slack channel for a very long time and I think it is a bug. Many thanks to all of you for your feedback and time! :)

I have 3 tables:
statuspages <1-----n> statuspages_to_statuspagegroups <n-----1> statuspagegroups

Image

What I try to accomplish is to store multiple records into the statuspages_to_statuspagegroups with the same statuspagegroup_id and statuspage_id ID but different collection_id and category_id values.

To gain control over the join table, usually a through Table is used.

These are my associations:

class StatuspagegroupsTable extends Table {

    public function initialize(array $config): void {
        parent::initialize($config);

        $this->setTable('statuspagegroups');
        $this->setPrimaryKey('id');

        $this->belongsToMany('Statuspages', [
            'className'        => 'Statuspages',
            'through'          => 'StatuspagesMembership',
            'targetForeignKey' => 'statuspage_id',
            'saveStrategy'     => 'replace'
        ]);
    }
}


class StatuspagesMembershipTable extends Table {

    public function initialize(array $config): void {

        $this->setTable('statuspages_to_statuspagegroups');
        $this->setDisplayField('id');
        $this->setPrimaryKey('id');

        $this->addBehavior('Timestamp');

        $this->belongsTo('Statuspagegroups', [
            'foreignKey' => 'statuspagegroup_id',
            'joinType'   => 'INNER',
        ]);
        $this->belongsTo('Statuspages', [
            'foreignKey' => 'statuspage_id',
            'joinType'   => 'INNER',
        ]);
    }
}

This association will work to read (find) data - no issues.
When inserting data (save), this association will only work as long as the statuspage_id is unique.

So I can insert data like to without any problems:

  "statuspages": [
    {
      "id": 5,                    // <-- this works
      "_joinData": {
        "statuspagegroup_id": 5,
        "collection_id": 10,
        "category_id": 16,
        "statuspage_id": 5        // <-- this works
      }
    },
    {
      "id": 1,
      "_joinData": {
        "statuspagegroup_id": 5,
        "collection_id": 11,
        "category_id": 16,
        "statuspage_id": 1
      }
    },

But, as soon as I try to create records with the same statuspage_id and statuspagegroup_id but different values for collection_id and category_id the ORM Marshaller will merge/overwrite the entities.

So i cannot save records like so:

  "statuspages": [
    {
      "id": 1,                    // <-- this works not
      "_joinData": {
        "statuspagegroup_id": 5,
        "collection_id": 10,      // and i think bc cake ignores the values of collection_id and category_id
        "category_id": 16,
        "statuspage_id": 1        // <-- this works not
      }
    },
    {
      "id": 1,
      "_joinData": {
        "statuspagegroup_id": 5,
        "collection_id": 11,     // different collection_id and category_id than above
        "category_id": 16,
        "statuspage_id": 1
      }
    },

I did not found a method to configure composite keys for the StatuspagesMembership association. I tried all sorts of combinations without any luck.

The ORM Marshaller will merge the two records (from request data) into one entity:

public function mergeMany(iterable $entities, array $data, array $options = []): array
{
$primary = (array)$this->_table->getPrimaryKey();
$indexed = (new Collection($data))
->groupBy(function ($el) use ($primary) {
$keys = [];
foreach ($primary as $key) {
$keys[] = $el[$key] ?? '';
}
return implode(';', $keys);
})
->map(function ($element, $key) {
return $key === '' ? $element : $element[0];
})
->toArray();
$new = $indexed[''] ?? [];

For now the workaround @josbeir in the Slack channel came up with is, to manually create the records into the join table.

$statuspagegroup = $StatuspagegroupsTable->get($id);

/** @var StatuspagesMembershipTable $StatuspagesMembershipTable */
$StatuspagesMembershipTable = TableRegistry::getTableLocator()->get('StatuspagesMembership');

$StatuspagesMembershipTable->deleteAllRecordsByStatuspagegroupId($statuspagegroup->id);
$data = $this->request->getData(null, []);
if (empty($data['statuspages'])) {
    $data['statuspages'] = [];
}

$joinTableRecords = [];
foreach ($data['statuspages'] as $statuspage) {
    $joinTableRecords[] = Hash::remove($statuspage['_joinData'], 'id');
}

$joinTableEntities = $StatuspagesMembershipTable->newEntities($joinTableRecords);
$StatuspagesMembershipTable->saveMany($joinTableEntities);
foreach ($joinTableEntities as $entity) {
    if ($entity->hasErrors()) {
        $this->response = $this->response->withStatus(400);
        $this->set('error', $entity->getErrors());
        $this->viewBuilder()->setOption('serialize', ['error']);
        return;
    }
}

While this workaround works, if feels wrong that i need to save the records manually.

The fact that collection_id and category_id are refs to other tables can be ignored as the same happens if you use strings like cat and dog for example.

CakePHP Version

5.2.6

PHP Version

8.3.6

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions