diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 20df2e87ee3cd..dd3fac396ecf8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -75,13 +75,14 @@ Cache\IntegrationTests Doctrine\Common\Cache - Symfony\Component\Cache - Symfony\Component\Cache\Tests\Fixtures - Symfony\Component\Cache\Tests\Traits - Symfony\Component\Cache\Traits - Symfony\Component\Console - Symfony\Component\HttpFoundation - Symfony\Component\Uid + Symfony\Bridge\Doctrine\Middleware\Debug + Symfony\Component\Cache + Symfony\Component\Cache\Tests\Fixtures + Symfony\Component\Cache\Tests\Traits + Symfony\Component\Cache\Traits + Symfony\Component\Console + Symfony\Component\HttpFoundation + Symfony\Component\Uid diff --git a/src/Symfony/Bridge/Doctrine/DataCollector/DoctrineDataCollector.php b/src/Symfony/Bridge/Doctrine/DataCollector/DoctrineDataCollector.php index 8e292cf36b8d7..8e500b56c1fe3 100644 --- a/src/Symfony/Bridge/Doctrine/DataCollector/DoctrineDataCollector.php +++ b/src/Symfony/Bridge/Doctrine/DataCollector/DoctrineDataCollector.php @@ -15,6 +15,7 @@ use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\Type; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Bridge\Doctrine\Middleware\Debug\DebugDataHolder; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; @@ -31,17 +32,19 @@ class DoctrineDataCollector extends DataCollector private $registry; private $connections; private $managers; + private $debugDataHolder; /** * @var DebugStack[] */ private $loggers = []; - public function __construct(ManagerRegistry $registry) + public function __construct(ManagerRegistry $registry, DebugDataHolder $debugDataHolder = null) { $this->registry = $registry; $this->connections = $registry->getConnectionNames(); $this->managers = $registry->getManagerNames(); + $this->debugDataHolder = $debugDataHolder; } /** @@ -56,23 +59,43 @@ public function addLogger(string $name, DebugStack $logger) * {@inheritdoc} */ public function collect(Request $request, Response $response, \Throwable $exception = null) + { + $this->data = [ + 'queries' => $this->collectQueries(), + 'connections' => $this->connections, + 'managers' => $this->managers, + ]; + } + + private function collectQueries(): array { $queries = []; + + if (null !== $this->debugDataHolder) { + foreach ($this->debugDataHolder->getData() as $name => $data) { + $queries[$name] = $this->sanitizeQueries($name, $data); + } + + return $queries; + } + foreach ($this->loggers as $name => $logger) { $queries[$name] = $this->sanitizeQueries($name, $logger->queries); } - $this->data = [ - 'queries' => $queries, - 'connections' => $this->connections, - 'managers' => $this->managers, - ]; + return $queries; } public function reset() { $this->data = []; + if (null !== $this->debugDataHolder) { + $this->debugDataHolder->reset(); + + return; + } + foreach ($this->loggers as $logger) { $logger->queries = []; $logger->currentQuery = 0; diff --git a/src/Symfony/Bridge/Doctrine/Middleware/Debug/Connection.php b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Connection.php new file mode 100644 index 0000000000000..d085b0af0e3de --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Connection.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Middleware\Debug; + +use Doctrine\DBAL\Driver\Connection as ConnectionInterface; +use Doctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware; +use Doctrine\DBAL\Driver\Result; +use Doctrine\DBAL\Driver\Statement as DriverStatement; +use Symfony\Component\Stopwatch\Stopwatch; + +/** + * @author Laurent VOULLEMIER + * + * @internal + */ +final class Connection extends AbstractConnectionMiddleware +{ + private $nestingLevel = 0; + private $debugDataHolder; + private $stopwatch; + private $connectionName; + + public function __construct(ConnectionInterface $connection, DebugDataHolder $debugDataHolder, ?Stopwatch $stopwatch, string $connectionName) + { + parent::__construct($connection); + + $this->debugDataHolder = $debugDataHolder; + $this->stopwatch = $stopwatch; + $this->connectionName = $connectionName; + } + + public function prepare(string $sql): DriverStatement + { + return new Statement( + parent::prepare($sql), + $this->debugDataHolder, + $this->connectionName, + $sql + ); + } + + public function query(string $sql): Result + { + $this->debugDataHolder->addQuery($this->connectionName, $query = new Query($sql)); + + if (null !== $this->stopwatch) { + $this->stopwatch->start('doctrine', 'doctrine'); + } + + $query->start(); + + try { + $result = parent::query($sql); + } finally { + $query->stop(); + + if (null !== $this->stopwatch) { + $this->stopwatch->stop('doctrine'); + } + } + + return $result; + } + + public function exec(string $sql): int + { + $this->debugDataHolder->addQuery($this->connectionName, $query = new Query($sql)); + + if (null !== $this->stopwatch) { + $this->stopwatch->start('doctrine', 'doctrine'); + } + + $query->start(); + + try { + $affectedRows = parent::exec($sql); + } finally { + $query->stop(); + + if (null !== $this->stopwatch) { + $this->stopwatch->stop('doctrine'); + } + } + + return $affectedRows; + } + + public function beginTransaction(): bool + { + $query = null; + if (1 === ++$this->nestingLevel) { + $this->debugDataHolder->addQuery($this->connectionName, $query = new Query('"START TRANSACTION"')); + } + + if (null !== $this->stopwatch) { + $this->stopwatch->start('doctrine', 'doctrine'); + } + + if (null !== $query) { + $query->start(); + } + + try { + $ret = parent::beginTransaction(); + } finally { + if (null !== $query) { + $query->stop(); + } + + if (null !== $this->stopwatch) { + $this->stopwatch->stop('doctrine'); + } + } + + return $ret; + } + + public function commit(): bool + { + $query = null; + if (1 === $this->nestingLevel--) { + $this->debugDataHolder->addQuery($this->connectionName, $query = new Query('"COMMIT"')); + } + + if (null !== $this->stopwatch) { + $this->stopwatch->start('doctrine', 'doctrine'); + } + + if (null !== $query) { + $query->start(); + } + + try { + $ret = parent::commit(); + } finally { + if (null !== $query) { + $query->stop(); + } + + if (null !== $this->stopwatch) { + $this->stopwatch->stop('doctrine'); + } + } + + return $ret; + } + + public function rollBack(): bool + { + $query = null; + if (1 === $this->nestingLevel--) { + $this->debugDataHolder->addQuery($this->connectionName, $query = new Query('"ROLLBACK"')); + } + + if (null !== $this->stopwatch) { + $this->stopwatch->start('doctrine', 'doctrine'); + } + + if (null !== $query) { + $query->start(); + } + + try { + $ret = parent::rollBack(); + } finally { + if (null !== $query) { + $query->stop(); + } + + if (null !== $this->stopwatch) { + $this->stopwatch->stop('doctrine'); + } + } + + return $ret; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Middleware/Debug/DebugDataHolder.php b/src/Symfony/Bridge/Doctrine/Middleware/Debug/DebugDataHolder.php new file mode 100644 index 0000000000000..2643cc7493830 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Middleware/Debug/DebugDataHolder.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Middleware\Debug; + +/** + * @author Laurent VOULLEMIER + */ +class DebugDataHolder +{ + private $data = []; + + public function addQuery(string $connectionName, Query $query): void + { + $this->data[$connectionName][] = [ + 'sql' => $query->getSql(), + 'params' => $query->getParams(), + 'types' => $query->getTypes(), + 'executionMS' => [$query, 'getDuration'], // stop() may not be called at this point + ]; + } + + public function getData(): array + { + foreach ($this->data as $connectionName => $dataForConn) { + foreach ($dataForConn as $idx => $data) { + if (\is_callable($data['executionMS'])) { + $this->data[$connectionName][$idx]['executionMS'] = $data['executionMS'](); + } + } + } + + return $this->data; + } + + public function reset(): void + { + $this->data = []; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Middleware/Debug/Driver.php b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Driver.php new file mode 100644 index 0000000000000..7f7fdd3bf0d8d --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Driver.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Middleware\Debug; + +use Doctrine\DBAL\Driver as DriverInterface; +use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware; +use Symfony\Component\Stopwatch\Stopwatch; + +/** + * @author Laurent VOULLEMIER + * + * @internal + */ +final class Driver extends AbstractDriverMiddleware +{ + private $debugDataHolder; + private $stopwatch; + private $connectionName; + + public function __construct(DriverInterface $driver, DebugDataHolder $debugDataHolder, ?Stopwatch $stopwatch, string $connectionName) + { + parent::__construct($driver); + + $this->debugDataHolder = $debugDataHolder; + $this->stopwatch = $stopwatch; + $this->connectionName = $connectionName; + } + + public function connect(array $params): Connection + { + return new Connection( + parent::connect($params), + $this->debugDataHolder, + $this->stopwatch, + $this->connectionName + ); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Middleware/Debug/Middleware.php b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Middleware.php new file mode 100644 index 0000000000000..18f6a58d5e7a2 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Middleware.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Middleware\Debug; + +use Doctrine\DBAL\Driver as DriverInterface; +use Doctrine\DBAL\Driver\Middleware as MiddlewareInterface; +use Symfony\Component\Stopwatch\Stopwatch; + +/** + * Middleware to collect debug data. + * + * @author Laurent VOULLEMIER + */ +final class Middleware implements MiddlewareInterface +{ + private $debugDataHolder; + private $stopwatch; + private $connectionName; + + public function __construct(DebugDataHolder $debugDataHolder, ?Stopwatch $stopwatch, string $connectionName = 'default') + { + $this->debugDataHolder = $debugDataHolder; + $this->stopwatch = $stopwatch; + $this->connectionName = $connectionName; + } + + public function wrap(DriverInterface $driver): DriverInterface + { + return new Driver($driver, $this->debugDataHolder, $this->stopwatch, $this->connectionName); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Middleware/Debug/Query.php b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Query.php new file mode 100644 index 0000000000000..d652f620ce2e8 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Query.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Middleware\Debug; + +use Doctrine\DBAL\ParameterType; + +/** + * @author Laurent VOULLEMIER + * + * @internal + */ +class Query +{ + private $params = []; + private $types = []; + + private $start; + private $duration; + + private $sql; + + public function __construct(string $sql) + { + $this->sql = $sql; + } + + public function start(): void + { + $this->start = microtime(true); + } + + public function stop(): void + { + if (null !== $this->start) { + $this->duration = microtime(true) - $this->start; + } + } + + /** + * @param string|int $param + * @param string|int|float|bool|null $variable + */ + public function setParam($param, &$variable, int $type): void + { + // Numeric indexes start at 0 in profiler + $idx = \is_int($param) ? $param - 1 : $param; + + $this->params[$idx] = &$variable; + $this->types[$idx] = $type; + } + + /** + * @param string|int $param + * @param string|int|float|bool|null $value + */ + public function setValue($param, $value, int $type): void + { + // Numeric indexes start at 0 in profiler + $idx = \is_int($param) ? $param - 1 : $param; + + $this->params[$idx] = $value; + $this->types[$idx] = $type; + } + + /** + * @param array $values + */ + public function setValues(array $values): void + { + foreach ($values as $param => $value) { + $this->setValue($param, $value, ParameterType::STRING); + } + } + + public function getSql(): string + { + return $this->sql; + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + /** + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * Query duration in seconds. + */ + public function getDuration(): ?float + { + return $this->duration; + } + + public function __clone() + { + $copy = []; + foreach ($this->params as $param => $valueOrVariable) { + $copy[$param] = $valueOrVariable; + } + $this->params = $copy; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Middleware/Debug/Statement.php b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Statement.php new file mode 100644 index 0000000000000..e52530e906dc2 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Statement.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Middleware\Debug; + +use Doctrine\DBAL\Driver\Middleware\AbstractStatementMiddleware; +use Doctrine\DBAL\Driver\Result as ResultInterface; +use Doctrine\DBAL\Driver\Statement as StatementInterface; +use Doctrine\DBAL\ParameterType; + +/** + * @author Laurent VOULLEMIER + * + * @internal + */ +final class Statement extends AbstractStatementMiddleware +{ + private $debugDataHolder; + private $connectionName; + private $query; + + public function __construct(StatementInterface $statement, DebugDataHolder $debugDataHolder, string $connectionName, string $sql) + { + parent::__construct($statement); + + $this->debugDataHolder = $debugDataHolder; + $this->connectionName = $connectionName; + $this->query = new Query($sql); + } + + public function bindParam($param, &$variable, $type = ParameterType::STRING, $length = null): bool + { + $this->query->setParam($param, $variable, $type); + + return parent::bindParam($param, $variable, $type, ...\array_slice(\func_get_args(), 3)); + } + + public function bindValue($param, $value, $type = ParameterType::STRING): bool + { + $this->query->setValue($param, $value, $type); + + return parent::bindValue($param, $value, $type); + } + + public function execute($params = null): ResultInterface + { + if (null !== $params) { + $this->query->setValues($params); + } + + // clone to prevent variables by reference to change + $this->debugDataHolder->addQuery($this->connectionName, $query = clone $this->query); + + $query->start(); + + try { + $result = parent::execute($params); + } finally { + $query->stop(); + } + + return $result; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTest.php b/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTest.php index 35fc48ff1536f..25cc33fb4ae9f 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTest.php @@ -12,11 +12,14 @@ namespace Symfony\Bridge\Doctrine\Tests\DataCollector; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Logging\DebugStack; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\TestCase; use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector; +use Symfony\Bridge\Doctrine\Middleware\Debug\DebugDataHolder; +use Symfony\Bridge\Doctrine\Middleware\Debug\Query; +use Symfony\Bridge\PhpUnit\ClockMock; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\VarDumper\Cloner\Data; @@ -27,66 +30,40 @@ class_exists(\Doctrine\DBAL\Platforms\MySqlPlatform::class); class DoctrineDataCollectorTest extends TestCase { - public function testCollectConnections() - { - $c = $this->createCollector([]); - $c->collect(new Request(), new Response()); - $this->assertEquals(['default' => 'doctrine.dbal.default_connection'], $c->getConnections()); - } - - public function testCollectManagers() - { - $c = $this->createCollector([]); - $c->collect(new Request(), new Response()); - $this->assertEquals(['default' => 'doctrine.orm.default_entity_manager'], $c->getManagers()); - } + use DoctrineDataCollectorTestTrait; - public function testCollectQueryCount() + protected function setUp(): void { - $c = $this->createCollector([]); - $c->collect(new Request(), new Response()); - $this->assertEquals(0, $c->getQueryCount()); - - $queries = [ - ['sql' => 'SELECT * FROM table1', 'params' => [], 'types' => [], 'executionMS' => 0], - ]; - $c = $this->createCollector($queries); - $c->collect(new Request(), new Response()); - $this->assertEquals(1, $c->getQueryCount()); + ClockMock::register(self::class); + ClockMock::withClockMock(1500000000); } - public function testCollectTime() + public function testReset() { - $c = $this->createCollector([]); - $c->collect(new Request(), new Response()); - $this->assertEquals(0, $c->getTime()); - $queries = [ ['sql' => 'SELECT * FROM table1', 'params' => [], 'types' => [], 'executionMS' => 1], ]; $c = $this->createCollector($queries); $c->collect(new Request(), new Response()); - $this->assertEquals(1, $c->getTime()); - $queries = [ - ['sql' => 'SELECT * FROM table1', 'params' => [], 'types' => [], 'executionMS' => 1], - ['sql' => 'SELECT * FROM table2', 'params' => [], 'types' => [], 'executionMS' => 2], - ]; - $c = $this->createCollector($queries); + $c->reset(); $c->collect(new Request(), new Response()); - $this->assertEquals(3, $c->getTime()); + $c = unserialize(serialize($c)); + + $this->assertEquals([], $c->getQueries()); } /** * @dataProvider paramProvider */ - public function testCollectQueries($param, $types, $expected, $explainable, bool $runnable = true) + public function testCollectQueries($param, $types, $expected) { $queries = [ ['sql' => 'SELECT * FROM table1 WHERE field1 = ?1', 'params' => [$param], 'types' => $types, 'executionMS' => 1], ]; $c = $this->createCollector($queries); $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); $collectedQueries = $c->getQueries(); @@ -102,8 +79,8 @@ public function testCollectQueries($param, $types, $expected, $explainable, bool $this->assertEquals($expected, $collectedParam); } - $this->assertEquals($explainable, $collectedQueries['default'][0]['explainable']); - $this->assertSame($runnable, $collectedQueries['default'][0]['runnable']); + $this->assertTrue($collectedQueries['default'][0]['explainable']); + $this->assertTrue($collectedQueries['default'][0]['runnable']); } public function testCollectQueryWithNoParams() @@ -114,6 +91,7 @@ public function testCollectQueryWithNoParams() ]; $c = $this->createCollector($queries); $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); $collectedQueries = $c->getQueries(); $this->assertInstanceOf(Data::class, $collectedQueries['default'][0]['params']); @@ -126,36 +104,10 @@ public function testCollectQueryWithNoParams() $this->assertTrue($collectedQueries['default'][1]['runnable']); } - public function testCollectQueryWithNoTypes() - { - $queries = [ - ['sql' => 'SET sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))', 'params' => [], 'types' => null, 'executionMS' => 1], - ]; - $c = $this->createCollector($queries); - $c->collect(new Request(), new Response()); - - $collectedQueries = $c->getQueries(); - $this->assertSame([], $collectedQueries['default'][0]['types']); - } - - public function testReset() - { - $queries = [ - ['sql' => 'SELECT * FROM table1', 'params' => [], 'types' => [], 'executionMS' => 1], - ]; - $c = $this->createCollector($queries); - $c->collect(new Request(), new Response()); - - $c->reset(); - $c->collect(new Request(), new Response()); - - $this->assertEquals(['default' => []], $c->getQueries()); - } - /** * @dataProvider paramProvider */ - public function testSerialization($param, array $types, $expected, $explainable, bool $runnable = true) + public function testSerialization($param, array $types, $expected) { $queries = [ ['sql' => 'SELECT * FROM table1 WHERE field1 = ?1', 'params' => [$param], 'types' => $types, 'executionMS' => 1], @@ -178,55 +130,17 @@ public function testSerialization($param, array $types, $expected, $explainable, $this->assertEquals($expected, $collectedParam); } - $this->assertEquals($explainable, $collectedQueries['default'][0]['explainable']); - $this->assertSame($runnable, $collectedQueries['default'][0]['runnable']); + $this->assertTrue($collectedQueries['default'][0]['explainable']); + $this->assertTrue($collectedQueries['default'][0]['runnable']); } public function paramProvider(): array { return [ - ['some value', [], 'some value', true], - [1, [], 1, true], - [true, [], true, true], - [null, [], null, true], - [new \DateTime('2011-09-11'), ['date'], '2011-09-11', true], - [fopen(__FILE__, 'r'), [], '/* Resource(stream) */', false, false], - [ - new \stdClass(), - [], - <<method('getConnection') ->willReturn($connection); - $logger = $this->createMock(DebugStack::class); - $logger->queries = $queries; + $debugDataHolder = new DebugDataHolder(); + $collector = new DoctrineDataCollector($registry, $debugDataHolder); + foreach ($queries as $queryData) { + $query = new Query($queryData['sql'] ?? ''); + foreach (($queryData['params'] ?? []) as $key => $value) { + if (\is_int($key)) { + ++$key; + } - $collector = new DoctrineDataCollector($registry); - $collector->addLogger('default', $logger); + $query->setValue($key, $value, $queryData['type'][$key] ?? ParameterType::STRING); + } - return $collector; - } -} + $query->start(); -class StringRepresentableClass -{ - public function __toString(): string - { - return 'string representation'; + $debugDataHolder->addQuery('default', $query); + + if (isset($queryData['executionMS'])) { + sleep($queryData['executionMS']); + } + $query->stop(); + } + + return $collector; } } diff --git a/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTestTrait.php b/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTestTrait.php new file mode 100644 index 0000000000000..23977a3be9881 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTestTrait.php @@ -0,0 +1,79 @@ +createCollector([]); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + $this->assertEquals(['default' => 'doctrine.dbal.default_connection'], $c->getConnections()); + } + + public function testCollectManagers() + { + $c = $this->createCollector([]); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + $this->assertEquals(['default' => 'doctrine.orm.default_entity_manager'], $c->getManagers()); + } + + public function testCollectQueryCount() + { + $c = $this->createCollector([]); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + $this->assertEquals(0, $c->getQueryCount()); + + $queries = [ + ['sql' => 'SELECT * FROM table1', 'params' => [], 'types' => [], 'executionMS' => 0], + ]; + $c = $this->createCollector($queries); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + $this->assertEquals(1, $c->getQueryCount()); + } + + public function testCollectTime() + { + $c = $this->createCollector([]); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + $this->assertEquals(0, $c->getTime()); + + $queries = [ + ['sql' => 'SELECT * FROM table1', 'params' => [], 'types' => [], 'executionMS' => 1], + ]; + $c = $this->createCollector($queries); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + $this->assertEquals(1, $c->getTime()); + + $queries = [ + ['sql' => 'SELECT * FROM table1', 'params' => [], 'types' => [], 'executionMS' => 1], + ['sql' => 'SELECT * FROM table2', 'params' => [], 'types' => [], 'executionMS' => 2], + ]; + $c = $this->createCollector($queries); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + $this->assertEquals(3, $c->getTime()); + } + + public function testCollectQueryWithNoTypes() + { + $queries = [ + ['sql' => 'SET sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))', 'params' => [], 'types' => null, 'executionMS' => 1], + ]; + $c = $this->createCollector($queries); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + + $collectedQueries = $c->getQueries(); + $this->assertSame([], $collectedQueries['default'][0]['types']); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorWithDebugStackTest.php b/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorWithDebugStackTest.php new file mode 100644 index 0000000000000..f0962eff3132d --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorWithDebugStackTest.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\DataCollector; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Logging\DebugStack; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\TestCase; +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; + +// Doctrine DBAL 2 compatibility +class_exists(\Doctrine\DBAL\Platforms\MySqlPlatform::class); + +/** + * @group legacy + */ +class DoctrineDataCollectorWithDebugStackTest extends TestCase +{ + use DoctrineDataCollectorTestTrait; + + public function testReset() + { + $queries = [ + ['sql' => 'SELECT * FROM table1', 'params' => [], 'types' => [], 'executionMS' => 1], + ]; + $c = $this->createCollector($queries); + $c->collect(new Request(), new Response()); + + $c->reset(); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + + $this->assertEquals(['default' => []], $c->getQueries()); + } + + /** + * @dataProvider paramProvider + */ + public function testCollectQueries($param, $types, $expected, $explainable, bool $runnable = true) + { + $queries = [ + ['sql' => 'SELECT * FROM table1 WHERE field1 = ?1', 'params' => [$param], 'types' => $types, 'executionMS' => 1], + ]; + $c = $this->createCollector($queries); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + + $collectedQueries = $c->getQueries(); + + $collectedParam = $collectedQueries['default'][0]['params'][0]; + if ($collectedParam instanceof Data) { + $dumper = new CliDumper($out = fopen('php://memory', 'r+')); + $dumper->setColors(false); + $collectedParam->dump($dumper); + $this->assertStringMatchesFormat($expected, print_r(stream_get_contents($out, -1, 0), true)); + } elseif (\is_string($expected)) { + $this->assertStringMatchesFormat($expected, $collectedParam); + } else { + $this->assertEquals($expected, $collectedParam); + } + + $this->assertEquals($explainable, $collectedQueries['default'][0]['explainable']); + $this->assertSame($runnable, $collectedQueries['default'][0]['runnable']); + } + + /** + * @dataProvider paramProvider + */ + public function testSerialization($param, array $types, $expected, $explainable, bool $runnable = true) + { + $queries = [ + ['sql' => 'SELECT * FROM table1 WHERE field1 = ?1', 'params' => [$param], 'types' => $types, 'executionMS' => 1], + ]; + $c = $this->createCollector($queries); + $c->collect(new Request(), new Response()); + $c = unserialize(serialize($c)); + + $collectedQueries = $c->getQueries(); + + $collectedParam = $collectedQueries['default'][0]['params'][0]; + if ($collectedParam instanceof Data) { + $dumper = new CliDumper($out = fopen('php://memory', 'r+')); + $dumper->setColors(false); + $collectedParam->dump($dumper); + $this->assertStringMatchesFormat($expected, print_r(stream_get_contents($out, -1, 0), true)); + } elseif (\is_string($expected)) { + $this->assertStringMatchesFormat($expected, $collectedParam); + } else { + $this->assertEquals($expected, $collectedParam); + } + + $this->assertEquals($explainable, $collectedQueries['default'][0]['explainable']); + $this->assertSame($runnable, $collectedQueries['default'][0]['runnable']); + } + + public function paramProvider(): array + { + return [ + ['some value', [], 'some value', true], + [1, [], 1, true], + [true, [], true, true], + [null, [], null, true], + [new \DateTime('2011-09-11'), ['date'], '2011-09-11', true], + [fopen(__FILE__, 'r'), [], '/* Resource(stream) */', false, false], + [ + new \stdClass(), + [], + <<getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->getMock(); + $connection->expects($this->any()) + ->method('getDatabasePlatform') + ->willReturn(new MySqlPlatform()); + + $registry = $this->createMock(ManagerRegistry::class); + $registry + ->expects($this->any()) + ->method('getConnectionNames') + ->willReturn(['default' => 'doctrine.dbal.default_connection']); + $registry + ->expects($this->any()) + ->method('getManagerNames') + ->willReturn(['default' => 'doctrine.orm.default_entity_manager']); + $registry->expects($this->any()) + ->method('getConnection') + ->willReturn($connection); + + $collector = new DoctrineDataCollector($registry); + $logger = $this->createMock(DebugStack::class); + $logger->queries = $queries; + $collector->addLogger('default', $logger); + + return $collector; + } +} + +class StringRepresentableClass +{ + public function __toString(): string + { + return 'string representation'; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Middleware/Debug/MiddlewareTest.php b/src/Symfony/Bridge/Doctrine/Tests/Middleware/Debug/MiddlewareTest.php new file mode 100644 index 0000000000000..e43cec8b98650 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Middleware/Debug/MiddlewareTest.php @@ -0,0 +1,253 @@ +markTestSkipped(sprintf('%s needed to run this test', MiddlewareInterface::class)); + } + + ClockMock::withClockMock(false); + } + + private function init(bool $withStopwatch = true): void + { + $this->stopwatch = $withStopwatch ? new Stopwatch() : null; + + $configuration = new Configuration(); + $this->debugDataHolder = new DebugDataHolder(); + $configuration->setMiddlewares([new Middleware($this->debugDataHolder, $this->stopwatch)]); + + $this->conn = DriverManager::getConnection([ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ], $configuration); + + $this->conn->executeQuery(<< [ + static function(object $target, ...$args) { + return $target->executeStatement(...$args); + }, + ], + 'executeQuery' => [ + static function(object $target, ...$args): Result { + return $target->executeQuery(...$args); + }, + ], + ]; + } + + /** + * @dataProvider provideExecuteMethod + */ + public function testWithoutBinding(callable $executeMethod) + { + $this->init(); + + $executeMethod($this->conn, 'INSERT INTO products(name, price, stock) VALUES ("product1", 12.5, 5)'); + + $debug = $this->debugDataHolder->getData()['default'] ?? []; + $this->assertCount(2, $debug); + $this->assertSame('INSERT INTO products(name, price, stock) VALUES ("product1", 12.5, 5)', $debug[1]['sql']); + $this->assertSame([], $debug[1]['params']); + $this->assertSame([], $debug[1]['types']); + $this->assertGreaterThan(0, $debug[1]['executionMS']); + } + + /** + * @dataProvider provideExecuteMethod + */ + public function testWithValueBound(callable $executeMethod) + { + $this->init(); + + $stmt = $this->conn->prepare('INSERT INTO products(name, price, stock) VALUES (?, ?, ?)'); + $stmt->bindValue(1, 'product1'); + $stmt->bindValue(2, 12.5); + $stmt->bindValue(3, 5, ParameterType::INTEGER); + + $executeMethod($stmt); + + $debug = $this->debugDataHolder->getData()['default'] ?? []; + $this->assertCount(2, $debug); + $this->assertSame('INSERT INTO products(name, price, stock) VALUES (?, ?, ?)', $debug[1]['sql']); + $this->assertSame(['product1', 12.5, 5], $debug[1]['params']); + $this->assertSame([ParameterType::STRING, ParameterType::STRING, ParameterType::INTEGER], $debug[1]['types']); + $this->assertGreaterThan(0, $debug[1]['executionMS']); + } + + /** + * @dataProvider provideExecuteMethod + */ + public function testWithParamBound(callable $executeMethod) + { + $this->init(); + + $product = 'product1'; + $price = 12.5; + $stock = 5; + + $stmt = $this->conn->prepare('INSERT INTO products(name, price, stock) VALUES (?, ?, ?)'); + $stmt->bindParam(1, $product); + $stmt->bindParam(2, $price); + $stmt->bindParam(3, $stock, ParameterType::INTEGER); + + $executeMethod($stmt); + + // Debug data should not be affected by these changes + $product = 'product2'; + $price = 13.5; + $stock = 4; + + $debug = $this->debugDataHolder->getData()['default'] ?? []; + $this->assertCount(2, $debug); + $this->assertSame('INSERT INTO products(name, price, stock) VALUES (?, ?, ?)', $debug[1]['sql']); + $this->assertSame(['product1', '12.5', 5], $debug[1]['params']); + $this->assertSame([ParameterType::STRING, ParameterType::STRING, ParameterType::INTEGER], $debug[1]['types']); + $this->assertGreaterThan(0, $debug[1]['executionMS']); + } + + public function provideEndTransactionMethod(): array + { + return [ + 'commit' => [ + static function(Connection $conn): bool { + return $conn->commit(); + }, + '"COMMIT"', + ], + 'rollback' => [ + static function(Connection $conn): bool { + return $conn->rollBack(); + }, + '"ROLLBACK"', + ], + ]; + } + + /** + * @dataProvider provideEndTransactionMethod + */ + public function testTransaction(callable $endTransactionMethod, string $expectedEndTransactionDebug) + { + $this->init(); + + $this->conn->beginTransaction(); + $this->conn->beginTransaction(); + $this->conn->executeStatement('INSERT INTO products(name, price, stock) VALUES ("product1", 12.5, 5)'); + $endTransactionMethod($this->conn); + $endTransactionMethod($this->conn); + $this->conn->beginTransaction(); + $this->conn->executeStatement('INSERT INTO products(name, price, stock) VALUES ("product2", 15.5, 12)'); + $endTransactionMethod($this->conn); + + $debug = $this->debugDataHolder->getData()['default'] ?? []; + $this->assertCount(7, $debug); + $this->assertSame('"START TRANSACTION"', $debug[1]['sql']); + $this->assertGreaterThan(0, $debug[1]['executionMS']); + $this->assertSame('INSERT INTO products(name, price, stock) VALUES ("product1", 12.5, 5)', $debug[2]['sql']); + $this->assertGreaterThan(0, $debug[2]['executionMS']); + $this->assertSame($expectedEndTransactionDebug, $debug[3]['sql']); + $this->assertGreaterThan(0, $debug[3]['executionMS']); + $this->assertSame('"START TRANSACTION"', $debug[4]['sql']); + $this->assertGreaterThan(0, $debug[4]['executionMS']); + $this->assertSame('INSERT INTO products(name, price, stock) VALUES ("product2", 15.5, 12)', $debug[5]['sql']); + $this->assertGreaterThan(0, $debug[5]['executionMS']); + $this->assertSame($expectedEndTransactionDebug, $debug[6]['sql']); + $this->assertGreaterThan(0, $debug[6]['executionMS']); + } + + public function provideExecuteAndEndTransactionMethods(): array + { + return [ + 'commit and exec' => [ + static function(Connection $conn, string $sql) { + return $conn->executeStatement($sql); + }, + static function(Connection $conn): bool { + return $conn->commit(); + }, + ], + 'rollback and query' => [ + static function(Connection $conn, string $sql): Result { + return $conn->executeQuery($sql); + }, + static function(Connection $conn): bool { + return $conn->rollBack(); + }, + ], + ]; + } + + /** + * @dataProvider provideExecuteAndEndTransactionMethods + */ + public function testGlobalDoctrineDuration(callable $sqlMethod, callable $endTransactionMethod) + { + $this->init(); + + $periods = $this->stopwatch->getEvent('doctrine')->getPeriods(); + $this->assertCount(1, $periods); + + $this->conn->beginTransaction(); + + $this->assertFalse($this->stopwatch->getEvent('doctrine')->isStarted()); + $this->assertCount(2, $this->stopwatch->getEvent('doctrine')->getPeriods()); + + $sqlMethod($this->conn, 'SELECT * FROM products'); + + $this->assertFalse($this->stopwatch->getEvent('doctrine')->isStarted()); + $this->assertCount(3, $this->stopwatch->getEvent('doctrine')->getPeriods()); + + $endTransactionMethod($this->conn); + + $this->assertFalse($this->stopwatch->getEvent('doctrine')->isStarted()); + $this->assertCount(4, $this->stopwatch->getEvent('doctrine')->getPeriods()); + } + + /** + * @dataProvider provideExecuteAndEndTransactionMethods + */ + public function testWithoutStopwatch(callable $sqlMethod, callable $endTransactionMethod) + { + $this->init(false); + + $this->conn->beginTransaction(); + $sqlMethod($this->conn, 'SELECT * FROM products'); + $endTransactionMethod($this->conn); + } +} diff --git a/src/Symfony/Bridge/Doctrine/composer.json b/src/Symfony/Bridge/Doctrine/composer.json index 5d8e8485c73e9..dadb456cc2029 100644 --- a/src/Symfony/Bridge/Doctrine/composer.json +++ b/src/Symfony/Bridge/Doctrine/composer.json @@ -34,6 +34,7 @@ "symfony/http-kernel": "^5.0|^6.0", "symfony/messenger": "^4.4|^5.0|^6.0", "symfony/doctrine-messenger": "^5.1|^6.0", + "symfony/phpunit-bridge": "^4.4|^5.4|^6.0", "symfony/property-access": "^4.4|^5.0|^6.0", "symfony/property-info": "^5.0|^6.0", "symfony/proxy-manager-bridge": "^4.4|^5.0|^6.0", diff --git a/src/Symfony/Bridge/Doctrine/phpunit.xml.dist b/src/Symfony/Bridge/Doctrine/phpunit.xml.dist index fa76fa9b500e7..31a2546b47ec4 100644 --- a/src/Symfony/Bridge/Doctrine/phpunit.xml.dist +++ b/src/Symfony/Bridge/Doctrine/phpunit.xml.dist @@ -28,4 +28,14 @@ + + + + + + Symfony\Bridge\Doctrine\Middleware\Debug + + + +