Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion 127 src/ORM/AssociationsNormalizerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ trait AssociationsNormalizerTrait
{
/**
* Returns an array out of the original passed associations list where dot notation
* is transformed into nested arrays so that they can be parsed by other routines
* is transformed into nested arrays so that they can be parsed by other routines.
*
* This method now supports the same nested array format as contain(), allowing:
* - Dot notation: ['First.Second']
* - Nested arrays: ['First' => ['Second', 'Third']]
* - Mixed with options: ['First' => ['Second', 'onlyIds' => true]]
*
* @param array|string $associations The array of included associations.
* @return array An array having dot notation transformed into nested arrays
Expand All @@ -40,6 +45,16 @@ protected function normalizeAssociations(array|string $associations): array
$options = [];
}

// Handle nested array format like contain()
// Only transform if the array looks like it contains associations (not just a simple array value)
if (is_array($options) && !isset($options['associated']) && $this->shouldExtractAssociations($options)) {
[$nestedAssociations, $actualOptions] = $this->extractAssociations($options);
if ($nestedAssociations) {
$actualOptions['associated'] = $this->normalizeAssociations($nestedAssociations);
}
$options = $actualOptions;
}

if (!str_contains($table, '.')) {
$result[$table] = $options;
continue;
Expand Down Expand Up @@ -67,4 +82,114 @@ protected function normalizeAssociations(array|string $associations): array

return $result['associated'] ?? $result;
}

/**
* Returns the list of known option keys that should not be treated as associations.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is why I actually prefer the use of the associated key, you don't have to maintain this list of known keys.

*
* @return array<string>
*/
protected function getKnownOptions(): array
{
return [
'onlyIds',
'validate',
'fields',
'patchableFields',
'forceNew',
'strictFields',
'queryBuilder',
'finder',
'foreignKey',
'joinType',
'propertyName',
'strategy',
'negateMatch',
'conditions',
'isMerge',
'junctionProperty',
];
}

/**
* Determines if an array should have associations extracted from it.
*
* Returns true if the array appears to be mixing association names with options,
* or if it contains nested association structures (like contain() format).
* Returns false for simple arrays that should be kept as-is.
*
* @param array $options The options array to check.
* @return bool
*/
protected function shouldExtractAssociations(array $options): bool
{
// Empty arrays should not be transformed
if (!$options) {
return false;
}

$knownOptions = $this->getKnownOptions();

$hasKnownOption = false;
$hasStringKeys = false;
$hasNestedArrayValues = false;
$hasMultipleItems = count($options) > 1;

foreach ($options as $key => $value) {
if (is_string($key)) {
$hasStringKeys = true;
if (in_array($key, $knownOptions, true)) {
$hasKnownOption = true;
}
}
// Check if value is an array (potential nested association)
if (is_array($value)) {
$hasNestedArrayValues = true;
}
}

// Only extract associations if:
// 1. We have a known option key (mixing associations and options)
// 2. We have string keys AND nested array values (contain-like format with nested associations)
// 3. We have multiple items (likely a list of associations like ['Users', 'Comments'])
return $hasKnownOption || ($hasStringKeys && $hasNestedArrayValues) || $hasMultipleItems;
}

/**
* Extracts association names from options array, separating them from actual options.
*
* This allows the same nested array format as contain():
* - ['Users', 'Comments'] → associations
* - ['Users' => [...], 'Comments'] → associations
* - ['onlyIds' => true, 'validate' => false] → options only
* - ['Users', 'onlyIds' => true] → mixed
*
* @param array $options The options array that may contain nested associations.
* @return array An array with two elements: [associations, options]
*/
protected function extractAssociations(array $options): array
{
$associations = [];
$actualOptions = [];
$knownOptions = $this->getKnownOptions();

foreach ($options as $key => $value) {
// Numeric keys are always association names
if (is_int($key)) {
$associations[] = $value;
continue;
}

// Known option keys
if (in_array($key, $knownOptions, true)) {
$actualOptions[$key] = $value;
continue;
}

// Everything else is treated as an association name
// This matches contain() behavior and includes special keys like _joinData
$associations[$key] = $value;
}

return [$associations, $actualOptions];
}
}
20 changes: 20 additions & 0 deletions 20 src/ORM/Marshaller.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ protected function buildPropertyMap(array $data, array $options): array
* ]);
* ```
*
* You can also use the same nested array format as contain():
*
* ```
* $result = $marshaller->one($data, [
* 'associated' => [
* 'Tags' => ['DeeperAssoc1', 'DeeperAssoc2']
* ]
* ]);
* ```
*
* @param array<string, mixed> $data The data to hydrate.
* @param array<string, mixed> $options List of options
* @return \Cake\Datasource\EntityInterface
Expand Down Expand Up @@ -563,6 +573,16 @@ protected function loadAssociatedByIds(Association $assoc, array $ids): array
* ]);
* ```
*
* You can also use the same nested array format as contain():
*
* ```
* $result = $marshaller->merge($entity, $data, [
* 'associated' => [
* 'Tags' => ['DeeperAssoc1', 'DeeperAssoc2']
* ]
* ]);
* ```
*
* @template TEntity of \Cake\Datasource\EntityInterface
* @param TEntity $entity the entity that will get the
* data merged in
Expand Down
113 changes: 113 additions & 0 deletions 113 tests/TestCase/ORM/MarshallerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,60 @@ public function testOneBelongsToManyWithNestedAssociationsWithoutDotNotation():
$this->assertTrue($tag->articles[0]->comments[1]->isNew());
}

/**
* Test the unified array structure similar to contain()
* This verifies that ['Articles' => ['Users', 'Comments']] works the same as
* ['Articles.Users', 'Articles.Comments'] for deep marshalling.
*
* @return void
*/
public function testOneBelongsToManyWithNestedAssociationsUnifiedFormat(): void
{
$this->tags->belongsToMany('Articles');
$data = [
'name' => 'new tag',
'articles' => [
// This nested article exists, and we want to update it.
[
'id' => 1,
'title' => 'New tagged article',
'body' => 'New tagged article',
'user' => [
'id' => 1,
'username' => 'newuser',
],
'comments' => [
['comment' => 'New comment', 'user_id' => 1],
['comment' => 'Second comment', 'user_id' => 1],
],
],
],
];
$marshaller = new Marshaller($this->tags);
// Using the unified format like contain()
$tag = $marshaller->one($data, ['associated' => ['Articles' => ['Users', 'Comments']]]);

$this->assertNotEmpty($tag->articles);
$this->assertCount(1, $tag->articles);
$this->assertTrue($tag->isDirty('articles'), 'Updated prop should be dirty');
$this->assertInstanceOf(Entity::class, $tag->articles[0]);
$this->assertSame('New tagged article', $tag->articles[0]->title);
$this->assertFalse($tag->articles[0]->isNew());

$this->assertNotEmpty($tag->articles[0]->user);
$this->assertInstanceOf(Entity::class, $tag->articles[0]->user);
$this->assertTrue($tag->articles[0]->isDirty('user'), 'Updated prop should be dirty');
$this->assertSame('newuser', $tag->articles[0]->user->username);
$this->assertTrue($tag->articles[0]->user->isNew());

$this->assertNotEmpty($tag->articles[0]->comments);
$this->assertCount(2, $tag->articles[0]->comments);
$this->assertTrue($tag->articles[0]->isDirty('comments'), 'Updated prop should be dirty');
$this->assertInstanceOf(Entity::class, $tag->articles[0]->comments[0]);
$this->assertTrue($tag->articles[0]->comments[0]->isNew());
$this->assertTrue($tag->articles[0]->comments[1]->isNew());
}

/**
* Test belongsToMany association with mixed data and _joinData
*/
Expand Down Expand Up @@ -2285,6 +2339,65 @@ public function testMergeBelongsToManyJoinDataAssociatedWithIdsWithoutDotNotatio
);
}

/**
* Test the unified array structure similar to contain() for merge operations.
* This verifies that the same nested format works for merging as it does for marshalling.
*/
public function testMergeBelongsToManyJoinDataAssociatedWithIdsUnifiedFormat(): void
{
$data = [
'title' => 'My title',
'tags' => [
[
'id' => 1,
'_joinData' => [
'active' => 1,
'user' => ['username' => 'MyLux'],
],
],
[
'id' => 2,
'_joinData' => [
'active' => 0,
'user' => ['username' => 'IronFall'],
],
],
],
];
$articlesTags = $this->getTableLocator()->get('ArticlesTags');
$articlesTags->belongsTo('Users');

$marshall = new Marshaller($this->articles);
$article = $this->articles->get(1, ...['associated' => 'Tags']);
// Using the unified format like contain() with dot notation for single association
$result = $marshall->merge($article, $data, ['associated' => [
'Tags._joinData.Users',
]]);

$this->assertTrue($result->isDirty('tags'));
$this->assertInstanceOf(Entity::class, $result->tags[0]);
$this->assertInstanceOf(Entity::class, $result->tags[1]);
$this->assertInstanceOf(Entity::class, $result->tags[0]->_joinData->user);

$this->assertInstanceOf(Entity::class, $result->tags[1]->_joinData->user);
$this->assertFalse($result->tags[0]->isNew(), 'Should not be new, as id is in db.');
$this->assertFalse($result->tags[1]->isNew(), 'Should not be new, as id is in db.');
$this->assertSame(1, $result->tags[0]->id);
$this->assertSame(2, $result->tags[1]->id);

$this->assertSame(1, $result->tags[0]->_joinData->active);
$this->assertSame(0, $result->tags[1]->_joinData->active);

$this->assertSame(
$data['tags'][0]['_joinData']['user']['username'],
$result->tags[0]->_joinData->user->username,
);
$this->assertSame(
$data['tags'][1]['_joinData']['user']['username'],
$result->tags[1]->_joinData->user->username,
);
}

/**
* Test merging the _joinData entity for belongstomany associations.
*/
Expand Down
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.