Merge branch '4.4'

* 4.4:
  [Messenger] Perform no deep merging of bus middleware
  [HttpFoundation] Added possibility to configure expiration time in redis session handler
  [FrameworkBundle] Remove project dir from Translator cache vary scanned directories
  [HttpFoundation] Allow redirecting to URLs that contain a semicolon
  Drop useless executable bit
  [DoctrineBridge] Improve queries parameters display in Profiler
  catch exceptions when using PDO directly
  [SecurityBundle] fix failing test
This commit is contained in:
Nicolas Grekas 2019-11-17 11:12:24 +01:00
commit 3d222c6cd5
16 changed files with 312 additions and 45 deletions

View File

@ -18,6 +18,8 @@ use Doctrine\DBAL\Types\Type;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\VarDumper\Caster\Caster;
use Symfony\Component\VarDumper\Cloner\Stub;
/**
* DoctrineDataCollector.
@ -117,6 +119,38 @@ class DoctrineDataCollector extends DataCollector
return 'db';
}
/**
* {@inheritdoc}
*/
protected function getCasters()
{
return parent::getCasters() + [
ObjectParameter::class => static function (ObjectParameter $o, array $a, Stub $s): array {
$s->class = $o->getClass();
$s->value = $o->getObject();
$r = new \ReflectionClass($o->getClass());
if ($f = $r->getFileName()) {
$s->attr['file'] = $f;
$s->attr['line'] = $r->getStartLine();
} else {
unset($s->attr['file']);
unset($s->attr['line']);
}
if ($error = $o->getError()) {
return [Caster::PREFIX_VIRTUAL.'⚠' => $error->getMessage()];
}
if ($o->isStringable()) {
return [Caster::PREFIX_VIRTUAL.'__toString()' => (string) $o->getObject()];
}
return [Caster::PREFIX_VIRTUAL.'⚠' => sprintf('Object of class "%s" could not be converted to string.', $o->getClass())];
},
];
}
private function sanitizeQueries(string $connectionName, array $queries): array
{
foreach ($queries as $i => $query) {
@ -129,6 +163,7 @@ class DoctrineDataCollector extends DataCollector
private function sanitizeQuery(string $connectionName, array $query): array
{
$query['explainable'] = true;
$query['runnable'] = true;
if (null === $query['params']) {
$query['params'] = [];
}
@ -139,6 +174,7 @@ class DoctrineDataCollector extends DataCollector
$query['types'] = [];
}
foreach ($query['params'] as $j => $param) {
$e = null;
if (isset($query['types'][$j])) {
// Transform the param according to the type
$type = $query['types'][$j];
@ -150,18 +186,19 @@ class DoctrineDataCollector extends DataCollector
try {
$param = $type->convertToDatabaseValue($param, $this->registry->getConnection($connectionName)->getDatabasePlatform());
} catch (\TypeError $e) {
// Error thrown while processing params, query is not explainable.
$query['explainable'] = false;
} catch (ConversionException $e) {
$query['explainable'] = false;
}
}
}
list($query['params'][$j], $explainable) = $this->sanitizeParam($param);
list($query['params'][$j], $explainable, $runnable) = $this->sanitizeParam($param, $e);
if (!$explainable) {
$query['explainable'] = false;
}
if (!$runnable) {
$query['runnable'] = false;
}
}
$query['params'] = $this->cloneVar($query['params']);
@ -176,32 +213,33 @@ class DoctrineDataCollector extends DataCollector
* indicating if the original value was kept (allowing to use the sanitized
* value to explain the query).
*/
private function sanitizeParam($var): array
private function sanitizeParam($var, ?\Throwable $error): array
{
if (\is_object($var)) {
$className = \get_class($var);
return [$o = new ObjectParameter($var, $error), false, $o->isStringable() && !$error];
}
return method_exists($var, '__toString') ?
[sprintf('/* Object(%s): */"%s"', $className, $var->__toString()), false] :
[sprintf('/* Object(%s) */', $className), false];
if ($error) {
return ['⚠ '.$error->getMessage(), false, false];
}
if (\is_array($var)) {
$a = [];
$original = true;
$explainable = $runnable = true;
foreach ($var as $k => $v) {
list($value, $orig) = $this->sanitizeParam($v);
$original = $original && $orig;
list($value, $e, $r) = $this->sanitizeParam($v, null);
$explainable = $explainable && $e;
$runnable = $runnable && $r;
$a[$k] = $value;
}
return [$a, $original];
return [$a, $explainable, $runnable];
}
if (\is_resource($var)) {
return [sprintf('/* Resource(%s) */', get_resource_type($var)), false];
return [sprintf('/* Resource(%s) */', get_resource_type($var)), false, false];
}
return [$var, true];
return [$var, true, true];
}
}

View File

@ -0,0 +1,54 @@
<?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\Bridge\Doctrine\DataCollector;
final class ObjectParameter
{
private $object;
private $error;
private $stringable;
private $class;
/**
* @param object $object
*/
public function __construct($object, ?\Throwable $error)
{
$this->object = $object;
$this->error = $error;
$this->stringable = \is_callable([$object, '__toString']);
$this->class = \get_class($object);
}
/**
* @return object
*/
public function getObject()
{
return $this->object;
}
public function getError(): ?\Throwable
{
return $this->error;
}
public function isStringable(): bool
{
return $this->stringable;
}
public function getClass(): string
{
return $this->class;
}
}

View File

@ -18,6 +18,7 @@ use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\VarDumper\Cloner\Data;
use Symfony\Component\VarDumper\Dumper\CliDumper;
class DoctrineDataCollectorTest extends TestCase
{
@ -74,7 +75,7 @@ class DoctrineDataCollectorTest extends TestCase
/**
* @dataProvider paramProvider
*/
public function testCollectQueries($param, $types, $expected, $explainable)
public function testCollectQueries($param, $types, $expected, $explainable, bool $runnable = true)
{
$queries = [
['sql' => 'SELECT * FROM table1 WHERE field1 = ?1', 'params' => [$param], 'types' => $types, 'executionMS' => 1],
@ -83,8 +84,19 @@ class DoctrineDataCollectorTest extends TestCase
$c->collect(new Request(), new Response());
$collectedQueries = $c->getQueries();
$this->assertEquals($expected, $collectedQueries['default'][0]['params'][0]);
$collectedParam = $collectedQueries['default'][0]['params'][0];
if ($collectedParam instanceof Data) {
$dumper = new CliDumper($out = fopen('php://memory', 'r+b'));
$dumper->setColors(false);
$collectedParam->dump($dumper);
$this->assertStringMatchesFormat($expected, print_r(stream_get_contents($out, -1, 0), true));
} else {
$this->assertEquals($expected, $collectedParam);
}
$this->assertEquals($explainable, $collectedQueries['default'][0]['explainable']);
$this->assertSame($runnable, $collectedQueries['default'][0]['runnable']);
}
public function testCollectQueryWithNoParams()
@ -100,9 +112,11 @@ class DoctrineDataCollectorTest extends TestCase
$this->assertInstanceOf(Data::class, $collectedQueries['default'][0]['params']);
$this->assertEquals([], $collectedQueries['default'][0]['params']->getValue());
$this->assertTrue($collectedQueries['default'][0]['explainable']);
$this->assertTrue($collectedQueries['default'][0]['runnable']);
$this->assertInstanceOf(Data::class, $collectedQueries['default'][1]['params']);
$this->assertEquals([], $collectedQueries['default'][1]['params']->getValue());
$this->assertTrue($collectedQueries['default'][1]['explainable']);
$this->assertTrue($collectedQueries['default'][1]['runnable']);
}
public function testCollectQueryWithNoTypes()
@ -134,7 +148,7 @@ class DoctrineDataCollectorTest extends TestCase
/**
* @dataProvider paramProvider
*/
public function testSerialization($param, $types, $expected, $explainable)
public function testSerialization($param, $types, $expected, $explainable, bool $runnable = true)
{
$queries = [
['sql' => 'SELECT * FROM table1 WHERE field1 = ?1', 'params' => [$param], 'types' => $types, 'executionMS' => 1],
@ -144,8 +158,19 @@ class DoctrineDataCollectorTest extends TestCase
$c = unserialize(serialize($c));
$collectedQueries = $c->getQueries();
$this->assertEquals($expected, $collectedQueries['default'][0]['params'][0]);
$collectedParam = $collectedQueries['default'][0]['params'][0];
if ($collectedParam instanceof Data) {
$dumper = new CliDumper($out = fopen('php://memory', 'r+b'));
$dumper->setColors(false);
$collectedParam->dump($dumper);
$this->assertStringMatchesFormat($expected, print_r(stream_get_contents($out, -1, 0), true));
} else {
$this->assertEquals($expected, $collectedParam);
}
$this->assertEquals($explainable, $collectedQueries['default'][0]['explainable']);
$this->assertSame($runnable, $collectedQueries['default'][0]['runnable']);
}
public function paramProvider()
@ -156,19 +181,46 @@ class DoctrineDataCollectorTest extends TestCase
[true, [], true, true],
[null, [], null, true],
[new \DateTime('2011-09-11'), ['date'], '2011-09-11', true],
[fopen(__FILE__, 'r'), [], '/* Resource(stream) */', false],
[new \stdClass(), [], '/* Object(stdClass) */', false],
[fopen(__FILE__, 'r'), [], '/* Resource(stream) */', false, false],
[
new \stdClass(),
[],
<<<EOTXT
{#%d
: "Object of class "stdClass" could not be converted to string."
}
EOTXT
,
false,
false,
],
[
new StringRepresentableClass(),
[],
'/* Object(Symfony\Bridge\Doctrine\Tests\DataCollector\StringRepresentableClass): */"string representation"',
<<<EOTXT
Symfony\Bridge\Doctrine\Tests\DataCollector\StringRepresentableClass {#%d
__toString(): "string representation"
}
EOTXT
,
false,
],
];
if (version_compare(Version::VERSION, '2.6', '>=')) {
$tests[] = ['this is not a date', ['date'], 'this is not a date', false];
$tests[] = [new \stdClass(), ['date'], '/* Object(stdClass) */', false];
$tests[] = ['this is not a date', ['date'], "⚠ Could not convert PHP value 'this is not a date' of type 'string' to type 'date'. Expected one of the following types: null, DateTime", false, false];
$tests[] = [
new \stdClass(),
['date'],
<<<EOTXT
{#%d
: "Could not convert PHP value of type 'stdClass' to type 'date'. Expected one of the following types: null, DateTime"
}
EOTXT
,
false,
false,
];
}
return $tests;

View File

@ -38,10 +38,11 @@ CHANGELOG
* Overriding the methods `KernelTestCase::tearDown()` and `WebTestCase::tearDown()` without the `void` return-type is deprecated.
* Added new `error_controller` configuration to handle system exceptions
* Added sort option for `translation:update` command.
* [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore.
* [BC Break] The `framework.messenger.routing.senders` config key is not deeply merged anymore.
* Added `secrets:*` commands and `%env(secret:...)%` processor to deal with secrets seamlessly.
* Made `framework.session.handler_id` accept a DSN
* Marked the `RouterDataCollector` class as `@final`.
* [BC Break] The `framework.messenger.buses.<name>.middleware` config key is not deeply merged anymore.
4.3.0
-----

View File

@ -1156,6 +1156,7 @@ class Configuration implements ConfigurationInterface
->defaultTrue()
->end()
->arrayNode('middleware')
->performNoDeepMerging()
->beforeNormalization()
->ifTrue(function ($v) { return \is_string($v) || (\is_array($v) && !\is_int(key($v))); })
->then(function ($v) { return [$v]; })

View File

@ -1115,11 +1115,18 @@ class FrameworkExtension extends Extension
$files[$locale][] = (string) $file;
}
$projectDir = $container->getParameter('kernel.project_dir');
$options = array_merge(
$translator->getArgument(4),
[
'resource_files' => $files,
'scanned_directories' => array_merge($dirs, $nonExistingDirs),
'scanned_directories' => $scannedDirectories = array_merge($dirs, $nonExistingDirs),
'cache_vary' => [
'scanned_directories' => array_map(static function (string $dir) use ($projectDir): string {
return 0 === strpos($dir, $projectDir.'/') ? substr($dir, 1 + \strlen($projectDir)) : $dir;
}, $scannedDirectories),
],
]
);

View File

@ -256,6 +256,64 @@ class ConfigurationTest extends TestCase
]);
}
public function testBusMiddlewareDontMerge()
{
$processor = new Processor();
$configuration = new Configuration(true);
$config = $processor->processConfiguration($configuration, [
[
'messenger' => [
'default_bus' => 'existing_bus',
'buses' => [
'existing_bus' => [
'middleware' => 'existing_bus.middleware',
],
'common_bus' => [
'default_middleware' => false,
'middleware' => 'common_bus.old_middleware',
],
],
],
],
[
'messenger' => [
'buses' => [
'common_bus' => [
'middleware' => 'common_bus.new_middleware',
],
'new_bus' => [
'middleware' => 'new_bus.middleware',
],
],
],
],
]);
$this->assertEquals(
[
'existing_bus' => [
'default_middleware' => true,
'middleware' => [
['id' => 'existing_bus.middleware', 'arguments' => []],
],
],
'common_bus' => [
'default_middleware' => false,
'middleware' => [
['id' => 'common_bus.new_middleware', 'arguments' => []],
],
],
'new_bus' => [
'default_middleware' => true,
'middleware' => [
['id' => 'new_bus.middleware', 'arguments' => []],
],
],
],
$config['messenger']['buses']
);
}
protected static function getBundleDefaultConfig()
{
return [

View File

@ -738,6 +738,8 @@ abstract class FrameworkExtensionTest extends TestCase
);
$this->assertNotEmpty($nonExistingDirectories, 'FrameworkBundle should pass non existing directories to Translator');
$this->assertSame('Fixtures/translations', $options['cache_vary']['scanned_directories'][3]);
}
public function testTranslatorMultipleFallbacks()

View File

@ -217,8 +217,10 @@ class TranslatorTest extends TestCase
__DIR__.'/../Fixtures/Resources/translations/messages.fr.yml',
],
],
'scanned_directories' => [
__DIR__.'/../Fixtures/Resources/translations/',
'cache_vary' => [
'scanned_directories' => [
'/Fixtures/Resources/translations/',
],
],
], 'yml');
@ -233,9 +235,11 @@ class TranslatorTest extends TestCase
__DIR__.'/../Fixtures/Resources/translations2/ccc.fr.yml',
],
],
'scanned_directories' => [
__DIR__.'/../Fixtures/Resources/translations/',
__DIR__.'/../Fixtures/Resources/translations2/',
'cache_vary' => [
'scanned_directories' => [
'/Fixtures/Resources/translations/',
'/Fixtures/Resources/translations2/',
],
],
], 'yml');

View File

@ -34,6 +34,7 @@ class Translator extends BaseTranslator implements WarmableInterface
'debug' => false,
'resource_files' => [],
'scanned_directories' => [],
'cache_vary' => [],
];
/**
@ -61,9 +62,10 @@ class Translator extends BaseTranslator implements WarmableInterface
*
* Available options:
*
* * cache_dir: The cache directory (or null to disable caching)
* * debug: Whether to enable debugging or not (false by default)
* * cache_dir: The cache directory (or null to disable caching)
* * debug: Whether to enable debugging or not (false by default)
* * resource_files: List of translation resources available grouped by locale.
* * cache_vary: An array of data that is serialized to generate the cached catalogue name.
*
* @throws InvalidArgumentException
*/
@ -82,9 +84,7 @@ class Translator extends BaseTranslator implements WarmableInterface
$this->resourceFiles = $this->options['resource_files'];
$this->scannedDirectories = $this->options['scanned_directories'];
parent::__construct($defaultLocale, $formatter, $this->options['cache_dir'], $this->options['debug'], [
'scanned_directories' => $this->scannedDirectories,
]);
parent::__construct($defaultLocale, $formatter, $this->options['cache_dir'], $this->options['debug'], $this->options['cache_vary']);
}
/**

View File

@ -184,6 +184,8 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface
$delete = $this->getConnection()->prepare($deleteSql);
} catch (TableNotFoundException $e) {
return true;
} catch (\PDOException $e) {
return true;
}
$delete->bindValue(':time', time(), \PDO::PARAM_INT);
@ -194,6 +196,8 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface
return $delete->execute();
} catch (TableNotFoundException $e) {
return true;
} catch (\PDOException $e) {
return true;
}
}
@ -269,6 +273,7 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface
try {
$conn->exec($sql);
} catch (TableNotFoundException $e) {
} catch (\PDOException $e) {
}
return true;
@ -285,6 +290,7 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface
$stmt = $this->getConnection()->prepare($sql);
$stmt->execute(array_values($ids));
} catch (TableNotFoundException $e) {
} catch (\PDOException $e) {
}
return true;
@ -341,6 +347,11 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface
$this->createTable();
}
$stmt = $conn->prepare($sql);
} catch (\PDOException $e) {
if (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) {
$this->createTable();
}
$stmt = $conn->prepare($sql);
}
if ('sqlsrv' === $driver || 'oci' === $driver) {
@ -375,6 +386,11 @@ class PdoAdapter extends AbstractAdapter implements PruneableInterface
$this->createTable();
}
$stmt->execute();
} catch (\PDOException $e) {
if (!$conn->inTransaction() || \in_array($this->driver, ['pgsql', 'sqlite', 'sqlsrv'], true)) {
$this->createTable();
}
$stmt->execute();
}
if (null === $driver && !$stmt->rowCount()) {
try {

View File

@ -89,7 +89,7 @@ class RedirectResponse extends Response
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="refresh" content="0;url=%1$s" />
<meta http-equiv="refresh" content="0;url=\'%1$s\'" />
<title>Redirecting to %1$s</title>
</head>

View File

@ -30,9 +30,15 @@ class RedisSessionHandler extends AbstractSessionHandler
*/
private $prefix;
/**
* @var int Time to live in seconds
*/
private $ttl;
/**
* List of available options:
* * prefix: The prefix to use for the keys in order to avoid collision on the Redis server.
* * prefix: The prefix to use for the keys in order to avoid collision on the Redis server
* * ttl: The time to live in seconds.
*
* @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface|RedisProxy|RedisClusterProxy $redis
*
@ -51,12 +57,13 @@ class RedisSessionHandler extends AbstractSessionHandler
throw new \InvalidArgumentException(sprintf('%s() expects parameter 1 to be Redis, RedisArray, RedisCluster or Predis\ClientInterface, %s given', __METHOD__, \is_object($redis) ? \get_class($redis) : \gettype($redis)));
}
if ($diff = array_diff(array_keys($options), ['prefix'])) {
if ($diff = array_diff(array_keys($options), ['prefix', 'ttl'])) {
throw new \InvalidArgumentException(sprintf('The following options are not supported "%s"', implode(', ', $diff)));
}
$this->redis = $redis;
$this->prefix = $options['prefix'] ?? 'sf_s';
$this->ttl = $options['ttl'] ?? (int) ini_get('session.gc_maxlifetime');
}
/**
@ -72,7 +79,7 @@ class RedisSessionHandler extends AbstractSessionHandler
*/
protected function doWrite(string $sessionId, string $data): bool
{
$result = $this->redis->setEx($this->prefix.$sessionId, (int) ini_get('session.gc_maxlifetime'), $data);
$result = $this->redis->setEx($this->prefix.$sessionId, $this->ttl, $data);
return $result && !$result instanceof ErrorInterface;
}
@ -108,6 +115,6 @@ class RedisSessionHandler extends AbstractSessionHandler
*/
public function updateTimestamp($sessionId, $data)
{
return (bool) $this->redis->expire($this->prefix.$sessionId, (int) ini_get('session.gc_maxlifetime'));
return (bool) $this->redis->expire($this->prefix.$sessionId, $this->ttl);
}
}

View File

@ -20,10 +20,7 @@ class RedirectResponseTest extends TestCase
{
$response = new RedirectResponse('foo.bar');
$this->assertEquals(1, preg_match(
'#<meta http-equiv="refresh" content="\d+;url=foo\.bar" />#',
preg_replace(['/\s+/', '/\'/'], [' ', '"'], $response->getContent())
));
$this->assertRegExp('#<meta http-equiv="refresh" content="\d+;url=\'foo\.bar\'" />#', preg_replace('/\s+/', ' ', $response->getContent()));
}
public function testRedirectResponseConstructorEmptyUrl()

View File

@ -139,7 +139,37 @@ abstract class AbstractRedisSessionHandlerTestCase extends TestCase
{
return [
[['prefix' => 'session'], true],
[['ttl' => 1000], true],
[['prefix' => 'sfs', 'ttl' => 1000], true],
[['prefix' => 'sfs', 'foo' => 'bar'], false],
[['ttl' => 'sfs', 'foo' => 'bar'], false],
];
}
/**
* @dataProvider getTtlFixtures
*/
public function testUseTtlOption(int $ttl)
{
$options = [
'prefix' => self::PREFIX,
'ttl' => $ttl,
];
$handler = new RedisSessionHandler($this->redisClient, $options);
$handler->write('id', 'data');
$redisTtl = $this->redisClient->ttl(self::PREFIX.'id');
$this->assertLessThan($redisTtl, $ttl - 5);
$this->assertGreaterThan($redisTtl, $ttl + 5);
}
public function getTtlFixtures(): array
{
return [
['ttl' => 5000],
['ttl' => 120],
['ttl' => 60],
];
}
}