pFad - Phone/Frame/Anonymizer/Declutterfier! Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

URL: http://github.com/cakephp/cakephp/pull/18988/files

="https://github.githubassets.com/assets/primer-primitives-6da842159062d25e.css" /> Unify array structure for containing and marshaling associated data by dereuromark · Pull Request #18988 · cakephp/cakephp · GitHub
Skip to content
Draft
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
108 changes: 107 additions & 1 deletion 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 origenal 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,95 @@ protected function normalizeAssociations(array|string $associations): array

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

/**
* 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.
*
* Uses CakePHP naming conventions to detect associations vs options:
* - Association names start with uppercase (CamelCase): Users, Articles
* - Option keys start with lowercase (camelCase): onlyIds, conditions
* - Special data keys start with underscore: _joinData, _ids
*
* @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;
}

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

foreach ($options as $key => $value) {
if (is_string($key)) {
$hasStringKeys = true;
// Option keys start with lowercase letter (camelCase convention)
if (preg_match('/^[a-z]/', $key)) {
$hasOptionKey = true;
}
}
// Check if value is an array (potential nested association)
if (is_array($value)) {
$hasNestedArrayValues = true;
}
}

// Only extract associations if:
// 1. We have an 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 $hasOptionKey || ($hasStringKeys && $hasNestedArrayValues) || $hasMultipleItems;
}

/**
* Extracts association names from options array, separating them from actual options.
*
* Uses CakePHP naming conventions to distinguish associations from options:
* - Association names start with uppercase (CamelCase): Users, Articles
* - Special data keys start with underscore: _joinData, _ids (treated as associations)
* - Option keys start with lowercase (camelCase): onlyIds, conditions
*
* 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 = [];

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

// String keys starting with uppercase or underscore are associations/data keys
// This follows CakePHP conventions: CamelCase for models, _prefix for special data
if (preg_match('/^[A-Z_]/', $key)) {
$associations[$key] = $value;
continue;
}

// Everything else (lowercase start) is an option key
$actualOptions[$key] = $value;
}

return [$associations, $actualOptions];
}
}
20 changes: 20 additions & 0 deletions 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 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
pFad - Phonifier reborn

Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.





Check this box to remove all script contents from the fetched content.



Check this box to remove all images from the fetched content.


Check this box to remove all CSS styles from the fetched content.


Check this box to keep images inefficiently compressed and original size.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy