Skip to content

Commit 1b584d8

Browse files
committed
Handle more collision scenarios on NestedArrayFormatter
See #1668 for more info. There is a new test "Deep name collision" on AllOfTest that exemplifies the kind of collision this change solves. Some extra `__root__` keys needed to be added to a select few other scenarios for consistency.
1 parent 63f7753 commit 1b584d8

File tree

6 files changed

+268
-79
lines changed

6 files changed

+268
-79
lines changed

src/Message/Formatter/NestedArrayFormatter.php

Lines changed: 72 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use Respect\Validation\Message\Renderer;
1616
use Respect\Validation\Result;
1717

18-
use function array_reduce;
18+
use function array_values;
1919
use function count;
2020
use function current;
2121
use function is_array;
@@ -31,25 +31,30 @@
3131
public function format(Result $result, Renderer $renderer, array $templates): array
3232
{
3333
if ($result->children === []) {
34-
return [$this->getKey($result) => $renderer->render($result, $templates)];
34+
return [$result->path->value ?? $result->id->value => $renderer->render($result, $templates)];
3535
}
3636

37-
[$children, $hasString] = $this->prepareChildren($result->children);
37+
$hasStringKey = false;
38+
foreach ($result->children as $child) {
39+
if (!is_numeric($child->path->value ?? $child->id->value)) {
40+
$hasStringKey = true;
41+
break;
42+
}
43+
}
3844

39-
$messages = array_reduce(
40-
$children,
41-
fn(array $messages, array $item) => $this->appendMessage(
45+
$messages = [];
46+
$childCount = count($result->children);
47+
foreach ($result->children as $child) {
48+
$messages = $this->formatChild(
4249
$messages,
43-
$item['key'],
44-
$item['child'],
50+
$child,
4551
$renderer,
4652
$templates,
47-
$hasString,
53+
$hasStringKey,
4854
$result,
49-
isParentVisible: count($children) > 1,
50-
),
51-
[],
52-
);
55+
$childCount > 1,
56+
);
57+
}
5358

5459
if (count($messages) > 1) {
5560
return ['__root__' => $renderer->render($result, $templates)] + $messages;
@@ -58,98 +63,92 @@ public function format(Result $result, Renderer $renderer, array $templates): ar
5863
return $messages;
5964
}
6065

61-
/**
62-
* @param array<Result> $children
63-
*
64-
* @return array{0: array<array{key: string|int, child: Result}>, 1: bool}
65-
*/
66-
private function prepareChildren(array $children): array
67-
{
68-
$mapped = [];
69-
$hasString = false;
70-
71-
foreach ($children as $child) {
72-
$key = $this->getKey($child);
73-
if (!is_numeric($key)) {
74-
$hasString = true;
75-
}
76-
77-
$mapped[] = ['key' => $key, 'child' => $child];
78-
}
79-
80-
return [$mapped, $hasString];
81-
}
82-
8366
/**
8467
* @param array<string|int, mixed> $messages
8568
* @param array<string|int, mixed> $templates
8669
*
8770
* @return array<string|int, mixed>
8871
*/
89-
private function appendMessage(
72+
private function formatChild(
9073
array $messages,
91-
string|int $key,
9274
Result $child,
9375
Renderer $renderer,
9476
array $templates,
95-
bool $hasString,
77+
bool $hasStringKey,
9678
Result $parent,
97-
bool $isParentVisible,
79+
bool $hasMultipleChildren,
9880
): array {
99-
if ($hasString && is_numeric($key)) {
100-
$key = $child->id->value;
101-
}
81+
$rawKey = $child->path->value ?? $child->id->value;
82+
$normalizedKey = $hasStringKey && is_numeric($rawKey) ? $child->id->value : $rawKey;
10283

103-
$message = $this->renderChild($child, $renderer, $templates, $parent, $isParentVisible);
84+
$formatted = $this->format(
85+
$hasMultipleChildren && $child->name === $parent->name ? $child->withoutName() : $child,
86+
$renderer,
87+
$templates,
88+
);
10489

105-
if (!$hasString) {
106-
$messages[] = $message;
90+
$childMessage = count($formatted) === 1 ? current($formatted) : $formatted;
10791

108-
return $messages;
92+
if (is_array($childMessage) && count($childMessage) > 1 && !isset($childMessage['__root__'])) {
93+
$childMessage = ['__root__' => $renderer->render($child, $templates)] + $childMessage;
10994
}
11095

111-
if (isset($messages[$key])) {
112-
if (!is_array($messages[$key])) {
113-
$messages[$key] = [$messages[$key]];
114-
}
96+
if (!$hasStringKey) {
97+
$messages[] = $childMessage;
11598

116-
$messages[$key][] = $message;
99+
return $messages;
100+
}
101+
102+
if (!isset($messages[$normalizedKey])) {
103+
$messages[$normalizedKey] = $childMessage;
117104

118105
return $messages;
119106
}
120107

121-
$messages[$key] = $message;
108+
if ($child->path !== null) {
109+
return $this->mergeWithExistingPath($messages, $normalizedKey, $childMessage);
110+
}
122111

123-
return $messages;
112+
return $this->flattenToIndexedList($messages, $childMessage, $parent, $renderer, $templates);
124113
}
125114

126115
/**
127-
* @param array<string|int, array<string>> $templates
116+
* @param array<string|int, mixed> $messages
117+
* @param array<string|int, mixed>|string $childMessage
128118
*
129-
* @return array<string>|string
119+
* @return array<string|int, mixed>
130120
*/
131-
private function renderChild(
132-
Result $child,
133-
Renderer $renderer,
134-
array $templates,
135-
Result $parent,
136-
bool $isParentVisible,
137-
): array|string {
138-
$formatted = $this->format(
139-
$isParentVisible && $child->name === $parent->name ? $child->withoutName() : $child,
140-
$renderer,
141-
$templates,
142-
);
121+
private function mergeWithExistingPath(array $messages, string|int $key, array|string $childMessage): array
122+
{
123+
if (is_array($messages[$key])) {
124+
if (isset($messages[$key]['__root__'])) {
125+
$messages[$key] = [$messages[$key]];
126+
}
143127

144-
if (count($formatted) === 1) {
145-
return current($formatted);
128+
$messages[$key][] = $childMessage;
129+
} else {
130+
$messages[$key] = [$messages[$key], $childMessage];
146131
}
147132

148-
return $formatted;
133+
return $messages;
149134
}
150135

151-
private function getKey(Result $result): string|int
152-
{
153-
return $result->path->value ?? $result->id->value;
136+
/**
137+
* @param array<string|int, mixed> $messages
138+
* @param array<string|int, mixed>|string $childMessage
139+
* @param array<string|int, mixed> $templates
140+
*
141+
* @return array<string|int, mixed>
142+
*/
143+
private function flattenToIndexedList(
144+
array $messages,
145+
array|string $childMessage,
146+
Result $parent,
147+
Renderer $renderer,
148+
array $templates,
149+
): array {
150+
$parentMessage = $messages['__root__'] ?? $renderer->render($parent, $templates);
151+
152+
return ['__root__' => $parentMessage] + array_values($messages) + [count($messages) => $childMessage];
154153
}
155154
}

tests/feature/Issues/Issue1289Test.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@
6363
0 => [
6464
'__root__' => '`.0` must pass the rules',
6565
'default' => [
66-
'`.0.default` must be a string',
67-
'`.0.default` must be a boolean',
66+
'__root__' => '`.0.default` must pass one of the rules',
67+
0 => '`.0.default` must be a string',
68+
1 => '`.0.default` must be a boolean',
6869
],
6970
'description' => '`.0.description` must be a string value',
7071
],

tests/feature/Issues/Issue1376Test.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@
3232
'title' => '`.title` must be present',
3333
'description' => '`.description` must be present',
3434
'author' => [
35-
'`.author` must be an integer',
36-
'The length of `.author` must be between 1 and 2',
35+
'__root__' => '`.author` must pass all the rules',
36+
0 => '`.author` must be an integer',
37+
1 => 'The length of `.author` must be between 1 and 2',
3738
],
3839
'user' => '`.user` must be present',
3940
]),
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
test('https://github.com/Respect/Validation/issues/1668', catchAll(
12+
fn() => v::allOf(
13+
v::named('alpha', v::allOf(
14+
v::contains('quick'),
15+
v::contains('fox'),
16+
)),
17+
v::named('zeta', v::allOf(
18+
v::contains('lorem'),
19+
v::contains('ipsum'),
20+
)),
21+
)->assert('foo bar baz'),
22+
fn(string $message, string $fullMessage, array $messages) => expect()
23+
->and($message)->toBe('alpha must contain "quick"')
24+
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
25+
- "foo bar baz" must pass all the rules
26+
- alpha must pass all the rules
27+
- "foo bar baz" must contain "quick"
28+
- "foo bar baz" must contain "fox"
29+
- zeta must pass all the rules
30+
- "foo bar baz" must contain "lorem"
31+
- "foo bar baz" must contain "ipsum"
32+
FULL_MESSAGE)
33+
->and($messages)->toBe([
34+
'__root__' => '"foo bar baz" must pass all the rules',
35+
0 => [
36+
'__root__' => 'alpha must pass all the rules',
37+
0 => '"foo bar baz" must contain "quick"',
38+
1 => '"foo bar baz" must contain "fox"',
39+
],
40+
1 => [
41+
'__root__' => 'zeta must pass all the rules',
42+
0 => '"foo bar baz" must contain "lorem"',
43+
1 => '"foo bar baz" must contain "ipsum"',
44+
],
45+
]),
46+
));
47+
48+
test('https://github.com/Respect/Validation/issues/1668 #2', catchAll(
49+
fn() => v::allOf(
50+
v::allOf(
51+
v::contains('quick'),
52+
v::contains('fox'),
53+
),
54+
v::allOf(
55+
v::contains('lorem'),
56+
v::contains('ipsum'),
57+
),
58+
)->assert('foo bar baz'),
59+
fn(string $message, string $fullMessage, array $messages) => expect()
60+
->and($message)->toBe('"foo bar baz" must contain "quick"')
61+
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
62+
- "foo bar baz" must pass all the rules
63+
- "foo bar baz" must pass all the rules
64+
- "foo bar baz" must contain "quick"
65+
- "foo bar baz" must contain "fox"
66+
- "foo bar baz" must pass all the rules
67+
- "foo bar baz" must contain "lorem"
68+
- "foo bar baz" must contain "ipsum"
69+
FULL_MESSAGE)
70+
->and($messages)->toBe([
71+
'__root__' => '"foo bar baz" must pass all the rules',
72+
0 => [
73+
'__root__' => '"foo bar baz" must pass all the rules',
74+
0 => '"foo bar baz" must contain "quick"',
75+
1 => '"foo bar baz" must contain "fox"',
76+
],
77+
1 => [
78+
'__root__' => '"foo bar baz" must pass all the rules',
79+
0 => '"foo bar baz" must contain "lorem"',
80+
1 => '"foo bar baz" must contain "ipsum"',
81+
],
82+
]),
83+
));
84+
85+
86+
test('https://github.com/Respect/Validation/issues/1668 #3', catchAll(
87+
fn() => v::named('match_making', v::allOf(
88+
v::key('couple', v::named('movies', v::allOf(
89+
v::key('she', v::named('vampire', v::contains('Twilight'))),
90+
v::key('he', v::named('heroes', v::contains('Avengers'))),
91+
))),
92+
v::key('couple', v::named('food', v::allOf(
93+
v::key('she', v::named('comfort', v::contains('mac & cheese'))),
94+
v::key('he', v::named('junk', v::contains('nachos'))),
95+
))),
96+
))->assert(['couple' => ['she' => 'Gilmore Girls, soup', 'he' => 'Batman, pizza']], [
97+
'match_making' => 'Match Making',
98+
'movies' => 'Cinema',
99+
'food' => 'Cousine',
100+
'vampire' => 'She must like vampire movies',
101+
'heroes' => 'He must like hero movies',
102+
'comfort' => 'She must like comfort food',
103+
'junk' => 'He must like junk food',
104+
]),
105+
fn(string $message, string $fullMessage, array $messages) => expect()
106+
->and($message)->toBe('She must like vampire movies')
107+
->and($fullMessage)->toBe(<<<'FULL_MESSAGE'
108+
- Match Making
109+
- Cinema
110+
- She must like vampire movies
111+
- He must like hero movies
112+
- Cousine
113+
- She must like comfort food
114+
- He must like junk food
115+
FULL_MESSAGE)
116+
->and($messages)->toBe([
117+
'couple' => [
118+
0 => [
119+
'__root__' => 'Cinema',
120+
'she' => 'She must like vampire movies',
121+
'he' => 'He must like hero movies',
122+
],
123+
1 => [
124+
'__root__' => 'Cousine',
125+
'she' => 'She must like comfort food',
126+
'he' => 'He must like junk food',
127+
],
128+
],
129+
]),
130+
));

tests/feature/Validators/AttributesTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@
6060
'__root__' => '`Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$birthdate="not a date" #$phone="not a phone number" + ... }` must pass the rules',
6161
'name' => '`.name` must be defined',
6262
'birthdate' => [
63-
'`.birthdate` must be a valid date in the format "2005-12-30"',
64-
'For comparison with now, `.birthdate` must be a valid datetime',
63+
'__root__' => '`.birthdate` must pass all the rules',
64+
0 => '`.birthdate` must be a valid date in the format "2005-12-30"',
65+
1 => 'For comparison with now, `.birthdate` must be a valid datetime',
6566
],
6667
'phone' => '`.phone` must be a valid telephone number or must be null',
6768
'email' => '`.email` must be a valid email address or must be null',

0 commit comments

Comments
 (0)