Merge branch '4.4'

* 4.4:
  [HttpKernel][FrameworkBundle] Add alternative convention for bundle directories
  [DI] deprecate support for non-object services
  [Translation] XliffLintCommand: allow .xliff file extension
  [Serializer] Encode empty objects as objects, not arrays
This commit is contained in:
Nicolas Grekas 2019-08-13 15:41:52 +02:00
commit b64fdb7c0a
48 changed files with 443 additions and 97 deletions

View File

@ -113,9 +113,33 @@ HttpFoundation
HttpKernel
----------
* Implementing the `BundleInterface` without implementing the `getPublicDir()` method is deprecated.
This method will be added to the interface in 5.0.
* The `DebugHandlersListener` class has been marked as `final`
* Added new Bundle directory convention consistent with standard skeletons:
```
└── MyBundle/
├── config/
├── public/
├── src/
│ └── MyBundle.php
├── templates/
└── translations/
```
To make this work properly, it is necessary to change the root path of the bundle:
```php
class MyBundle extends Bundle
{
public function getPath(): string
{
return \dirname(__DIR__);
}
}
```
As many bundles must be compatible with a range of Symfony versions, the current
directory convention is not deprecated yet, but it will be in the future.
Lock
----

View File

@ -291,7 +291,6 @@ HttpFoundation
HttpKernel
----------
* The `getPublicDir()` method has been added to the `BundleInterface`.
* Removed `Client`, use `HttpKernelBrowser` instead
* The `Kernel::getRootDir()` and the `kernel.root_dir` parameter have been removed
* The `KernelInterface::getName()` and the `kernel.name` parameter have been removed
@ -308,6 +307,32 @@ HttpKernel
* Removed `TranslatorListener` in favor of `LocaleAwareListener`
* The `DebugHandlersListener` class has been made `final`
* Removed `SaveSessionListener` in favor of `AbstractSessionListener`
* Added new Bundle directory convention consistent with standard skeletons:
```
└── MyBundle/
├── config/
├── public/
├── src/
│ └── MyBundle.php
├── templates/
└── translations/
```
To make this work properly, it is necessary to change the root path of the bundle:
```php
class MyBundle extends Bundle
{
public function getPath(): string
{
return \dirname(__DIR__);
}
}
```
As many bundles must be compatible with a range of Symfony versions, the current
directory convention is not deprecated yet, but it will be in the future.
Intl
----

View File

@ -48,11 +48,10 @@ class DoctrineValidationPass implements CompilerPassInterface
}
$files = $container->getParameter('validator.mapping.loader.'.$mapping.'_files_loader.mapping_files');
$validationPath = 'Resources/config/validation.'.$this->managerType.'.'.$extension;
$validationPath = '/config/validation.'.$this->managerType.'.'.$extension;
foreach ($container->getParameter('kernel.bundles') as $bundle) {
$reflection = new \ReflectionClass($bundle);
if ($container->fileExists($file = \dirname($reflection->getFileName()).'/'.$validationPath)) {
foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) {
if ($container->fileExists($file = $bundle['path'].'/Resources'.$validationPath) || $container->fileExists($file = $bundle['path'].$validationPath)) {
$files[] = $file;
}
}

View File

@ -133,7 +133,7 @@ EOT
$validAssetDirs = [];
/** @var BundleInterface $bundle */
foreach ($kernel->getBundles() as $bundle) {
if (!is_dir($originDir = $bundle->getPath().\DIRECTORY_SEPARATOR.ltrim($bundle->getPublicDir(), '\\/'))) {
if (!is_dir($originDir = $bundle->getPath().'/Resources/public') && !is_dir($originDir = $bundle->getPath().'/public')) {
continue;
}

View File

@ -140,11 +140,12 @@ EOF
if (null !== $input->getArgument('bundle')) {
try {
$bundle = $kernel->getBundle($input->getArgument('bundle'));
$transPaths = [$bundle->getPath().'/Resources/translations'];
$bundleDir = $bundle->getPath();
$transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundleDir.'/translations'];
$viewsPaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundleDir.'/templates'];
if ($this->defaultTransPath) {
$transPaths[] = $this->defaultTransPath;
}
$viewsPaths = [$bundle->getPath().'/Resources/views'];
if ($this->defaultViewsPath) {
$viewsPaths[] = $this->defaultViewsPath;
}
@ -161,8 +162,9 @@ EOF
}
} elseif ($input->getOption('all')) {
foreach ($kernel->getBundles() as $bundle) {
$transPaths[] = $bundle->getPath().'/Resources/translations';
$viewsPaths[] = $bundle->getPath().'/Resources/views';
$bundleDir = $bundle->getPath();
$transPaths[] = is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundle->getPath().'/translations';
$viewsPaths[] = is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundle->getPath().'/templates';
}
}

View File

@ -139,11 +139,12 @@ EOF
if (null !== $input->getArgument('bundle')) {
try {
$foundBundle = $kernel->getBundle($input->getArgument('bundle'));
$transPaths = [$foundBundle->getPath().'/Resources/translations'];
$bundleDir = $foundBundle->getPath();
$transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundleDir.'/translations'];
$viewsPaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundleDir.'/templates'];
if ($this->defaultTransPath) {
$transPaths[] = $this->defaultTransPath;
}
$viewsPaths = [$foundBundle->getPath().'/Resources/views'];
if ($this->defaultViewsPath) {
$viewsPaths[] = $this->defaultViewsPath;
}

View File

@ -1027,7 +1027,7 @@ class FrameworkExtension extends Extension
}
$defaultDir = $container->getParameterBag()->resolveValue($config['default_path']);
foreach ($container->getParameter('kernel.bundles_metadata') as $name => $bundle) {
if ($container->fileExists($dir = $bundle['path'].'/Resources/translations')) {
if ($container->fileExists($dir = $bundle['path'].'/Resources/translations') || $container->fileExists($dir = $bundle['path'].'/translations')) {
$dirs[] = $dir;
} else {
$nonExistingDirs[] = $dir;
@ -1167,20 +1167,20 @@ class FrameworkExtension extends Extension
}
foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) {
$dirname = $bundle['path'];
$configDir = is_dir($bundle['path'].'/Resources/config') ? $bundle['path'].'/Resources/config' : $bundle['path'].'/config';
if (
$container->fileExists($file = $dirname.'/Resources/config/validation.yaml', false) ||
$container->fileExists($file = $dirname.'/Resources/config/validation.yml', false)
$container->fileExists($file = $configDir.'/validation.yaml', false) ||
$container->fileExists($file = $configDir.'/validation.yml', false)
) {
$fileRecorder('yml', $file);
}
if ($container->fileExists($file = $dirname.'/Resources/config/validation.xml', false)) {
if ($container->fileExists($file = $configDir.'/validation.xml', false)) {
$fileRecorder('xml', $file);
}
if ($container->fileExists($dir = $dirname.'/Resources/config/validation', '/^$/')) {
if ($container->fileExists($dir = $configDir.'/validation', '/^$/')) {
$this->registerMappingFilesFromDir($dir, $fileRecorder);
}
}
@ -1352,20 +1352,20 @@ class FrameworkExtension extends Extension
};
foreach ($container->getParameter('kernel.bundles_metadata') as $bundle) {
$dirname = $bundle['path'];
$configDir = is_dir($bundle['path'].'/Resources/config') ? $bundle['path'].'/Resources/config' : $bundle['path'].'/config';
if ($container->fileExists($file = $dirname.'/Resources/config/serialization.xml', false)) {
if ($container->fileExists($file = $configDir.'/serialization.xml', false)) {
$fileRecorder('xml', $file);
}
if (
$container->fileExists($file = $dirname.'/Resources/config/serialization.yaml', false) ||
$container->fileExists($file = $dirname.'/Resources/config/serialization.yml', false)
$container->fileExists($file = $configDir.'/serialization.yaml', false) ||
$container->fileExists($file = $configDir.'/serialization.yml', false)
) {
$fileRecorder('yml', $file);
}
if ($container->fileExists($dir = $dirname.'/Resources/config/serialization', '/^$/')) {
if ($container->fileExists($dir = $configDir.'/serialization', '/^$/')) {
$this->registerMappingFilesFromDir($dir, $fileRecorder);
}
}

View File

@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\LegacyBundle\Entity;
class LegacyPerson
{
public $name;
public $age;
public function __construct(string $name, string $age)
{
$this->name = $name;
$this->age = $age;
}
}

View File

@ -0,0 +1,18 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\LegacyBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class LegacyBundle extends Bundle
{
}

View File

@ -0,0 +1,4 @@
Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\LegacyBundle\Entity\LegacyPerson:
attributes:
name:
serialized_name: 'full_name'

View File

@ -0,0 +1,5 @@
Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\LegacyBundle\Entity\LegacyPerson:
properties:
age:
- GreaterThan:
value: 18

View File

@ -0,0 +1,4 @@
Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\ModernBundle\src\Entity\ModernPerson:
attributes:
name:
serialized_name: 'full_name'

View File

@ -0,0 +1,5 @@
Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\ModernBundle\src\Entity\ModernPerson:
properties:
age:
- GreaterThan:
value: 18

View File

@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\ModernBundle\src\Entity;
class ModernPerson
{
public $name;
public $age;
public function __construct(string $name, string $age)
{
$this->name = $name;
$this->age = $age;
}
}

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\ModernBundle\src;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class ModernBundle extends Bundle
{
public function getPath(): string
{
return \dirname(__DIR__);
}
}

View File

@ -0,0 +1,84 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\FrameworkBundle\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Command\AssetsInstallCommand;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\LegacyBundle\Entity\LegacyPerson;
use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\ModernBundle\src\Entity\ModernPerson;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Filesystem;
class BundlePathsTest extends AbstractWebTestCase
{
public function testBundlePublicDir()
{
$kernel = static::bootKernel(['test_case' => 'BundlePaths']);
$projectDir = sys_get_temp_dir().'/'.uniqid('sf_bundle_paths', true);
$fs = new Filesystem();
$fs->mkdir($projectDir.'/public');
$command = (new Application($kernel))->add(new AssetsInstallCommand($fs, $projectDir));
$exitCode = (new CommandTester($command))->execute(['target' => $projectDir.'/public']);
$this->assertSame(0, $exitCode);
$this->assertFileExists($projectDir.'/public/bundles/modern/modern.css');
$this->assertFileExists($projectDir.'/public/bundles/legacy/legacy.css');
$fs->remove($projectDir);
}
public function testBundleTwigTemplatesDir()
{
static::bootKernel(['test_case' => 'BundlePaths']);
$twig = static::$container->get('twig');
$bundlesMetadata = static::$container->getParameter('kernel.bundles_metadata');
$this->assertSame([$bundlesMetadata['LegacyBundle']['path'].'/Resources/views'], $twig->getLoader()->getPaths('Legacy'));
$this->assertSame("OK\n", $twig->render('@Legacy/index.html.twig'));
$this->assertSame([$bundlesMetadata['ModernBundle']['path'].'/templates'], $twig->getLoader()->getPaths('Modern'));
$this->assertSame("OK\n", $twig->render('@Modern/index.html.twig'));
}
public function testBundleTranslationsDir()
{
static::bootKernel(['test_case' => 'BundlePaths']);
$translator = static::$container->get('translator');
$this->assertSame('OK', $translator->trans('ok_label', [], 'legacy'));
$this->assertSame('OK', $translator->trans('ok_label', [], 'modern'));
}
public function testBundleValidationConfigDir()
{
static::bootKernel(['test_case' => 'BundlePaths']);
$validator = static::$container->get('validator');
$this->assertTrue($validator->hasMetadataFor(LegacyPerson::class));
$this->assertCount(1, $constraintViolationList = $validator->validate(new LegacyPerson('john', 5)));
$this->assertSame('This value should be greater than 18.', $constraintViolationList->get(0)->getMessage());
$this->assertTrue($validator->hasMetadataFor(ModernPerson::class));
$this->assertCount(1, $constraintViolationList = $validator->validate(new ModernPerson('john', 5)));
$this->assertSame('This value should be greater than 18.', $constraintViolationList->get(0)->getMessage());
}
public function testBundleSerializationConfigDir()
{
static::bootKernel(['test_case' => 'BundlePaths']);
$serializer = static::$container->get('serializer');
$this->assertEquals(['full_name' => 'john', 'age' => 5], $serializer->normalize(new LegacyPerson('john', 5), 'json'));
$this->assertEquals(['full_name' => 'john', 'age' => 5], $serializer->normalize(new ModernPerson('john', 5), 'json'));
}
}

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\LegacyBundle\LegacyBundle;
use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\ModernBundle\src\ModernBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [
new FrameworkBundle(),
new TwigBundle(),
new ModernBundle(),
new LegacyBundle(),
];

View File

@ -0,0 +1,11 @@
imports:
- { resource: ../config/default.yml }
framework:
translator: true
validation: true
serializer: true
twig:
strict_variables: '%kernel.debug%'
exception_controller: ~

View File

@ -161,7 +161,7 @@ class TwigExtension extends Extension
}
$container->addResource(new FileExistenceResource($defaultOverrideBundlePath));
if (file_exists($dir = $bundle['path'].'/Resources/views')) {
if (file_exists($dir = $bundle['path'].'/Resources/views') || file_exists($dir = $bundle['path'].'/templates')) {
$bundleHierarchy[$name][] = $dir;
}
$container->addResource(new FileExistenceResource($dir));

View File

@ -56,9 +56,11 @@ class TemplateIterator implements \IteratorAggregate
$name = substr($name, 0, -6);
}
$bundleTemplatesDir = is_dir($bundle->getPath().'/Resources/views') ? $bundle->getPath().'/Resources/views' : $bundle->getPath().'/templates';
$templates = array_merge(
$templates,
$this->findTemplatesInDirectory($bundle->getPath().'/Resources/views', $name),
$this->findTemplatesInDirectory($bundleTemplatesDir, $name),
null !== $this->defaultPath ? $this->findTemplatesInDirectory($this->defaultPath.'/bundles/'.$bundle->getName(), $name) : []
);
}

View File

@ -140,10 +140,8 @@ class Container implements ContainerInterface, ResetInterface
*
* Setting a synthetic service to null resets it: has() returns false and get()
* behaves in the same way as if the service was never created.
*
* @param object $service The service instance
*/
public function set(string $id, $service)
public function set(string $id, ?object $service)
{
// Runs the internal initializer; used by the dumped container to include always-needed files
if (isset($this->privates['service_container']) && $this->privates['service_container'] instanceof \Closure) {
@ -209,7 +207,7 @@ class Container implements ContainerInterface, ResetInterface
* @param string $id The service identifier
* @param int $invalidBehavior The behavior when the service does not exist
*
* @return object The associated service
* @return object|null The associated service
*
* @throws ServiceCircularReferenceException When a circular reference is detected
* @throws ServiceNotFoundException When the service is not defined

View File

@ -479,11 +479,9 @@ class ContainerBuilder extends Container implements TaggedContainerInterface
/**
* Sets a service.
*
* @param object $service The service instance
*
* @throws BadMethodCallException When this ContainerBuilder is compiled
*/
public function set(string $id, $service)
public function set(string $id, ?object $service)
{
if ($this->isCompiled() && (isset($this->definitions[$id]) && !$this->definitions[$id]->isSynthetic())) {
// setting a synthetic service on a compiled container is alright
@ -526,7 +524,7 @@ class ContainerBuilder extends Container implements TaggedContainerInterface
* @param string $id The service identifier
* @param int $invalidBehavior The behavior when the service does not exist
*
* @return object The associated service
* @return object|null The associated service
*
* @throws InvalidArgumentException when no definitions are available
* @throws ServiceCircularReferenceException When a circular reference is detected

View File

@ -32,10 +32,8 @@ interface ContainerInterface extends PsrContainerInterface
/**
* Sets a service.
*
* @param object $service The service instance
*/
public function set(string $id, $service);
public function set(string $id, ?object $service);
/**
* Gets a service.

View File

@ -1573,16 +1573,17 @@ class ContainerBuilderTest extends TestCase
public function testScalarService()
{
$c = new ContainerBuilder();
$c->register('foo', 'string')
->setPublic(true)
$container = new ContainerBuilder();
$container->register('foo', 'string')
->setFactory([ScalarFactory::class, 'getSomeValue'])
;
$container->register('bar', 'stdClass')
->setProperty('foo', new Reference('foo'))
->setPublic(true)
;
$container->compile();
$c->compile();
$this->assertTrue($c->has('foo'));
$this->assertSame('some value', $c->get('foo'));
$this->assertSame('some value', $container->get('bar')->foo);
}
public function testWither()

View File

@ -288,16 +288,6 @@ class ContainerTest extends TestCase
$this->assertTrue($sc->has('foo.baz'), '->has() returns true if a get*Method() is defined');
}
public function testScalarService()
{
$c = new Container();
$c->set('foo', 'some value');
$this->assertTrue($c->has('foo'));
$this->assertSame('some value', $c->get('foo'));
}
public function testInitialized()
{
$sc = new ProjectServiceContainer();

View File

@ -1282,10 +1282,12 @@ class PhpDumperTest extends TestCase
{
$container = new ContainerBuilder();
$container->register('foo', 'string')
->setPublic(true)
->setFactory([ScalarFactory::class, 'getSomeValue'])
;
$container->register('bar', 'stdClass')
->setProperty('foo', new Reference('foo'))
->setPublic(true)
;
$container->compile();
$dumper = new PhpDumper($container);
@ -1293,8 +1295,7 @@ class PhpDumperTest extends TestCase
$container = new \Symfony_DI_PhpDumper_Test_Scalar_Service();
$this->assertTrue($container->has('foo'));
$this->assertSame('some value', $container->get('foo'));
$this->assertSame('some value', $container->get('bar')->foo);
}
public function testWither()

View File

@ -133,11 +133,6 @@ abstract class Bundle implements BundleInterface
{
}
public function getPublicDir(): string
{
return 'Resources/public';
}
/**
* Returns the bundle's container extension class.
*

View File

@ -68,9 +68,4 @@ interface BundleInterface extends ContainerAwareInterface
* @return string The Bundle absolute path
*/
public function getPath();
/**
* Returns relative path to the public assets directory.
*/
public function getPublicDir(): string;
}

View File

@ -4,7 +4,6 @@ CHANGELOG
5.0.0
-----
* added the `getPublicDir()` method to `BundleInterface`.
* removed the first and second constructor argument of `ConfigDataCollector`
* removed `ConfigDataCollector::getApplicationName()`
* removed `ConfigDataCollector::getApplicationVersion()`
@ -27,9 +26,8 @@ CHANGELOG
4.4.0
-----
* Implementing the `BundleInterface` without implementing the `getPublicDir()` method is deprecated.
This method will be added to the interface in 5.0.
* The `DebugHandlersListener` class has been marked as `final`
* Added new Bundle directory convention consistent with standard skeletons
4.3.0
-----

View File

@ -306,7 +306,7 @@ class RegisterControllerArgumentLocatorsPassTest extends TestCase
public function testBindScalarValueToControllerArgument($bindingKey)
{
$container = new ContainerBuilder();
$resolver = $container->register('argument_resolver.service')->addArgument([]);
$resolver = $container->register('argument_resolver.service', 'stdClass')->addArgument([]);
$container->register('foo', ArgumentWithoutTypeController::class)
->setBindings([$bindingKey => '%foo%'])
@ -317,19 +317,13 @@ class RegisterControllerArgumentLocatorsPassTest extends TestCase
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
$locatorId = (string) $resolver->getArgument(0);
$container->getDefinition($locatorId)->setPublic(true);
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
$container->compile();
// assert the locator has a someArg key
$arguments = $locator->getArgument(0);
$this->assertArrayHasKey('someArg', $arguments);
$this->assertInstanceOf(ServiceClosureArgument::class, $arguments['someArg']);
// get the Reference that someArg points to
$reference = $arguments['someArg']->getValues()[0];
// make sure this service *does* exist and returns the correct value
$this->assertTrue($container->has((string) $reference));
$this->assertSame('foo_val', $container->get((string) $reference));
$locator = $container->get($locatorId);
$this->assertSame('foo_val', $locator->get('foo::fooAction')->get('someArg'));
}
public function provideBindScalarValueToControllerArgument()

View File

@ -584,7 +584,7 @@ EOF;
{
$bundle = $this
->getMockBuilder('Symfony\Component\HttpKernel\Bundle\BundleInterface')
->setMethods(['getPath', 'getPublicDir', 'getParent', 'getName'])
->setMethods(['getPath', 'getParent', 'getName'])
->disableOriginalConstructor()
;
@ -606,12 +606,6 @@ EOF;
->willReturn($dir)
;
$bundle
->expects($this->any())
->method('getPublicDir')
->willReturn('Resources/public')
;
$bundle
->expects($this->any())
->method('getParent')

View File

@ -55,7 +55,7 @@ class CsvEncoder implements EncoderInterface, DecoderInterface
{
$handle = fopen('php://temp,', 'w+');
if (!\is_array($data)) {
if (!is_iterable($data)) {
$data = [[$data]];
} elseif (empty($data)) {
$data = [[]];
@ -192,10 +192,10 @@ class CsvEncoder implements EncoderInterface, DecoderInterface
/**
* Flattens an array and generates keys including the path.
*/
private function flatten(array $array, array &$result, string $keySeparator, string $parentKey = '', bool $escapeFormulas = false)
private function flatten(iterable $array, array &$result, string $keySeparator, string $parentKey = '', bool $escapeFormulas = false)
{
foreach ($array as $key => $value) {
if (\is_array($value)) {
if (is_iterable($value)) {
$this->flatten($value, $result, $keySeparator, $parentKey.$key.$keySeparator, $escapeFormulas);
} else {
if ($escapeFormulas && \in_array(substr((string) $value, 0, 1), $this->formulasStartCharacters, true)) {
@ -228,7 +228,7 @@ class CsvEncoder implements EncoderInterface, DecoderInterface
/**
* @return string[]
*/
private function extractHeaders(array $data): array
private function extractHeaders(iterable $data): array
{
$headers = [];
$flippedHeaders = [];

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Serializer\Encoder;
use Symfony\Component\Serializer\Exception\RuntimeException;
use Symfony\Component\Yaml\Dumper;
use Symfony\Component\Yaml\Parser;
use Symfony\Component\Yaml\Yaml;
/**
* Encodes YAML data.
@ -25,6 +26,8 @@ class YamlEncoder implements EncoderInterface, DecoderInterface
const FORMAT = 'yaml';
private const ALTERNATIVE_FORMAT = 'yml';
public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects';
private $dumper;
private $parser;
private $defaultContext = ['yaml_inline' => 0, 'yaml_indent' => 0, 'yaml_flags' => 0];
@ -47,6 +50,10 @@ class YamlEncoder implements EncoderInterface, DecoderInterface
{
$context = array_merge($this->defaultContext, $context);
if (isset($context[self::PRESERVE_EMPTY_OBJECTS])) {
$context['yaml_flags'] |= Yaml::DUMP_OBJECT_AS_MAP;
}
return $this->dumper->dump($data, $context['yaml_inline'], $context['yaml_indent'], $context['yaml_flags']);
}

View File

@ -88,6 +88,8 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
*/
public const DEEP_OBJECT_TO_POPULATE = 'deep_object_to_populate';
public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects';
private $propertyTypeExtractor;
private $typesCache = [];
private $attributesCache = [];
@ -199,6 +201,10 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
$data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $this->createChildContext($context, $attribute, $format)), $class, $format, $context);
}
if (isset($context[self::PRESERVE_EMPTY_OBJECTS]) && !\count($data)) {
return new \ArrayObject();
}
return $data;
}

View File

@ -30,7 +30,7 @@ interface NormalizerInterface
* @param string $format Format the normalization result will be encoded as
* @param array $context Context options for the normalizer
*
* @return array|string|int|float|bool
* @return array|string|int|float|bool|\ArrayObject \ArrayObject is used to make sure an empty object is encoded as an object not an array
*
* @throws InvalidArgumentException Occurs when the object given is not an attempted type for the normalizer
* @throws CircularReferenceException Occurs when the normalizer detects a circular reference when no circular

View File

@ -311,6 +311,43 @@ CSV
]));
}
public function testEncodeArrayObject()
{
$value = new \ArrayObject(['foo' => 'hello', 'bar' => 'hey ho']);
$this->assertEquals(<<<'CSV'
foo,bar
hello,"hey ho"
CSV
, $this->encoder->encode($value, 'csv'));
$value = new \ArrayObject();
$this->assertEquals("\n", $this->encoder->encode($value, 'csv'));
}
public function testEncodeNestedArrayObject()
{
$value = new \ArrayObject(['foo' => new \ArrayObject(['nested' => 'value']), 'bar' => new \ArrayObject(['another' => 'word'])]);
$this->assertEquals(<<<'CSV'
foo.nested,bar.another
value,word
CSV
, $this->encoder->encode($value, 'csv'));
}
public function testEncodeEmptyArrayObject()
{
$value = new \ArrayObject();
$this->assertEquals("\n", $this->encoder->encode($value, 'csv'));
$value = ['foo' => new \ArrayObject()];
$this->assertEquals("\n\n", $this->encoder->encode($value, 'csv'));
}
public function testSupportsDecoding()
{
$this->assertTrue($this->encoder->supportsDecoding('csv'));

View File

@ -46,6 +46,8 @@ class JsonEncodeTest extends TestCase
return [
[[], '[]', []],
[[], '{}', ['json_encode_options' => JSON_FORCE_OBJECT]],
[new \ArrayObject(), '{}', []],
[new \ArrayObject(['foo' => 'bar']), '{"foo":"bar"}', []],
];
}

View File

@ -48,6 +48,26 @@ class XmlEncoderTest extends TestCase
$this->assertEquals($expected, $this->encoder->encode($obj, 'xml'));
}
public function testEncodeArrayObject()
{
$obj = new \ArrayObject(['foo' => 'bar']);
$expected = '<?xml version="1.0"?>'."\n".
'<response><foo>bar</foo></response>'."\n";
$this->assertEquals($expected, $this->encoder->encode($obj, 'xml'));
}
public function testEncodeEmptyArrayObject()
{
$obj = new \ArrayObject();
$expected = '<?xml version="1.0"?>'."\n".
'<response/>'."\n";
$this->assertEquals($expected, $this->encoder->encode($obj, 'xml'));
}
public function testDocTypeIsNotAllowed()
{
$this->expectException('Symfony\Component\Serializer\Exception\UnexpectedValueException');

View File

@ -28,6 +28,8 @@ class YamlEncoderTest extends TestCase
$this->assertEquals('foo', $encoder->encode('foo', 'yaml'));
$this->assertEquals('{ foo: 1 }', $encoder->encode(['foo' => 1], 'yaml'));
$this->assertEquals('null', $encoder->encode(new \ArrayObject(['foo' => 1]), 'yaml'));
$this->assertEquals('{ foo: 1 }', $encoder->encode(new \ArrayObject(['foo' => 1]), 'yaml', ['preserve_empty_objects' => true]));
}
public function testSupportsEncoding()

View File

@ -198,12 +198,25 @@ class AbstractObjectNormalizerTest extends TestCase
'allow_extra_attributes' => false,
]);
}
public function testNormalizeEmptyObject()
{
$normalizer = new AbstractObjectNormalizerDummy();
// This results in objects turning into arrays in some encoders
$normalizedData = $normalizer->normalize(new EmptyDummy());
$this->assertEquals([], $normalizedData);
$normalizedData = $normalizer->normalize(new EmptyDummy(), 'any', ['preserve_empty_objects' => true]);
$this->assertEquals(new \ArrayObject(), $normalizedData);
}
}
class AbstractObjectNormalizerDummy extends AbstractObjectNormalizer
{
protected function extractAttributes($object, $format = null, array $context = [])
{
return [];
}
protected function getAttributeValue($object, $attribute, $format = null, array $context = [])
@ -233,6 +246,10 @@ class Dummy
public $baz;
}
class EmptyDummy
{
}
class AbstractObjectNormalizerWithMetadata extends AbstractObjectNormalizer
{
public function __construct()

View File

@ -192,6 +192,19 @@ class SerializerTest extends TestCase
$this->assertEquals(json_encode($data), $result);
}
public function testSerializeEmpty()
{
$serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]);
$data = ['foo' => new \stdClass()];
//Old buggy behaviour
$result = $serializer->serialize($data, 'json');
$this->assertEquals('{"foo":[]}', $result);
$result = $serializer->serialize($data, 'json', ['preserve_empty_objects' => true]);
$this->assertEquals('{"foo":{}}', $result);
}
public function testSerializeNoEncoder()
{
$this->expectException('Symfony\Component\Serializer\Exception\UnexpectedValueException');

View File

@ -126,7 +126,7 @@ EOF
// otherwise, both '____.locale.xlf' and 'locale.____.xlf' are allowed
// also, the regexp matching must be case-insensitive, as defined for 'target-language' values
// http://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#target-language
$expectedFilenamePattern = $this->requireStrictFileNames ? sprintf('/^.*\.(?i:%s)\.xlf/', $normalizedLocale) : sprintf('/^(.*\.(?i:%s)\.xlf|(?i:%s)\..*\.xlf)/', $normalizedLocale, $normalizedLocale);
$expectedFilenamePattern = $this->requireStrictFileNames ? sprintf('/^.*\.(?i:%s)\.(?:xlf|xliff)/', $normalizedLocale) : sprintf('/^(?:.*\.(?i:%s)|(?i:%s)\..*)\.(?:xlf|xliff)/', $normalizedLocale, $normalizedLocale);
if (0 === preg_match($expectedFilenamePattern, basename($file))) {
$errors[] = [