Skip to content

Commit 1915b6f

Browse files
committed
Use paths to identify when a rule fails
When nested-structural validation fails, it's challenging to identify which rule failed from the main exception message. A great example is the `Issue796Test.php` file. The exception message says: host must be a string But you're left unsure whether it's the `host` key from the `mysql` key or the `postgresql` key. This commit changes that behaviour by introducing the concept of "Path." The `path` represents the path that a rule has taken, and we can use it in structural rules to identify the path of an array or object. Here's what it looks like before and after: ```diff -host must be a string +`.mysql.host` must be a string ``` Because paths are a specific concept, I added a dot (`.`) at the beginning of all paths when displaying them. I was inspired by the `jq` syntax. I also added backticks around paths to distinguish them from any other value. I didn't manage to fix a test, and I skipped it instead of fixing it because I want to make changes in how we display error messages as arrays, and it will be easier to fix it then.
1 parent a0d6355 commit 1915b6f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+632
-562
lines changed

library/Message/Placeholder/Quoted.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ public function __construct(
1616
) {
1717
}
1818

19+
public static function fromPath(int|string $path): self
20+
{
21+
return new self('.' . $path);
22+
}
23+
1924
public function getValue(): string
2025
{
2126
return $this->value;

library/Message/StandardFormatter.php

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
use function array_filter;
1616
use function array_key_exists;
17+
use function array_map;
1718
use function array_reduce;
1819
use function array_values;
1920
use function count;
@@ -35,22 +36,22 @@ public function __construct(
3536
}
3637

3738
/**
38-
* @param array<string, mixed> $templates
39+
* @param array<string|int, mixed> $templates
3940
*/
4041
public function main(Result $result, array $templates, Translator $translator): string
4142
{
4243
$selectedTemplates = $this->selectTemplates($result, $templates);
4344
if (!$this->isFinalTemplate($result, $selectedTemplates)) {
4445
foreach ($this->extractDeduplicatedChildren($result) as $child) {
45-
return $this->main($child, $selectedTemplates, $translator);
46+
return $this->main($this->resultWithPath($result, $child), $selectedTemplates, $translator);
4647
}
4748
}
4849

4950
return $this->renderer->render($this->getTemplated($result, $selectedTemplates), $translator);
5051
}
5152

5253
/**
53-
* @param array<string, mixed> $templates
54+
* @param array<string|int, mixed> $templates
5455
*/
5556
public function full(
5657
Result $result,
@@ -68,13 +69,19 @@ public function full(
6869
$rendered .= sprintf(
6970
'%s- %s' . PHP_EOL,
7071
$indentation,
71-
$this->renderer->render($this->getTemplated($result, $selectedTemplates), $translator),
72+
$this->renderer->render(
73+
$this->getTemplated($depth > 0 ? $result->withDeepestPath() : $result, $selectedTemplates),
74+
$translator
75+
),
7276
);
7377
$depth++;
7478
}
7579

7680
if (!$isFinalTemplate) {
77-
$results = $this->extractDeduplicatedChildren($result);
81+
$results = array_map(
82+
fn(Result $child) => $this->resultWithPath($result, $child),
83+
$this->extractDeduplicatedChildren($result)
84+
);
7885
foreach ($results as $child) {
7986
$rendered .= $this->full(
8087
$child,
@@ -91,37 +98,44 @@ public function full(
9198
}
9299

93100
/**
94-
* @param array<string, mixed> $templates
101+
* @param array<string|int, mixed> $templates
95102
*
96-
* @return array<string, mixed>
103+
* @return array<string|int, mixed>
97104
*/
98105
public function array(Result $result, array $templates, Translator $translator): array
99106
{
100107
$selectedTemplates = $this->selectTemplates($result, $templates);
101108
$deduplicatedChildren = $this->extractDeduplicatedChildren($result);
102109
if (count($deduplicatedChildren) === 0 || $this->isFinalTemplate($result, $selectedTemplates)) {
103110
return [
104-
$result->id => $this->renderer->render($this->getTemplated($result, $selectedTemplates), $translator),
111+
$result->getDeepestPath() ?? $result->id => $this->renderer->render(
112+
$this->getTemplated($result->withDeepestPath(), $selectedTemplates),
113+
$translator
114+
),
105115
];
106116
}
107117

108118
$messages = [];
109119
foreach ($deduplicatedChildren as $child) {
110-
$messages[$child->id] = $this->array(
111-
$child,
120+
$key = $child->getDeepestPath() ?? $child->id;
121+
$messages[$key] = $this->array(
122+
$this->resultWithPath($result, $child),
112123
$this->selectTemplates($child, $selectedTemplates),
113124
$translator
114125
);
115-
if (count($messages[$child->id]) !== 1) {
126+
if (count($messages[$key]) !== 1) {
116127
continue;
117128
}
118129

119-
$messages[$child->id] = current($messages[$child->id]);
130+
$messages[$key] = current($messages[$key]);
120131
}
121132

122133
if (count($messages) > 1) {
123134
$self = [
124-
'__root__' => $this->renderer->render($this->getTemplated($result, $selectedTemplates), $translator),
135+
'__root__' => $this->renderer->render(
136+
$this->getTemplated($result->withDeepestPath(), $selectedTemplates),
137+
$translator
138+
),
125139
];
126140

127141
return $self + $messages;
@@ -130,6 +144,19 @@ public function array(Result $result, array $templates, Translator $translator):
130144
return $messages;
131145
}
132146

147+
public function resultWithPath(Result $parent, Result $child): Result
148+
{
149+
if ($parent->path !== null && $child->path !== null && $child->path !== $parent->path) {
150+
return $child->withPath($parent->path);
151+
}
152+
153+
if ($parent->path !== null && $child->path === null) {
154+
return $child->withPath($parent->path);
155+
}
156+
157+
return $child;
158+
}
159+
133160
private function isAlwaysVisible(Result $result, Result ...$siblings): bool
134161
{
135162
if ($result->isValid) {
@@ -165,56 +192,66 @@ private function isAlwaysVisible(Result $result, Result ...$siblings): bool
165192
);
166193
}
167194

168-
/** @param array<string, mixed> $templates */
195+
/** @param array<string|int, mixed> $templates */
169196
private function getTemplated(Result $result, array $templates): Result
170197
{
171198
if ($result->hasCustomTemplate()) {
172199
return $result;
173200
}
174201

175-
if (!isset($templates[$result->id]) && isset($templates['__root__'])) {
176-
return $result->withTemplate($templates['__root__']);
177-
}
202+
foreach ([$result->path, $result->name, $result->id, '__root__'] as $key) {
203+
if (!isset($templates[$key])) {
204+
continue;
205+
}
178206

179-
if (!isset($templates[$result->id])) {
180-
return $result;
181-
}
207+
if (is_string($templates[$key])) {
208+
return $result->withTemplate($templates[$key]);
209+
}
182210

183-
$template = $templates[$result->id];
184-
if (is_string($template)) {
185-
return $result->withTemplate($template);
211+
throw new ComponentException(
212+
sprintf('Template for "%s" must be a string, %s given', $key, stringify($templates[$key]))
213+
);
186214
}
187215

188-
throw new ComponentException(
189-
sprintf('Template for "%s" must be a string, %s given', $result->id, stringify($template))
190-
);
216+
return $result;
191217
}
192218

193219
/**
194-
* @param array<string, mixed> $templates
220+
* @param array<string|int, mixed> $templates
195221
*/
196222
private function isFinalTemplate(Result $result, array $templates): bool
197223
{
198-
if (isset($templates[$result->id]) && is_string($templates[$result->id])) {
199-
return true;
224+
$keys = [$result->path, $result->name, $result->id];
225+
foreach ($keys as $key) {
226+
if (isset($templates[$key]) && is_string($templates[$key])) {
227+
return true;
228+
}
200229
}
201230

202231
if (count($templates) !== 1) {
203232
return false;
204233
}
205234

206-
return isset($templates['__root__']) || isset($templates[$result->id]);
235+
foreach ($keys as $key) {
236+
if (isset($templates[$key])) {
237+
return true;
238+
}
239+
}
240+
241+
return isset($templates['__root__']);
207242
}
208243

209244
/**
210-
* @param array<string, mixed> $templates
245+
* @param array<string|int, mixed> $templates
211246
*
212-
* @return array<string, mixed>
247+
* @return array<string|int, mixed>
213248
*/
214-
private function selectTemplates(Result $message, array $templates): array
249+
private function selectTemplates(Result $result, array $templates): array
215250
{
216-
if (isset($templates[$message->id]) && is_array($templates[$message->id])) {
217-
return $templates[$message->id];
251+
foreach ([$result->path, $result->name, $result->id] as $key) {
252+
if (isset($templates[$key]) && is_array($templates[$key])) {
253+
return $templates[$key];
254+
}
218255
}
219256

220257
return $templates;
@@ -227,7 +264,7 @@ private function extractDeduplicatedChildren(Result $result): array
227264
$deduplicatedResults = [];
228265
$duplicateCounters = [];
229266
foreach ($result->children as $child) {
230-
$id = $child->id;
267+
$id = $child->getDeepestPath() ?? $child->id;
231268
if (isset($duplicateCounters[$id])) {
232269
$id .= '.' . ++$duplicateCounters[$id];
233270
} elseif (array_key_exists($id, $deduplicatedResults)) {
@@ -236,7 +273,13 @@ private function extractDeduplicatedChildren(Result $result): array
236273
$duplicateCounters[$id] = 2;
237274
$id .= '.2';
238275
}
239-
$deduplicatedResults[$id] = $child->isValid ? null : $child->withId($id);
276+
277+
if ($child->path === null) {
278+
$deduplicatedResults[$id] = $child->isValid ? null : $child->withId($id);
279+
continue;
280+
}
281+
282+
$deduplicatedResults[$id] = $child->isValid ? null : $child;
240283
}
241284

242285
return array_values(array_filter($deduplicatedResults));

library/Message/StandardRenderer.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ public function __construct(
3737
public function render(Result $result, Translator $translator, ?string $template = null): string
3838
{
3939
$parameters = $result->parameters;
40-
$parameters['name'] ??= $result->name ?? $this->placeholder('input', $result->input, $translator);
40+
$parameters['path'] = $result->path !== null ? Quoted::fromPath($result->path) : null;
4141
$parameters['input'] = $result->input;
4242

43+
$builtName = $result->name ?? $parameters['path'] ?? $this->placeholder('input', $result->input, $translator);
44+
$parameters['name'] ??= $builtName;
45+
4346
$rendered = (string) preg_replace_callback(
4447
'/{{(\w+)(\|([^}]+))?}}/',
4548
function (array $matches) use ($parameters, $translator) {

library/Result.php

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
namespace Respect\Validation;
1111

1212
use Respect\Validation\Rules\Core\Nameable;
13-
use Respect\Validation\Rules\Core\Renameable;
1413

1514
use function array_filter;
1615
use function array_map;
1716
use function count;
17+
use function end;
18+
use function explode;
1819
use function lcfirst;
1920
use function preg_match;
2021
use function strrchr;
@@ -39,7 +40,7 @@ public function __construct(
3940
public readonly ?string $name = null,
4041
?string $id = null,
4142
public readonly ?Result $adjacent = null,
42-
public readonly bool $unchangeableId = false,
43+
public readonly string|int|null $path = null,
4344
Result ...$children,
4445
) {
4546
$this->id = $id ?? lcfirst(substr((string) strrchr($rule::class, '\\'), 1));
@@ -102,10 +103,6 @@ public function withExtraParameters(array $parameters): self
102103

103104
public function withId(string $id): self
104105
{
105-
if ($this->unchangeableId) {
106-
return $this;
107-
}
108-
109106
return $this->clone(id: $id);
110107
}
111108

@@ -114,14 +111,44 @@ public function withIdFrom(Rule $rule): self
114111
return $this->clone(id: lcfirst(substr((string) strrchr($rule::class, '\\'), 1)));
115112
}
116113

117-
public function withUnchangeableId(string $id): self
114+
public function withPath(string|int $path): self
115+
{
116+
return $this->clone(
117+
adjacent: $this->adjacent?->withPath($path),
118+
path: $this->path === null ? $path : $path . '.' . $this->path,
119+
);
120+
}
121+
122+
public function withDeepestPath(): self
123+
{
124+
$path = $this->getDeepestPath();
125+
if ($path === null || $path === (string) $this->path) {
126+
return $this;
127+
}
128+
129+
return $this->clone(
130+
adjacent: $this->adjacent?->withPath($path),
131+
path: $path,
132+
);
133+
}
134+
135+
public function getDeepestPath(): ?string
118136
{
119-
return $this->clone(id: $id, unchangeableId: true);
137+
if ($this->path === null) {
138+
return null;
139+
}
140+
141+
$paths = explode('.', (string) $this->path);
142+
if (count($paths) === 1) {
143+
return (string) $this->path;
144+
}
145+
146+
return end($paths);
120147
}
121148

122149
public function withPrefix(string $prefix): self
123150
{
124-
if ($this->id === $this->name || $this->unchangeableId) {
151+
if ($this->id === $this->name || $this->path !== null) {
125152
return $this;
126153
}
127154

@@ -136,10 +163,10 @@ public function withChildren(Result ...$children): self
136163
public function withName(string $name): self
137164
{
138165
return $this->clone(
139-
name: $this->rule instanceof Renameable ? $name : ($this->name ?? $name),
166+
name: $this->name ?? $name,
140167
adjacent: $this->adjacent?->withName($name),
141168
children: array_map(
142-
static fn (Result $child) => $child->withName($child->name ?? $name),
169+
static fn (Result $child) => $child->path === null ? $child->withName($child->name ?? $name) : $child,
143170
$this->children
144171
),
145172
);
@@ -223,7 +250,7 @@ private function clone(
223250
?string $name = null,
224251
?string $id = null,
225252
?Result $adjacent = null,
226-
?bool $unchangeableId = null,
253+
string|int|null $path = null,
227254
?array $children = null
228255
): self {
229256
return new self(
@@ -236,7 +263,7 @@ private function clone(
236263
$name ?? $this->name,
237264
$id ?? $this->id,
238265
$adjacent ?? $this->adjacent,
239-
$unchangeableId ?? $this->unchangeableId,
266+
$path ?? $this->path,
240267
...($children ?? $this->children)
241268
);
242269
}

library/Rules/Core/Renameable.php

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)