Skip to content

[Yaml] Add support for dumping null as an empty value by using the Yaml::DUMP_NULL_AS_EMPTY flag #58243

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 29, 2024
Merged
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
1 change: 1 addition & 0 deletions src/Symfony/Component/Yaml/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
---

* Deprecate parsing duplicate mapping keys whose value is `null`
* Add support for dumping `null` as an empty value by using the `Yaml::DUMP_NULL_AS_EMPTY` flag

7.1
---
Expand Down
25 changes: 17 additions & 8 deletions src/Symfony/Component/Yaml/Dumper.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ public function __construct(private int $indentation = 4)
* @param int-mask-of<Yaml::DUMP_*> $flags A bit field of Yaml::DUMP_* constants to customize the dumped YAML string
*/
public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags = 0): string
{
if ($flags & Yaml::DUMP_NULL_AS_EMPTY && $flags & Yaml::DUMP_NULL_AS_TILDE) {
throw new \InvalidArgumentException('The Yaml::DUMP_NULL_AS_EMPTY and Yaml::DUMP_NULL_AS_TILDE flags cannot be used together.');
}

return $this->doDump($input, $inline, $indent, $flags);
}

private function doDump(mixed $input, int $inline = 0, int $indent = 0, int $flags = 0, int $nestingLevel = 0): string
{
$output = '';
$prefix = $indent ? str_repeat(' ', $indent) : '';
Expand All @@ -51,9 +60,9 @@ public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags
}

if ($inline <= 0 || (!\is_array($input) && !$input instanceof TaggedValue && $dumpObjectAsInlineMap) || !$input) {
$output .= $prefix.Inline::dump($input, $flags);
$output .= $prefix.Inline::dump($input, $flags, 0 === $nestingLevel);
} elseif ($input instanceof TaggedValue) {
$output .= $this->dumpTaggedValue($input, $inline, $indent, $flags, $prefix);
$output .= $this->dumpTaggedValue($input, $inline, $indent, $flags, $prefix, $nestingLevel);
} else {
$dumpAsMap = Inline::isHash($input);

Expand Down Expand Up @@ -105,10 +114,10 @@ public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags
}

if ($inline - 1 <= 0 || null === $value->getValue() || \is_scalar($value->getValue())) {
$output .= ' '.$this->dump($value->getValue(), $inline - 1, 0, $flags)."\n";
$output .= ' '.$this->doDump($value->getValue(), $inline - 1, 0, $flags, $nestingLevel + 1)."\n";
} else {
$output .= "\n";
$output .= $this->dump($value->getValue(), $inline - 1, $dumpAsMap ? $indent + $this->indentation : $indent + 2, $flags);
$output .= $this->doDump($value->getValue(), $inline - 1, $dumpAsMap ? $indent + $this->indentation : $indent + 2, $flags, $nestingLevel + 1);
}

continue;
Expand All @@ -126,15 +135,15 @@ public function dump(mixed $input, int $inline = 0, int $indent = 0, int $flags
$prefix,
$dumpAsMap ? Inline::dump($key, $flags).':' : '-',
$willBeInlined ? ' ' : "\n",
$this->dump($value, $inline - 1, $willBeInlined ? 0 : $indent + $this->indentation, $flags)
$this->doDump($value, $inline - 1, $willBeInlined ? 0 : $indent + $this->indentation, $flags, $nestingLevel + 1)
).($willBeInlined ? "\n" : '');
}
}

return $output;
}

private function dumpTaggedValue(TaggedValue $value, int $inline, int $indent, int $flags, string $prefix): string
private function dumpTaggedValue(TaggedValue $value, int $inline, int $indent, int $flags, string $prefix, int $nestingLevel): string
{
$output = \sprintf('%s!%s', $prefix ? $prefix.' ' : '', $value->getTag());

Expand All @@ -150,10 +159,10 @@ private function dumpTaggedValue(TaggedValue $value, int $inline, int $indent, i
}

if ($inline - 1 <= 0 || null === $value->getValue() || \is_scalar($value->getValue())) {
return $output.' '.$this->dump($value->getValue(), $inline - 1, 0, $flags)."\n";
return $output.' '.$this->doDump($value->getValue(), $inline - 1, 0, $flags, $nestingLevel + 1)."\n";
}

return $output."\n".$this->dump($value->getValue(), $inline - 1, $indent, $flags);
return $output."\n".$this->doDump($value->getValue(), $inline - 1, $indent, $flags, $nestingLevel + 1);
}

private function getBlockIndentationIndicator(string $value): string
Expand Down
10 changes: 7 additions & 3 deletions src/Symfony/Component/Yaml/Inline.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public static function parse(string $value, int $flags = 0, array &$references =
*
* @throws DumpException When trying to dump PHP resource
*/
public static function dump(mixed $value, int $flags = 0): string
public static function dump(mixed $value, int $flags = 0, bool $rootLevel = false): string
{
switch (true) {
case \is_resource($value):
Expand Down Expand Up @@ -138,7 +138,7 @@ public static function dump(mixed $value, int $flags = 0): string
case \is_array($value):
return self::dumpArray($value, $flags);
case null === $value:
return self::dumpNull($flags);
return self::dumpNull($flags, $rootLevel);
case true === $value:
return 'true';
case false === $value:
Expand Down Expand Up @@ -253,12 +253,16 @@ private static function dumpHashArray(array|\ArrayObject|\stdClass $value, int $
return \sprintf('{ %s }', implode(', ', $output));
}

private static function dumpNull(int $flags): string
private static function dumpNull(int $flags, bool $rootLevel = false): string
{
if (Yaml::DUMP_NULL_AS_TILDE & $flags) {
return '~';
}

if (Yaml::DUMP_NULL_AS_EMPTY & $flags && !$rootLevel) {
return '';
}

return 'null';
}

Expand Down
57 changes: 57 additions & 0 deletions src/Symfony/Component/Yaml/Tests/DumperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,63 @@ public function testObjectSupportDisabledWithExceptions()
$this->dumper->dump(['foo' => new A(), 'bar' => 1], 0, 0, Yaml::DUMP_EXCEPTION_ON_INVALID_TYPE);
}

public function testDumpWithMultipleNullFlagsFormatsThrows()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The Yaml::DUMP_NULL_AS_EMPTY and Yaml::DUMP_NULL_AS_TILDE flags cannot be used together.');

$this->dumper->dump(['foo' => 'bar'], 0, 0, Yaml::DUMP_NULL_AS_EMPTY | Yaml::DUMP_NULL_AS_TILDE);
}

public function testDumpNullAsEmptyInExpandedMapping()
{
$expected = "qux:\n foo: bar\n baz: \n";

$this->assertSame($expected, $this->dumper->dump(['qux' => ['foo' => 'bar', 'baz' => null]], 2, flags: Yaml::DUMP_NULL_AS_EMPTY));
}

public function testDumpNullAsEmptyWithObject()
{
$class = new \stdClass();
$class->foo = 'bar';
$class->baz = null;

$this->assertSame("foo: bar\nbaz: \n", $this->dumper->dump($class, 2, flags: Yaml::DUMP_NULL_AS_EMPTY | Yaml::DUMP_OBJECT_AS_MAP));
}

public function testDumpNullAsEmptyDumpsWhenInInlineMapping()
{
$expected = "foo: \nqux: { foo: bar, baz: }\n";

$this->assertSame($expected, $this->dumper->dump(['foo' => null, 'qux' => ['foo' => 'bar', 'baz' => null]], 1, flags: Yaml::DUMP_NULL_AS_EMPTY));
}

public function testDumpNullAsEmptyDumpsNestedMaps()
{
$expected = "foo: \nqux:\n foo: bar\n baz: \n";

$this->assertSame($expected, $this->dumper->dump(['foo' => null, 'qux' => ['foo' => 'bar', 'baz' => null]], 10, flags: Yaml::DUMP_NULL_AS_EMPTY));
}

public function testDumpNullAsEmptyInExpandedSequence()
{
$expected = "qux:\n - foo\n - \n - bar\n";

$this->assertSame($expected, $this->dumper->dump(['qux' => ['foo', null, 'bar']], 2, flags: Yaml::DUMP_NULL_AS_EMPTY));
}

public function testDumpNullAsEmptyWhenInInlineSequence()
{
$expected = "foo: \nqux: [foo, , bar]\n";

$this->assertSame($expected, $this->dumper->dump(['foo' => null, 'qux' => ['foo', null, 'bar']], 1, flags: Yaml::DUMP_NULL_AS_EMPTY));
}

public function testDumpNullAsEmptyAtRoot()
{
$this->assertSame('null', $this->dumper->dump(null, 2, flags: Yaml::DUMP_NULL_AS_EMPTY));
}

/**
* @dataProvider getEscapeSequences
*/
Expand Down
27 changes: 27 additions & 0 deletions src/Symfony/Component/Yaml/Tests/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ public function testTopLevelNull()
$this->assertSameData($expected, $data);
}

public function testEmptyValueInExpandedMappingIsSupported()
{
$yml = <<<'YAML'
foo:
bar:
baz: qux
YAML;

$data = $this->parser->parse($yml);
$expected = ['foo' => ['bar' => null, 'baz' => 'qux']];
$this->assertSameData($expected, $data);
}

public function testEmptyValueInExpandedSequenceIsSupported()
{
$yml = <<<'YAML'
foo:
- bar
-
- baz
YAML;

$data = $this->parser->parse($yml);
$expected = ['foo' => ['bar', null, 'baz']];
$this->assertSameData($expected, $data);
}

public function testTaggedValueTopLevelNumber()
{
$yml = '!number 5';
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Yaml/Yaml.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Yaml
public const DUMP_EMPTY_ARRAY_AS_SEQUENCE = 1024;
public const DUMP_NULL_AS_TILDE = 2048;
public const DUMP_NUMERIC_KEY_AS_STRING = 4096;
public const DUMP_NULL_AS_EMPTY = 8192;

/**
* Parses a YAML file into a PHP value.
Expand Down