diff --git a/.travis.yml b/.travis.yml index ab28d45..4ae7b92 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ php: - 7.1 - 7.2 - 7.3 - - 7.4snapshot + - 7.4 env: global: @@ -21,7 +21,7 @@ before_install: - composer global require --no-progress --no-scripts --no-plugins symfony/flex dev-master install: - - travis_retry composer update -n --prefer-dist --prefer-stable + - travis_retry composer update -n --prefer-stable script: - ./vendor/bin/phpunit -v @@ -32,20 +32,19 @@ jobs: - php: 7.1 env: LOWEST SYMFONY_DEPRECATIONS_HELPER=weak install: - - travis_retry composer update -n --prefer-lowest --prefer-stable --prefer-dist + - travis_retry composer update -n --prefer-lowest --prefer-stable - # Test against latest Symfony 4.4 dev + # Symfony 4.4 - php: 7.3 env: SYMFONY_REQUIRE="4.4.*" install: - - travis_retry composer update -n --prefer-dist + - travis_retry composer update -n - # Test dev versions - - php: 7.3 - if: type = cron - env: DEV + # Symfony 5.0 + - php: 7.4 + env: SYMFONY_REQUIRE="5.0.*" install: - - travis_retry composer update -n --prefer-dist + - travis_retry composer update -n - stage: Code Quality env: CODING_STANDARDS @@ -56,7 +55,7 @@ jobs: - stage: Coverage php: 7.3 install: - - travis_retry composer update -n --prefer-dist + - travis_retry composer update -n before_script: - mv ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini{.disabled,} - if [[ ! $(php -m | grep -si xdebug) ]]; then echo "xdebug required for coverage"; exit 1; fi diff --git a/composer.json b/composer.json index e421e1f..50f31fe 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "keywords": ["DBAL", "Database"], "homepage": "http://www.doctrine-project.org", "license": "MIT", - "minimum-stability": "dev", + "minimum-stability": "stable", "authors": [ { "name": "Doctrine Project", @@ -14,23 +14,38 @@ ], "require": { "php": "^7.1", + "doctrine/dbal": "^2.9", + "jdorn/sql-formatter": "^1.2.16", + "psr/container": "^1.0", + "symfony/config": "^4.3.3|^5.0", + "symfony/console": "^3.4.30|^4.3.3|^5.0", + "symfony/dependency-injection": "^4.3.3|^5.0", + "symfony/doctrine-bridge": "^4.3.7|^5.0", + "symfony/framework-bundle": "^3.4.30|^4.3.3|^5.0", "symfony/http-kernel": "^3.4.30|^4.3.3|^5.0" }, "require-dev": { "doctrine/coding-standard": "^6.0", - "phpunit/phpunit": "^7.5 || ^8.4", - "symfony/phpunit-bridge": "^5.0" + "phpunit/phpunit": "^7.5 || ^8.5", + "symfony/phpunit-bridge": "^5.0", + "symfony/twig-bridge": "^3.4.30|^4.3.3|^5.0", + "symfony/web-profiler-bundle": "^3.4.30|^4.3.3|^5.0", + "symfony/yaml": "^3.4.30|^4.3.3|^5.0", + "twig/twig": "^1.34|^2.12" }, "config": { - "sort-packages": true + "sort-packages": true, + "preferred-install": "dist" }, "conflict": { - "doctrine/orm": "<2.6", "twig/twig": "<1.34|>=2.0,<2.4" }, "autoload": { "psr-4": { "Doctrine\\Bundle\\DBALBundle\\": "src" } }, + "autoload-dev": { + "psr-4": { "Doctrine\\Bundle\\DBALBundle\\Tests\\": "tests" } + }, "extra": { "branch-alias": { "dev-master": "1.0.x-dev" diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..6037ec0 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,53 @@ + + + + + + + + + + + + . + vendor/* + + + + + + + + + + + + + + + + + tests/* + + + tests/* + + + tests/Fixtures/* + + + src/DependencyInjection/* + src/Twig/DoctrineDBALExtension.php + tests/* + + + src/DependencyInjection/* + src/Twig/DoctrineDBALExtension.php + tests/* + + + src/DependencyInjection/* + src/Twig/DoctrineDBALExtension.php + tests/* + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..f5a6bab --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + + + tests/ + tests/Fixtures + + + + + + ./src/ + + + diff --git a/src/Command/CreateDatabaseCommand.php b/src/Command/CreateDatabaseCommand.php new file mode 100644 index 0000000..2cddd1f --- /dev/null +++ b/src/Command/CreateDatabaseCommand.php @@ -0,0 +1,120 @@ +registry = $registry; + } + + /** + * {@inheritDoc} + */ + protected function configure() + { + $this + ->setName('doctrine:database:create') + ->setDescription('Creates the configured database') + ->addOption('shard', null, InputOption::VALUE_REQUIRED, 'The shard connection to use for this command') + ->addOption('connection', null, InputOption::VALUE_OPTIONAL, 'The connection to use for this command') + ->addOption('if-not-exists', null, InputOption::VALUE_NONE, 'Don\'t trigger an error, when the database already exists') + ->setHelp(<<%command.name% command creates the default connections database: + + php %command.full_name% + +You can also optionally specify the name of a connection to create the database for: + + php %command.full_name% --connection=default +EOT + ); + } + + /** + * {@inheritDoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $connectionName = $input->getOption('connection'); + if (empty($connectionName)) { + $connectionName = $this->registry->getDefaultConnectionName(); + } + $connection = $this->registry->getConnection($connectionName); + + $ifNotExists = $input->getOption('if-not-exists'); + + $params = $connection->getParams(); + if (isset($params['master'])) { + $params = $params['master']; + } + + // Cannot inject `shard` option in parent::getDoctrineConnection + // cause it will try to connect to a non-existing database + if (isset($params['shards'])) { + $shards = $params['shards']; + // Default select global + $params = array_merge($params, $params['global']); + unset($params['global']['dbname'], $params['global']['path'], $params['global']['url']); + if ($input->getOption('shard')) { + foreach ($shards as $i => $shard) { + if ($shard['id'] === (int) $input->getOption('shard')) { + // Select sharded database + $params = array_merge($params, $shard); + unset($params['shards'][$i]['dbname'], $params['shards'][$i]['path'], $params['shards'][$i]['url'], $params['id']); + break; + } + } + } + } + + $hasPath = isset($params['path']); + $name = $hasPath ? $params['path'] : (isset($params['dbname']) ? $params['dbname'] : false); + if (! $name) { + throw new InvalidArgumentException("Connection does not contain a 'path' or 'dbname' parameter and cannot be created."); + } + // Need to get rid of _every_ occurrence of dbname from connection configuration and we have already extracted all relevant info from url + unset($params['dbname'], $params['path'], $params['url']); + + $tmpConnection = DriverManager::getConnection($params); + $tmpConnection->connect($input->getOption('shard')); + $shouldNotCreateDatabase = $ifNotExists && in_array($name, $tmpConnection->getSchemaManager()->listDatabases()); + + // Only quote if we don't have a path + if (! $hasPath) { + $name = $tmpConnection->getDatabasePlatform()->quoteSingleIdentifier($name); + } + + $error = false; + try { + if ($shouldNotCreateDatabase) { + $output->writeln(sprintf('Database %s for connection named %s already exists. Skipped.', $name, $connectionName)); + } else { + $tmpConnection->getSchemaManager()->createDatabase($name); + $output->writeln(sprintf('Created database %s for connection named %s', $name, $connectionName)); + } + } catch (Exception $e) { + $output->writeln(sprintf('Could not create database %s for connection named %s', $name, $connectionName)); + $output->writeln(sprintf('%s', $e->getMessage())); + $error = true; + } + + $tmpConnection->close(); + + return $error ? 1 : 0; + } +} diff --git a/src/Command/DropDatabaseCommand.php b/src/Command/DropDatabaseCommand.php new file mode 100644 index 0000000..931ef93 --- /dev/null +++ b/src/Command/DropDatabaseCommand.php @@ -0,0 +1,133 @@ +registry = $registry; + } + + /** + * {@inheritDoc} + */ + protected function configure() + { + $this + ->setName('doctrine:database:drop') + ->setDescription('Drops the configured database') + ->addOption('shard', null, InputOption::VALUE_REQUIRED, 'The shard connection to use for this command') + ->addOption('connection', null, InputOption::VALUE_OPTIONAL, 'The connection to use for this command') + ->addOption('if-exists', null, InputOption::VALUE_NONE, 'Don\'t trigger an error, when the database doesn\'t exist') + ->addOption('force', null, InputOption::VALUE_NONE, 'Set this parameter to execute this action') + ->setHelp(<<%command.name% command drops the default connections database: + + php %command.full_name% + +The --force parameter has to be used to actually drop the database. + +You can also optionally specify the name of a connection to drop the database for: + + php %command.full_name% --connection=default + +Be careful: All data in a given database will be lost when executing this command. +EOT + ); + } + + /** + * {@inheritDoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $connectionName = $input->getOption('connection'); + if (empty($connectionName)) { + $connectionName = $this->registry->getDefaultConnectionName(); + } + $connection = $this->registry->getConnection($connectionName); + + $ifExists = $input->getOption('if-exists'); + + $params = $connection->getParams(); + if (isset($params['master'])) { + $params = $params['master']; + } + + if (isset($params['shards'])) { + $shards = $params['shards']; + // Default select global + $params = array_merge($params, $params['global']); + if ($input->getOption('shard')) { + foreach ($shards as $shard) { + if ($shard['id'] === (int) $input->getOption('shard')) { + // Select sharded database + $params = $shard; + unset($params['id']); + break; + } + } + } + } + + $name = isset($params['path']) ? $params['path'] : (isset($params['dbname']) ? $params['dbname'] : false); + if (! $name) { + throw new InvalidArgumentException("Connection does not contain a 'path' or 'dbname' parameter and cannot be dropped."); + } + unset($params['dbname'], $params['url']); + + if (! $input->getOption('force')) { + $output->writeln('ATTENTION: This operation should not be executed in a production environment.'); + $output->writeln(''); + $output->writeln(sprintf('Would drop the database %s for connection named %s.', $name, $connectionName)); + $output->writeln('Please run the operation with --force to execute'); + $output->writeln('All data will be lost!'); + + return self::RETURN_CODE_NO_FORCE; + } + + // Reopen connection without database name set + // as some vendors do not allow dropping the database connected to. + $connection->close(); + $connection = DriverManager::getConnection($params); + $shouldDropDatabase = ! $ifExists || in_array($name, $connection->getSchemaManager()->listDatabases()); + + // Only quote if we don't have a path + if (! isset($params['path'])) { + $name = $connection->getDatabasePlatform()->quoteSingleIdentifier($name); + } + + try { + if ($shouldDropDatabase) { + $connection->getSchemaManager()->dropDatabase($name); + $output->writeln(sprintf('Dropped database %s for connection named %s', $name, $connectionName)); + } else { + $output->writeln(sprintf('Database %s for connection named %s doesn\'t exist. Skipped.', $name, $connectionName)); + } + + return 0; + } catch (Exception $e) { + $output->writeln(sprintf('Could not drop database %s for connection named %s', $name, $connectionName)); + $output->writeln(sprintf('%s', $e->getMessage())); + + return self::RETURN_CODE_NOT_DROP; + } + } +} diff --git a/src/Command/Proxy/ConnectionProviderAdapter.php b/src/Command/Proxy/ConnectionProviderAdapter.php new file mode 100644 index 0000000..41fdb0d --- /dev/null +++ b/src/Command/Proxy/ConnectionProviderAdapter.php @@ -0,0 +1,28 @@ +registry = $registry; + } + + public function getDefaultConnection() : Connection + { + return $this->registry->getConnection(); + } + + public function getConnection(string $name) : Connection + { + return $this->registry->getConnection($name); + } +} diff --git a/src/Command/Proxy/RunSqlCommand.php b/src/Command/Proxy/RunSqlCommand.php new file mode 100644 index 0000000..7c3c7cd --- /dev/null +++ b/src/Command/Proxy/RunSqlCommand.php @@ -0,0 +1,67 @@ +setName('doctrine:query:sql') + ->setHelp(<<%command.name% command executes the given SQL query and +outputs the results: + +php %command.full_name% "SELECT * FROM users" +EOT + ); + + if ($this->getDefinition()->hasOption('connection')) { + return; + } + + // BC with dbal < 2.11 + $this->addOption('connection', null, InputOption::VALUE_OPTIONAL, 'The connection to use for this command'); + } + + /** + * {@inheritDoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + // BC with dbal < 2.11 + $this->setConnectionHelper($this->getApplication(), $input->getOption('connection')); + + return parent::execute($input, $output); + } + + private function setConnectionHelper(Application $application, ?string $connectionName) + { + $connection = $application->getKernel()->getContainer()->get('doctrine.dbal.connection_registry')->getConnection($connectionName); + $helperSet = $application->getHelperSet(); + $helperSet->set(new ConnectionHelper($connection), 'db'); + } +} diff --git a/src/ConnectionFactory.php b/src/ConnectionFactory.php new file mode 100644 index 0000000..6dace69 --- /dev/null +++ b/src/ConnectionFactory.php @@ -0,0 +1,136 @@ +typesConfig = $typesConfig; + } + + /** + * Create a connection by name. + * + * @param mixed[] $params + * @param string[]|Type[] $mappingTypes + * + * @return Connection + */ + public function createConnection( + array $params, + Configuration $config = null, + EventManager $eventManager = null, + array $mappingTypes = [] + ) { + if (! $this->initialized) { + $this->initializeTypes(); + } + + if (! isset($params['pdo']) && ! isset($params['charset'])) { + $wrapperClass = null; + if (isset($params['wrapperClass'])) { + if (! is_subclass_of($params['wrapperClass'], Connection::class)) { + throw DBALException::invalidWrapperClass($params['wrapperClass']); + } + + $wrapperClass = $params['wrapperClass']; + $params['wrapperClass'] = null; + } + + $connection = DriverManager::getConnection($params, $config, $eventManager); + $params = $connection->getParams(); + $driver = $connection->getDriver(); + + if ($driver instanceof AbstractMySQLDriver) { + $params['charset'] = 'utf8mb4'; + + if (! isset($params['defaultTableOptions']['collate'])) { + $params['defaultTableOptions']['collate'] = 'utf8mb4_unicode_ci'; + } + } else { + $params['charset'] = 'utf8'; + } + + if ($wrapperClass !== null) { + $params['wrapperClass'] = $wrapperClass; + } else { + $wrapperClass = Connection::class; + } + + $connection = new $wrapperClass($params, $driver, $config, $eventManager); + } else { + $connection = DriverManager::getConnection($params, $config, $eventManager); + } + + if (! empty($mappingTypes)) { + $platform = $this->getDatabasePlatform($connection); + foreach ($mappingTypes as $dbType => $doctrineType) { + $platform->registerDoctrineTypeMapping($dbType, $doctrineType); + } + } + + return $connection; + } + + /** + * Try to get the database platform. + * + * This could fail if types should be registered to an predefined/unused connection + * and the platform version is unknown. + * For details have a look at DoctrineBundle issue #673. + * + * @throws DBALException + */ + private function getDatabasePlatform(Connection $connection) : AbstractPlatform + { + try { + return $connection->getDatabasePlatform(); + } catch (DriverException $driverException) { + throw new DBALException( + 'An exception occured while establishing a connection to figure out your platform version.' . PHP_EOL . + "You can circumvent this by setting a 'server_version' configuration value" . PHP_EOL . PHP_EOL . + 'For further information have a look at:' . PHP_EOL . + 'https://github.com/doctrine/DoctrineBundle/issues/673', + 0, + $driverException + ); + } + } + + /** + * initialize the types + */ + private function initializeTypes() : void + { + foreach ($this->typesConfig as $typeName => $typeConfig) { + if (Type::hasType($typeName)) { + Type::overrideType($typeName, $typeConfig['class']); + } else { + Type::addType($typeName, $typeConfig['class']); + } + } + + $this->initialized = true; + } +} diff --git a/src/ConnectionRegistry.php b/src/ConnectionRegistry.php new file mode 100644 index 0000000..8501dc5 --- /dev/null +++ b/src/ConnectionRegistry.php @@ -0,0 +1,36 @@ + An array of Connection instances. + */ + public function getConnections() : array; + + /** + * Gets all connection names. + * + * @return array An array of connection names. + */ + public function getConnectionNames() : array; +} diff --git a/src/Controller/ProfilerController.php b/src/Controller/ProfilerController.php new file mode 100644 index 0000000..dfdbd6e --- /dev/null +++ b/src/Controller/ProfilerController.php @@ -0,0 +1,123 @@ +container = $container; + } + + /** + * Renders the profiler panel for the given token. + * + * @param string $token The profiler token + * @param string $connectionName + * @param int $query + * + * @return Response A Response instance + */ + public function explainAction($token, $connectionName, $query) + { + /** @var Profiler $profiler */ + $profiler = $this->container->get('profiler'); + $profiler->disable(); + + $profile = $profiler->loadProfile($token); + $queries = $profile->getCollector('doctrine.dbal')->getQueries(); + + if (! isset($queries[$connectionName][$query])) { + return new Response('This query does not exist.'); + } + + $query = $queries[$connectionName][$query]; + if (! $query['explainable']) { + return new Response('This query cannot be explained.'); + } + + /** @var Connection $connection */ + + //TODO: make service private and use DI + $connection = $this->container->get('doctrine.dbal.connection_registry')->getConnection($connectionName); + try { + $platform = $connection->getDatabasePlatform(); + if ($platform instanceof SqlitePlatform) { + $results = $this->explainSQLitePlatform($connection, $query); + } elseif ($platform instanceof SQLServerPlatform) { + $results = $this->explainSQLServerPlatform($connection, $query); + } else { + $results = $this->explainOtherPlatform($connection, $query); + } + } catch (Exception $e) { + return new Response('This query cannot be explained.'); + } + + return new Response($this->container->get('twig')->render('@DoctrineDBAL/Collector/explain.html.twig', [ + 'data' => $results, + 'query' => $query, + ])); + } + + private function explainSQLitePlatform(Connection $connection, $query) + { + $params = $query['params']; + + if ($params instanceof Data) { + $params = $params->getValue(true); + } + + return $connection->executeQuery('EXPLAIN QUERY PLAN ' . $query['sql'], $params, $query['types']) + ->fetchAll(PDO::FETCH_ASSOC); + } + + private function explainSQLServerPlatform(Connection $connection, $query) + { + if (stripos($query['sql'], 'SELECT') === 0) { + $sql = 'SET STATISTICS PROFILE ON; ' . $query['sql'] . '; SET STATISTICS PROFILE OFF;'; + } else { + $sql = 'SET SHOWPLAN_TEXT ON; GO; SET NOEXEC ON; ' . $query['sql'] . '; SET NOEXEC OFF; GO; SET SHOWPLAN_TEXT OFF;'; + } + + $params = $query['params']; + + if ($params instanceof Data) { + $params = $params->getValue(true); + } + + $stmt = $connection->executeQuery($sql, $params, $query['types']); + $stmt->nextRowset(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + private function explainOtherPlatform(Connection $connection, $query) + { + $params = $query['params']; + + if ($params instanceof Data) { + $params = $params->getValue(true); + } + + return $connection->executeQuery('EXPLAIN ' . $query['sql'], $params, $query['types']) + ->fetchAll(PDO::FETCH_ASSOC); + } +} diff --git a/src/DBAL/Logging/BacktraceLogger.php b/src/DBAL/Logging/BacktraceLogger.php new file mode 100644 index 0000000..4015f9d --- /dev/null +++ b/src/DBAL/Logging/BacktraceLogger.php @@ -0,0 +1,23 @@ +queries[$this->currentQuery]['backtrace'] = $backtrace; + } +} diff --git a/src/DBAL/SchemaFilter/BlacklistSchemaAssetFilter.php b/src/DBAL/SchemaFilter/BlacklistSchemaAssetFilter.php new file mode 100644 index 0000000..dcfb1ca --- /dev/null +++ b/src/DBAL/SchemaFilter/BlacklistSchemaAssetFilter.php @@ -0,0 +1,29 @@ +blacklist = $blacklist; + } + + public function __invoke($assetName) : bool + { + if ($assetName instanceof AbstractAsset) { + $assetName = $assetName->getName(); + } + + return ! in_array($assetName, $this->blacklist, true); + } +} diff --git a/src/DBAL/SchemaFilter/RegexSchemaAssetFilter.php b/src/DBAL/SchemaFilter/RegexSchemaAssetFilter.php new file mode 100644 index 0000000..ff5a106 --- /dev/null +++ b/src/DBAL/SchemaFilter/RegexSchemaAssetFilter.php @@ -0,0 +1,25 @@ +filterExpression = $filterExpression; + } + + public function __invoke($assetName) : bool + { + if ($assetName instanceof AbstractAsset) { + $assetName = $assetName->getName(); + } + + return preg_match($this->filterExpression, $assetName); + } +} diff --git a/src/DBAL/SchemaFilter/SchemaAssetsFilterManager.php b/src/DBAL/SchemaFilter/SchemaAssetsFilterManager.php new file mode 100644 index 0000000..de3587c --- /dev/null +++ b/src/DBAL/SchemaFilter/SchemaAssetsFilterManager.php @@ -0,0 +1,31 @@ +schemaAssetFilters = $schemaAssetFilters; + } + + public function __invoke($assetName) : bool + { + foreach ($this->schemaAssetFilters as $schemaAssetFilter) { + if ($schemaAssetFilter($assetName) === false) { + return false; + } + } + + return true; + } +} diff --git a/src/DataCollector/DoctrineDBALDataCollector.php b/src/DataCollector/DoctrineDBALDataCollector.php new file mode 100644 index 0000000..32f29f9 --- /dev/null +++ b/src/DataCollector/DoctrineDBALDataCollector.php @@ -0,0 +1,293 @@ + */ + private $connectionServiceIds; + + /** + * @param array $connectionServiceIds + */ + public function __construct(ConnectionRegistry $connectionRegistry, array $connectionServiceIds) + { + $this->connectionRegistry = $connectionRegistry; + $this->connectionServiceIds = $connectionServiceIds; + } + + /** + * {@inheritdoc} + */ + public function collect(Request $request, Response $response, Throwable $exception = null) + { + $queries = []; + foreach ($this->loggers as $name => $logger) { + $queries[$name] = $this->sanitizeQueries($name, $logger->queries); + } + + $this->data = [ + 'queries' => $queries, + 'connections' => $this->connectionServiceIds, + ]; + + // Might be good idea to replicate this block in doctrine bridge so we can drop this from here after some time. + // This code is compatible with such change, because cloneVar is supposed to check if input is already cloned. + foreach ($this->data['queries'] as &$queries) { + foreach ($queries as &$query) { + $query['params'] = $this->cloneVar($query['params']); + // To be removed when the required minimum version of symfony/doctrine-bridge is >= 4.4 + $query['runnable'] = $query['runnable'] ?? true; + } + } + + $this->groupedQueries = null; + } + + /** + * {@inheritdoc} + */ + public function getName() + { + return 'doctrine.dbal'; + } + + /** + * {@inheritdoc} + */ + public function reset() + { + $this->data = []; + + foreach ($this->loggers as $logger) { + $logger->queries = []; + $logger->currentQuery = 0; + } + } + + /** + * Adds the stack logger for a connection. + */ + public function addLogger(string $name, DebugStack $logger) + { + $this->loggers[$name] = $logger; + } + + public function getConnections() + { + return $this->data['connections']; + } + + public function getQueryCount() + { + return array_sum(array_map('count', $this->data['queries'])); + } + + public function getQueries() + { + return $this->data['queries']; + } + + public function getTime() + { + $time = 0; + foreach ($this->data['queries'] as $queries) { + foreach ($queries as $query) { + $time += $query['executionMS']; + } + } + + return $time; + } + + public function getGroupedQueryCount() + { + $count = 0; + foreach ($this->getGroupedQueries() as $connectionGroupedQueries) { + $count += count($connectionGroupedQueries); + } + + return $count; + } + + public function getGroupedQueries() + { + if ($this->groupedQueries !== null) { + return $this->groupedQueries; + } + + $this->groupedQueries = []; + $totalExecutionMS = 0; + foreach ($this->data['queries'] as $connection => $queries) { + $connectionGroupedQueries = []; + foreach ($queries as $i => $query) { + $key = $query['sql']; + if (! isset($connectionGroupedQueries[$key])) { + $connectionGroupedQueries[$key] = $query; + $connectionGroupedQueries[$key]['executionMS'] = 0; + $connectionGroupedQueries[$key]['count'] = 0; + $connectionGroupedQueries[$key]['index'] = $i; // "Explain query" relies on query index in 'queries'. + } + $connectionGroupedQueries[$key]['executionMS'] += $query['executionMS']; + $connectionGroupedQueries[$key]['count']++; + $totalExecutionMS += $query['executionMS']; + } + usort($connectionGroupedQueries, static function ($a, $b) { + if ($a['executionMS'] === $b['executionMS']) { + return 0; + } + + return $a['executionMS'] < $b['executionMS'] ? 1 : -1; + }); + $this->groupedQueries[$connection] = $connectionGroupedQueries; + } + + foreach ($this->groupedQueries as $connection => $queries) { + foreach ($queries as $i => $query) { + $this->groupedQueries[$connection][$i]['executionPercent'] = + $this->executionTimePercentage($query['executionMS'], $totalExecutionMS); + } + } + + return $this->groupedQueries; + } + + private function executionTimePercentage($executionTimeMS, $totalExecutionTimeMS) + { + if ($totalExecutionTimeMS === 0.0 || $totalExecutionTimeMS === 0) { + return 0; + } + + return $executionTimeMS / $totalExecutionTimeMS * 100; + } + + /** + * @param mixed[] $queries + * + * @return mixed[] + */ + private function sanitizeQueries(string $connectionName, array $queries) : array + { + foreach ($queries as $i => $query) { + $queries[$i] = $this->sanitizeQuery($connectionName, $query); + } + + return $queries; + } + + /** + * @param mixed[] $query + * + * @return mixed[] + */ + private function sanitizeQuery(string $connectionName, array $query) : array + { + $query['explainable'] = true; + $query['runnable'] = true; + if ($query['params'] === null) { + $query['params'] = []; + } + if (! is_array($query['params'])) { + $query['params'] = [$query['params']]; + } + if (! is_array($query['types'])) { + $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]; + if (is_string($type)) { + $type = Type::getType($type); + } + if ($type instanceof Type) { + $query['types'][$j] = $type->getBindingType(); + try { + $param = $type->convertToDatabaseValue($param, $this->connectionRegistry->getConnection($connectionName)->getDatabasePlatform()); + } catch (TypeError $e) { + } catch (ConversionException $e) { + } + } + } + + [$query['params'][$j], $explainable, $runnable] = $this->sanitizeParam($param, $e); + if (! $explainable) { + $query['explainable'] = false; + } + + if ($runnable) { + continue; + } + + $query['runnable'] = false; + } + + $query['params'] = $this->cloneVar($query['params']); + + return $query; + } + + /** + * Sanitizes a param. + * + * The return value is an array with the sanitized value and a boolean + * indicating if the original value was kept (allowing to use the sanitized + * value to explain the query). + * + * @return mixed[] + */ + private function sanitizeParam($var, ?Throwable $error) : array + { + if (is_object($var)) { + return [$o = new ObjectParameter($var, $error), false, $o->isStringable() && ! $error]; + } + + if ($error) { + return ['⚠ ' . $error->getMessage(), false, false]; + } + + if (is_array($var)) { + $a = []; + $explainable = $runnable = true; + foreach ($var as $k => $v) { + [$value, $e, $r] = $this->sanitizeParam($v, null); + $explainable = $explainable && $e; + $runnable = $runnable && $r; + $a[$k] = $value; + } + + return [$a, $explainable, $runnable]; + } + + if (is_resource($var)) { + return [sprintf('/* Resource(%s) */', get_resource_type($var)), false, false]; + } + + return [$var, true, true]; + } +} diff --git a/src/DependencyInjection/Compiler/DbalSchemaFilterPass.php b/src/DependencyInjection/Compiler/DbalSchemaFilterPass.php new file mode 100644 index 0000000..41bf77d --- /dev/null +++ b/src/DependencyInjection/Compiler/DbalSchemaFilterPass.php @@ -0,0 +1,57 @@ +findTaggedServiceIds('doctrine.dbal.schema_filter'); + + if (count($filters) > 0 && ! method_exists(Configuration::class, 'setSchemaAssetsFilter')) { + throw new LogicException('The doctrine.dbal.schema_filter tag is only supported when using doctrine/dbal 2.9 or higher.'); + } + + $connectionFilters = []; + foreach ($filters as $id => $tagAttributes) { + foreach ($tagAttributes as $attributes) { + $name = isset($attributes['connection']) ? $attributes['connection'] : $container->getParameter('doctrine.default_connection'); + + if (! isset($connectionFilters[$name])) { + $connectionFilters[$name] = []; + } + + $connectionFilters[$name][] = new Reference($id); + } + } + + foreach ($connectionFilters as $name => $references) { + $configurationId = sprintf('doctrine.dbal.%s_connection.configuration', $name); + + if (! $container->hasDefinition($configurationId)) { + continue; + } + + $definition = new ChildDefinition('doctrine.dbal.schema_asset_filter_manager'); + $definition->setArgument(0, $references); + + $id = sprintf('doctrine.dbal.%s_schema_asset_filter_manager', $name); + $container->setDefinition($id, $definition); + $container->findDefinition($configurationId) + ->addMethodCall('setSchemaAssetsFilter', [new Reference($id)]); + } + } +} diff --git a/src/DependencyInjection/Compiler/WellKnownSchemaFilterPass.php b/src/DependencyInjection/Compiler/WellKnownSchemaFilterPass.php new file mode 100644 index 0000000..ff1f205 --- /dev/null +++ b/src/DependencyInjection/Compiler/WellKnownSchemaFilterPass.php @@ -0,0 +1,59 @@ +getDefinitions() as $definition) { + if ($definition->isAbstract() || $definition->isSynthetic()) { + continue; + } + + switch ($definition->getClass()) { + case PdoAdapter::class: + $blacklist[] = $definition->getArguments()[3]['db_table'] ?? 'cache_items'; + break; + + case PdoSessionHandler::class: + $blacklist[] = $definition->getArguments()[1]['db_table'] ?? 'lock_keys'; + break; + + case PdoStore::class: + $blacklist[] = $definition->getArguments()[1]['db_table'] ?? 'sessions'; + break; + + case Connection::class: + $blacklist[] = $definition->getArguments()[0]['table_name'] ?? 'messenger_messages'; + break; + } + } + + if (! $blacklist) { + return; + } + + $definition = $container->getDefinition('doctrine.dbal.well_known_schema_asset_filter'); + $definition->replaceArgument(0, $blacklist); + + foreach (array_keys($container->getParameter('doctrine.connections')) as $name) { + $definition->addTag('doctrine.dbal.schema_filter', ['connection' => $name]); + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..34753c8 --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,271 @@ +debug = (bool) $debug; + } + + public function getConfigTreeBuilder() + { + $treeBuilder = new TreeBuilder('doctrine_dbal'); + $rootNode = $treeBuilder->getRootNode(); + + $this->addDbalSection($rootNode); + + return $treeBuilder; + } + + private function addDbalSection(ArrayNodeDefinition $node) + { + $node + ->beforeNormalization() + ->ifTrue(static function ($v) { + return is_array($v) && ! array_key_exists('connections', $v) && ! array_key_exists('connection', $v); + }) + ->then(static function ($v) { + // Key that should not be rewritten to the connection config + $excludedKeys = ['default_connection' => true, 'types' => true, 'type' => true]; + $connection = []; + foreach ($v as $key => $value) { + if (isset($excludedKeys[$key])) { + continue; + } + $connection[$key] = $v[$key]; + unset($v[$key]); + } + $v['default_connection'] = isset($v['default_connection']) ? (string) $v['default_connection'] : 'default'; + $v['connections'] = [$v['default_connection'] => $connection]; + + return $v; + }) + ->end() + ->children() + ->scalarNode('default_connection')->end() + ->end() + ->fixXmlConfig('type') + ->children() + ->arrayNode('types') + ->useAttributeAsKey('name') + ->prototype('array') + ->beforeNormalization() + ->ifString() + ->then(static function ($v) { + return ['class' => $v]; + }) + ->end() + ->children() + ->scalarNode('class')->isRequired()->end() + ->booleanNode('commented')->setDeprecated()->end() + ->end() + ->end() + ->end() + ->end() + ->fixXmlConfig('connection') + ->append($this->getDbalConnectionsNode()); + } + + /** + * Return the dbal connections node + * + * @return ArrayNodeDefinition + */ + private function getDbalConnectionsNode() + { + $treeBuilder = new TreeBuilder('connections'); + $node = $treeBuilder->getRootNode(); + + /** @var ArrayNodeDefinition $connectionNode */ + $connectionNode = $node + ->requiresAtLeastOneElement() + ->useAttributeAsKey('name') + ->prototype('array'); + + $this->configureDbalDriverNode($connectionNode); + + $connectionNode + ->fixXmlConfig('option') + ->fixXmlConfig('mapping_type') + ->fixXmlConfig('slave') + ->fixXmlConfig('shard') + ->fixXmlConfig('default_table_option') + ->children() + ->scalarNode('driver')->defaultValue('pdo_mysql')->end() + ->scalarNode('platform_service')->end() + ->booleanNode('auto_commit')->end() + ->scalarNode('schema_filter')->end() + ->booleanNode('logging')->defaultValue($this->debug)->end() + ->booleanNode('profiling')->defaultValue($this->debug)->end() + ->booleanNode('profiling_collect_backtrace') + ->defaultValue(false) + ->info('Enables collecting backtraces when profiling is enabled') + ->end() + ->scalarNode('server_version')->end() + ->scalarNode('driver_class')->end() + ->scalarNode('wrapper_class')->end() + ->scalarNode('shard_manager_class')->end() + ->scalarNode('shard_choser')->end() + ->scalarNode('shard_choser_service')->end() + ->booleanNode('keep_slave')->end() + ->arrayNode('options') + ->useAttributeAsKey('key') + ->prototype('scalar')->end() + ->end() + ->arrayNode('mapping_types') + ->useAttributeAsKey('name') + ->prototype('scalar')->end() + ->end() + ->arrayNode('default_table_options') + ->info("This option is used by the schema-tool and affects generated SQL. Possible keys include 'charset','collate', and 'engine'.") + ->useAttributeAsKey('name') + ->prototype('scalar')->end() + ->end() + ->end(); + + $slaveNode = $connectionNode + ->children() + ->arrayNode('slaves') + ->useAttributeAsKey('name') + ->prototype('array'); + $this->configureDbalDriverNode($slaveNode); + + $shardNode = $connectionNode + ->children() + ->arrayNode('shards') + ->prototype('array') + ->children() + ->integerNode('id') + ->min(1) + ->isRequired() + ->end() + ->end(); + $this->configureDbalDriverNode($shardNode); + + return $node; + } + + /** + * Adds config keys related to params processed by the DBAL drivers + * + * These keys are available for slave configurations too. + */ + private function configureDbalDriverNode(ArrayNodeDefinition $node) + { + $node + ->children() + ->scalarNode('url')->info('A URL with connection information; any parameter value parsed from this string will override explicitly set parameters')->end() + ->scalarNode('dbname')->end() + ->scalarNode('host')->defaultValue('localhost')->end() + ->scalarNode('port')->defaultNull()->end() + ->scalarNode('user')->defaultValue('root')->end() + ->scalarNode('password')->defaultNull()->end() + ->scalarNode('application_name')->end() + ->scalarNode('charset')->end() + ->scalarNode('path')->end() + ->booleanNode('memory')->end() + ->scalarNode('unix_socket')->info('The unix socket to use for MySQL')->end() + ->booleanNode('persistent')->info('True to use as persistent connection for the ibm_db2 driver')->end() + ->scalarNode('protocol')->info('The protocol to use for the ibm_db2 driver (default to TCPIP if ommited)')->end() + ->booleanNode('service') + ->info('True to use SERVICE_NAME as connection parameter instead of SID for Oracle') + ->end() + ->scalarNode('servicename') + ->info( + 'Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter ' . + 'for Oracle depending on the service parameter.' + ) + ->end() + ->scalarNode('sessionMode') + ->info('The session mode to use for the oci8 driver') + ->end() + ->scalarNode('server') + ->info('The name of a running database server to connect to for SQL Anywhere.') + ->end() + ->scalarNode('default_dbname') + ->info( + 'Override the default database (postgres) to connect to for PostgreSQL connexion.' + ) + ->end() + ->scalarNode('sslmode') + ->info( + 'Determines whether or with what priority a SSL TCP/IP connection will be negotiated with ' . + 'the server for PostgreSQL.' + ) + ->end() + ->scalarNode('sslrootcert') + ->info( + 'The name of a file containing SSL certificate authority (CA) certificate(s). ' . + 'If the file exists, the server\'s certificate will be verified to be signed by one of these authorities.' + ) + ->end() + ->scalarNode('sslcert') + ->info( + 'The path to the SSL client certificate file for PostgreSQL.' + ) + ->end() + ->scalarNode('sslkey') + ->info( + 'The path to the SSL client key file for PostgreSQL.' + ) + ->end() + ->scalarNode('sslcrl') + ->info( + 'The file name of the SSL certificate revocation list for PostgreSQL.' + ) + ->end() + ->booleanNode('pooled')->info('True to use a pooled server with the oci8/pdo_oracle driver')->end() + ->booleanNode('MultipleActiveResultSets')->info('Configuring MultipleActiveResultSets for the pdo_sqlsrv driver')->end() + ->booleanNode('use_savepoints')->info('Use savepoints for nested transactions')->end() + ->scalarNode('instancename') + ->info( + 'Optional parameter, complete whether to add the INSTANCE_NAME parameter in the connection.' . + ' It is generally used to connect to an Oracle RAC server to select the name' . + ' of a particular instance.' + ) + ->end() + ->scalarNode('connectstring') + ->info( + 'Complete Easy Connect connection descriptor, see https://docs.oracle.com/database/121/NETAG/naming.htm.' . + 'When using this option, you will still need to provide the user and password parameters, but the other ' . + 'parameters will no longer be used. Note that when using this parameter, the getHost and getPort methods' . + ' from Doctrine\DBAL\Connection will no longer function as expected.' + ) + ->end() + ->end() + ->beforeNormalization() + ->ifTrue(static function ($v) { + return ! isset($v['sessionMode']) && isset($v['session_mode']); + }) + ->then(static function ($v) { + $v['sessionMode'] = $v['session_mode']; + unset($v['session_mode']); + + return $v; + }) + ->end() + ->beforeNormalization() + ->ifTrue(static function ($v) { + return ! isset($v['MultipleActiveResultSets']) && isset($v['multiple_active_result_sets']); + }) + ->then(static function ($v) { + $v['MultipleActiveResultSets'] = $v['multiple_active_result_sets']; + unset($v['multiple_active_result_sets']); + + return $v; + }) + ->end(); + } +} diff --git a/src/DependencyInjection/DoctrineDBALExtension.php b/src/DependencyInjection/DoctrineDBALExtension.php new file mode 100644 index 0000000..a943807 --- /dev/null +++ b/src/DependencyInjection/DoctrineDBALExtension.php @@ -0,0 +1,309 @@ +getParameter('kernel.debug')); + $config = $this->processConfiguration($configuration, $configs); + + $this->dbalLoad($config, $container); + } + + /** + * Loads the DBAL configuration. + * + * Usage example: + * + * + * + * @param array $config An array of configuration settings + * @param ContainerBuilder $container A ContainerBuilder instance + */ + private function dbalLoad(array $config, ContainerBuilder $container) + { + $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); + $loader->load('dbal.xml'); + + if (empty($config['default_connection'])) { + $keys = array_keys($config['connections']); + $config['default_connection'] = reset($keys); + } + + $defaultConnection = $config['default_connection']; + + $container->setAlias('database_connection', sprintf('doctrine.dbal.%s_connection', $defaultConnection)); + $container->getAlias('database_connection')->setPublic(true); + $container->setAlias('doctrine.dbal.event_manager', new Alias(sprintf('doctrine.dbal.%s_connection.event_manager', $defaultConnection), false)); + + $container->setParameter('doctrine.dbal.connection_factory.types', $config['types']); + + $connections = []; + + foreach (array_keys($config['connections']) as $name) { + $connections[$name] = sprintf('doctrine.dbal.%s_connection', $name); + } + + $container->setParameter('doctrine.connections', $connections); + $container->setParameter('doctrine.default_connection', $defaultConnection); + + foreach ($config['connections'] as $name => $connection) { + $this->loadDbalConnection($name, $connection, $container); + } + + $registry = $container->getDefinition('doctrine.dbal.connection_registry'); + $registry->setArguments([ + ServiceLocatorTagPass::register($container, array_map(static function ($id) { + return new Reference($id); + }, $connections)), + $defaultConnection, + array_keys($connections), + ]); + + if (! class_exists(ConnectionProvider::class)) { + return; + } + + // dbal >= 2.11 + $container->register('doctrine.dbal.cli.connection_provider', ConnectionProviderAdapter::class) + ->setArguments([new Reference('doctrine.dbal.connection_registry')]); + $container->findDefinition('doctrine.query_sql_command')->setArguments([new Reference('doctrine.dbal.cli.connection_provider')]); + } + + /** + * Loads a configured DBAL connection. + * + * @param string $name The name of the connection + * @param array $connection A dbal connection configuration. + * @param ContainerBuilder $container A ContainerBuilder instance + */ + protected function loadDbalConnection($name, array $connection, ContainerBuilder $container) + { + $configuration = $container->setDefinition(sprintf('doctrine.dbal.%s_connection.configuration', $name), new ChildDefinition('doctrine.dbal.connection.configuration')); + $logger = null; + if ($connection['logging']) { + $logger = new Reference('doctrine.dbal.logger'); + } + unset($connection['logging']); + + if ($connection['profiling']) { + $profilingAbstractId = $connection['profiling_collect_backtrace'] ? + 'doctrine.dbal.logger.backtrace' : + 'doctrine.dbal.logger.profiling'; + + $profilingLoggerId = $profilingAbstractId . '.' . $name; + $container->setDefinition($profilingLoggerId, new ChildDefinition($profilingAbstractId)); + $profilingLogger = new Reference($profilingLoggerId); + $container->getDefinition('data_collector.doctrine.dbal')->addMethodCall('addLogger', [$name, $profilingLogger]); + + if ($logger !== null) { + $chainLogger = new ChildDefinition('doctrine.dbal.logger.chain'); + $chainLogger->addMethodCall('addLogger', [$profilingLogger]); + + $loggerId = 'doctrine.dbal.logger.chain.' . $name; + $container->setDefinition($loggerId, $chainLogger); + $logger = new Reference($loggerId); + } else { + $logger = $profilingLogger; + } + } + unset($connection['profiling'], $connection['profiling_collect_backtrace']); + + if (isset($connection['auto_commit'])) { + $configuration->addMethodCall('setAutoCommit', [$connection['auto_commit']]); + } + + unset($connection['auto_commit']); + + if (isset($connection['schema_filter']) && $connection['schema_filter']) { + $definition = new Definition(RegexSchemaAssetFilter::class, [$connection['schema_filter']]); + $definition->addTag('doctrine.dbal.schema_filter', ['connection' => $name]); + $container->setDefinition(sprintf('doctrine.dbal.%s_regex_schema_filter', $name), $definition); + } + + unset($connection['schema_filter']); + + if ($logger) { + $configuration->addMethodCall('setSQLLogger', [$logger]); + } + + // event manager + $container->setDefinition(sprintf('doctrine.dbal.%s_connection.event_manager', $name), new ChildDefinition('doctrine.dbal.connection.event_manager')); + + // connection + $options = $this->getConnectionOptions($connection); + + $def = $container + ->setDefinition(sprintf('doctrine.dbal.%s_connection', $name), new ChildDefinition('doctrine.dbal.connection')) + ->setPublic(true) + ->setArguments([ + $options, + new Reference(sprintf('doctrine.dbal.%s_connection.configuration', $name)), + new Reference(sprintf('doctrine.dbal.%s_connection.event_manager', $name)), + $connection['mapping_types'], + ]); + + // Set class in case "wrapper_class" option was used to assist IDEs + if (isset($options['wrapperClass'])) { + $def->setClass($options['wrapperClass']); + } + + if (! empty($connection['use_savepoints'])) { + $def->addMethodCall('setNestTransactionsWithSavepoints', [$connection['use_savepoints']]); + } + + // Create a shard_manager for this connection + if (! isset($options['shards'])) { + return; + } + + $shardManagerDefinition = new Definition($options['shardManagerClass'], [new Reference(sprintf('doctrine.dbal.%s_connection', $name))]); + $container->setDefinition(sprintf('doctrine.dbal.%s_shard_manager', $name), $shardManagerDefinition); + } + + protected function getConnectionOptions($connection) + { + $options = $connection; + + if (isset($options['platform_service'])) { + $options['platform'] = new Reference($options['platform_service']); + unset($options['platform_service']); + } + unset($options['mapping_types']); + + if (isset($options['shard_choser_service'])) { + $options['shard_choser'] = new Reference($options['shard_choser_service']); + unset($options['shard_choser_service']); + } + + foreach ([ + 'options' => 'driverOptions', + 'driver_class' => 'driverClass', + 'wrapper_class' => 'wrapperClass', + 'keep_slave' => 'keepSlave', + 'shard_choser' => 'shardChoser', + 'shard_manager_class' => 'shardManagerClass', + 'server_version' => 'serverVersion', + 'default_table_options' => 'defaultTableOptions', + ] as $old => $new) { + if (! isset($options[$old])) { + continue; + } + + $options[$new] = $options[$old]; + unset($options[$old]); + } + + if (! empty($options['slaves']) && ! empty($options['shards'])) { + throw new InvalidArgumentException('Sharding and master-slave connection cannot be used together'); + } + + if (! empty($options['slaves'])) { + $nonRewrittenKeys = [ + 'driver' => true, + 'driverOptions' => true, + 'driverClass' => true, + 'wrapperClass' => true, + 'keepSlave' => true, + 'shardChoser' => true, + 'platform' => true, + 'slaves' => true, + 'master' => true, + 'shards' => true, + 'serverVersion' => true, + 'defaultTableOptions' => true, + // included by safety but should have been unset already + 'logging' => true, + 'profiling' => true, + 'mapping_types' => true, + 'platform_service' => true, + ]; + foreach ($options as $key => $value) { + if (isset($nonRewrittenKeys[$key])) { + continue; + } + $options['master'][$key] = $value; + unset($options[$key]); + } + if (empty($options['wrapperClass'])) { + // Change the wrapper class only if the user does not already forced using a custom one. + $options['wrapperClass'] = 'Doctrine\\DBAL\\Connections\\MasterSlaveConnection'; + } + } else { + unset($options['slaves']); + } + + if (! empty($options['shards'])) { + $nonRewrittenKeys = [ + 'driver' => true, + 'driverOptions' => true, + 'driverClass' => true, + 'wrapperClass' => true, + 'keepSlave' => true, + 'shardChoser' => true, + 'platform' => true, + 'slaves' => true, + 'global' => true, + 'shards' => true, + 'serverVersion' => true, + 'defaultTableOptions' => true, + // included by safety but should have been unset already + 'logging' => true, + 'profiling' => true, + 'mapping_types' => true, + 'platform_service' => true, + ]; + foreach ($options as $key => $value) { + if (isset($nonRewrittenKeys[$key])) { + continue; + } + $options['global'][$key] = $value; + unset($options[$key]); + } + if (empty($options['wrapperClass'])) { + // Change the wrapper class only if the user does not already forced using a custom one. + $options['wrapperClass'] = 'Doctrine\\DBAL\\Sharding\\PoolingShardConnection'; + } + if (empty($options['shardManagerClass'])) { + // Change the shard manager class only if the user does not already forced using a custom one. + $options['shardManagerClass'] = 'Doctrine\\DBAL\\Sharding\\PoolingShardManager'; + } + } else { + unset($options['shards']); + } + + return $options; + } + + /** + * {@inheritDoc} + */ + public function getXsdValidationBasePath() : string + { + return __DIR__ . '/../Resources/config/schema'; + } + + /** + * {@inheritDoc} + */ + public function getNamespace() : string + { + return 'http://symfony.com/schema/dic/doctrine_dbal'; + } +} diff --git a/src/DoctrineDBALBundle.php b/src/DoctrineDBALBundle.php index c62d4ea..c680453 100644 --- a/src/DoctrineDBALBundle.php +++ b/src/DoctrineDBALBundle.php @@ -2,18 +2,8 @@ namespace Doctrine\Bundle\DBALBundle; -use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DbalSchemaFilterPass; -use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\EntityListenerPass; -use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass; -use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\WellKnownSchemaFilterPass; -use Doctrine\Common\Util\ClassUtils; -use Doctrine\ORM\EntityManager; -use Doctrine\ORM\Proxy\Autoloader; -use Symfony\Bridge\Doctrine\DependencyInjection\CompilerPass\DoctrineValidationPass; -use Symfony\Bridge\Doctrine\DependencyInjection\CompilerPass\RegisterEventListenersAndSubscribersPass; -use Symfony\Bridge\Doctrine\DependencyInjection\Security\UserProvider\EntityFactory; -use Symfony\Component\Console\Application; -use Symfony\Component\DependencyInjection\Compiler\PassConfig; +use Doctrine\Bundle\DBALBundle\DependencyInjection\Compiler\DbalSchemaFilterPass; +use Doctrine\Bundle\DBALBundle\DependencyInjection\Compiler\WellKnownSchemaFilterPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -22,7 +12,30 @@ class DoctrineDBALBundle extends Bundle /** * {@inheritDoc} */ - public function registerCommands(Application $application) + public function build(ContainerBuilder $container) { + parent::build($container); + $container->addCompilerPass(new WellKnownSchemaFilterPass()); + $container->addCompilerPass(new DbalSchemaFilterPass()); + } + + /** + * {@inheritDoc} + */ + public function shutdown() + { + // Close all connections to avoid reaching too many connections in the process when booting again later (tests) + if (! $this->container->hasParameter('doctrine.connections')) { + return; + } + + // TODO: use ConnectionRegistry? + foreach ($this->container->getParameter('doctrine.connections') as $id) { + if (! $this->container->initialized($id)) { + continue; + } + + $this->container->get($id)->close(); + } } } diff --git a/src/Psr11ConnectionRegistry.php b/src/Psr11ConnectionRegistry.php new file mode 100644 index 0000000..487ac69 --- /dev/null +++ b/src/Psr11ConnectionRegistry.php @@ -0,0 +1,73 @@ +container = $container; + $this->defaultConnectionName = $defaultConnectionName; + $this->connectionNames = $connectionNames; + } + + /** + * @inheritDoc + */ + public function getDefaultConnectionName() : string + { + return $this->defaultConnectionName; + } + + /** + * @inheritDoc + */ + public function getConnection(?string $name = null) : Connection + { + $name = $name ?? $this->defaultConnectionName; + + if (! $this->container->has($name)) { + throw new InvalidArgumentException(sprintf('Connection with name "%s" does not exist.', $name)); + } + + return $this->container->get($name); + } + + /** + * @inheritDoc + */ + public function getConnections() : array + { + $connections = []; + + foreach ($this->connectionNames as $connectionName) { + $connections[$connectionName] = $this->container->get($connectionName); + } + + return $connections; + } + + /** + * @inheritDoc + */ + public function getConnectionNames() : array + { + return $this->connectionNames; + } +} diff --git a/src/Resources/config/dbal.xml b/src/Resources/config/dbal.xml new file mode 100644 index 0000000..dab9fa9 --- /dev/null +++ b/src/Resources/config/dbal.xml @@ -0,0 +1,91 @@ + + + + + + Doctrine\DBAL\Logging\LoggerChain + Doctrine\DBAL\Logging\DebugStack + Symfony\Bridge\Doctrine\Logger\DbalLogger + Doctrine\DBAL\Configuration + Doctrine\Bundle\DBALBundle\DataCollector\DoctrineDBALDataCollector + Symfony\Bridge\Doctrine\ContainerAwareEventManager + Doctrine\Bundle\DBALBundle\ConnectionFactory + Doctrine\DBAL\Event\Listeners\MysqlSessionInit + Doctrine\DBAL\Event\Listeners\OracleSessionInit + + + + + + + + + + + + + + + + + + + + + + + + + %doctrine.connections% + + + + + + + %doctrine.dbal.connection_factory.types% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/schema/doctrine_dbal-1.0.xsd b/src/Resources/config/schema/doctrine_dbal-1.0.xsd new file mode 100644 index 0000000..aa09204 --- /dev/null +++ b/src/Resources/config/schema/doctrine_dbal-1.0.xsd @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/views/Collector/explain.html.twig b/src/Resources/views/Collector/explain.html.twig new file mode 100644 index 0000000..3b2dd7f --- /dev/null +++ b/src/Resources/views/Collector/explain.html.twig @@ -0,0 +1,28 @@ +{% if data[0]|length > 1 %} + {# The platform returns a table for the explanation (e.g. MySQL), display all columns #} + + + + {% for label in data[0]|keys %} + + {% endfor %} + + + + {% for row in data %} + + {% for key, item in row %} + + {% endfor %} + + {% endfor %} + +
{{ label }}
{{ item|replace({',': ', '}) }}
+{% else %} + {# The Platform returns a single column for a textual explanation (e.g. PostgreSQL), display all lines #} +
+        {%- for row in data -%}
+            {{ row|first }}{{ "\n" }}
+        {%- endfor -%}
+    
+{% endif %} diff --git a/src/Resources/views/Collector/icon.svg b/src/Resources/views/Collector/icon.svg new file mode 100644 index 0000000..49fb528 --- /dev/null +++ b/src/Resources/views/Collector/icon.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/Resources/views/Collector/panel.html.twig b/src/Resources/views/Collector/panel.html.twig new file mode 100644 index 0000000..cf430d1 --- /dev/null +++ b/src/Resources/views/Collector/panel.html.twig @@ -0,0 +1,370 @@ +{% extends request.isXmlHttpRequest ? '@WebProfiler/Profiler/ajax_layout.html.twig' : '@WebProfiler/Profiler/layout.html.twig' %} + +{% import _self as helper %} + +{% block toolbar %} + {% if collector.querycount > 0 %} + + {% set profiler_markup_version = profiler_markup_version|default(1) %} + + {% set icon %} + {% if profiler_markup_version == 1 %} + + Doctrine DBAL + {{ collector.querycount }} + {% if collector.querycount > 0 %} + in {{ '%0.2f'|format(collector.time * 1000) }} ms + {% endif %} + + {% else %} + + {% set status = collector.querycount > 50 ? 'yellow' %} + + {{ include('@DoctrineDBAL/Collector/icon.svg') }} + + {{ collector.querycount }} + + in + {{ '%0.2f'|format(collector.time * 1000) }} + ms + + + {% endif %} + {% endset %} + + {% set text %} +
+ Database Queries + {{ collector.querycount }} +
+
+ Query time + {{ '%0.2f'|format(collector.time * 1000) }} ms +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status|default('') }) }} + + {% endif %} +{% endblock %} + +{% block menu %} + {% set profiler_markup_version = profiler_markup_version|default(1) %} + + {% if profiler_markup_version == 1 %} + + + + Doctrine DBAL + + {{ collector.querycount }} + {{ '%0.0f'|format(collector.time * 1000) }} ms + + + + {% else %} + + + {{ include('@DoctrineDBAL/Collector/icon.svg') }} + Doctrine DBAL + + + {% endif %} +{% endblock %} + +{% block panel %} + {% set profiler_markup_version = profiler_markup_version|default(1) %} + + {% if 'explain' == page %} + {{ render(controller('Doctrine\\Bundle\\DBALBundle\\Controller\\ProfilerController::explainAction', { + token: token, + panel: 'doctrine.dbal', + connectionName: request.query.get('connection'), + query: request.query.get('query') + })) }} + {% else %} + {{ block('queries') }} + {% endif %} +{% endblock %} + +{% block queries %} + + + {% if profiler_markup_version > 1 %} +

Query Metrics

+ +
+
+ {{ collector.querycount }} + Database Queries +
+ +
+ {{ collector.groupedQueryCount }} + Different statements +
+ +
+ {{ '%0.2f'|format(collector.time * 1000) }} ms + Query time +
+
+ {% endif %} + + {% set group_queries = request.query.getBoolean('group') %} + {% if group_queries %} +

Grouped Statements

+

Show all queries

+ {% else %} +

Queries

+

Group similar statements

+ {% endif %} + + {% for connection, queries in collector.queries %} + {% if collector.connections|length > 1 %} +

{{ connection }} connection

+ {% endif %} + + {% if queries is empty %} +
+

No database queries were performed.

+
+ {% else %} + {% if group_queries %} + {% set queries = collector.groupedQueries[connection] %} + {% endif %} + + + + {% if group_queries %} + + + {% else %} + + + {% endif %} + + + + + {% for i, query in queries %} + {% set i = group_queries ? query.index : i %} + + {% if group_queries %} + + + {% else %} + + + {% endif %} + + + {% endfor %} + +
TimeCount#TimeInfo
+ + {{ '%0.2f'|format(query.executionMS * 1000) }} ms
({{ '%0.2f'|format(query.executionPercent) }}%)
+
{{ query.count }}{{ loop.index }}{{ '%0.2f'|format(query.executionMS * 1000) }} ms + {{ query.sql|doctrine_pretty_query(highlight_only = true) }} + +
+ Parameters: {{ profiler_dump(query.params, 2) }} +
+ +
+ View formatted query + + {% if query.runnable %} +    + View runnable query + {% endif %} + + {% if query.explainable %} +    + Explain query + {% endif %} + + {% if query.backtrace is defined %} +    + View query backtrace + {% endif %} +
+ + + + {% if query.runnable %} + + {% endif %} + + {% if query.explainable %} +
+ {% endif %} + + {% if query.backtrace is defined %} + + {% endif %} +
+ {% endif %} + {% endfor %} + +

Database Connections

+ + {% if not collector.connections %} +
+

There are no configured database connections.

+
+ {% else %} + {{ helper.render_simple_table('Name', 'Service', collector.connections) }} + {% endif %} + + +{% endblock %} + +{% macro render_simple_table(label1, label2, data) %} + + + + + + + + + {% for key, value in data %} + + + + + {% endfor %} + +
{{ label1 }}{{ label2 }}
{{ key }}{{ value }}
+{% endmacro %} diff --git a/src/Twig/DoctrineDBALExtension.php b/src/Twig/DoctrineDBALExtension.php new file mode 100644 index 0000000..9d1dfd9 --- /dev/null +++ b/src/Twig/DoctrineDBALExtension.php @@ -0,0 +1,349 @@ + true]), + new TwigFilter('doctrine_pretty_query', [$this, 'formatQuery'], ['is_safe' => ['html']]), + new TwigFilter('doctrine_replace_query_parameters', [$this, 'replaceQueryParameters']), + ]; + } + + /** + * Get the possible combinations of elements from the given array + * + * @param array $elements + * @param int $combinationsLevel + * + * @return array + */ + private function getPossibleCombinations(array $elements, $combinationsLevel) + { + $baseCount = count($elements); + $result = []; + + if ($combinationsLevel === 1) { + foreach ($elements as $element) { + $result[] = [$element]; + } + + return $result; + } + + $nextLevelElements = $this->getPossibleCombinations($elements, $combinationsLevel - 1); + + foreach ($nextLevelElements as $nextLevelElement) { + $lastElement = $nextLevelElement[$combinationsLevel - 2]; + $found = false; + + foreach ($elements as $key => $element) { + if ($element === $lastElement) { + $found = true; + continue; + } + + if ($found !== true || $key >= $baseCount) { + continue; + } + + $tmp = $nextLevelElement; + $newCombination = array_slice($tmp, 0); + $newCombination[] = $element; + $result[] = array_slice($newCombination, 0); + } + } + + return $result; + } + + /** + * Shrink the values of parameters from a combination + * + * @param array $parameters + * @param array $combination + * + * @return string + */ + private function shrinkParameters(array $parameters, array $combination) + { + array_shift($parameters); + $result = ''; + + $maxLength = $this->maxCharWidth; + $maxLength -= count($parameters) * 5; + $maxLength /= count($parameters); + + foreach ($parameters as $key => $value) { + $isLarger = false; + + if (strlen($value) > $maxLength) { + $value = wordwrap($value, $maxLength, "\n", true); + $value = explode("\n", $value); + $value = $value[0]; + + $isLarger = true; + } + $value = self::escapeFunction($value); + + if (! is_numeric($value)) { + $value = substr($value, 1, -1); + } + + if ($isLarger) { + $value .= ' [...]'; + } + + $result .= ' ' . $combination[$key] . ' ' . $value; + } + + return trim($result); + } + + /** + * Attempt to compose the best scenario minified query so that a user could find it without expanding it + * + * @param string $query + * @param array $keywords + * @param int $required + * + * @return string + */ + private function composeMiniQuery($query, array $keywords, $required) + { + // Extract the mandatory keywords and consider the rest as optional keywords + $mandatoryKeywords = array_splice($keywords, 0, $required); + + $combinations = []; + $combinationsCount = count($keywords); + + // Compute all the possible combinations of keywords to match the query for + while ($combinationsCount > 0) { + $combinations = array_merge($combinations, $this->getPossibleCombinations($keywords, $combinationsCount)); + $combinationsCount--; + } + + // Try and match the best case query pattern + foreach ($combinations as $combination) { + $combination = array_merge($mandatoryKeywords, $combination); + + $regexp = implode('(.*) ', $combination) . ' (.*)'; + $regexp = '/^' . $regexp . '/is'; + + if (preg_match($regexp, $query, $matches)) { + return $this->shrinkParameters($matches, $combination); + } + } + + // Try and match the simplest query form that contains only the mandatory keywords + $regexp = implode(' (.*)', $mandatoryKeywords) . ' (.*)'; + $regexp = '/^' . $regexp . '/is'; + + if (preg_match($regexp, $query, $matches)) { + return $this->shrinkParameters($matches, $mandatoryKeywords); + } + + // Fallback in case we didn't managed to find any good match (can we actually have that happen?!) + return substr($query, 0, $this->maxCharWidth); + } + + /** + * Minify the query + * + * @param string $query + * + * @return string + */ + public function minifyQuery($query) + { + $result = ''; + $keywords = []; + $required = 1; + + // Check if we can match the query against any of the major types + switch (true) { + case stripos($query, 'SELECT') !== false: + $keywords = ['SELECT', 'FROM', 'WHERE', 'HAVING', 'ORDER BY', 'LIMIT']; + $required = 2; + break; + + case stripos($query, 'DELETE') !== false: + $keywords = ['DELETE', 'FROM', 'WHERE', 'ORDER BY', 'LIMIT']; + $required = 2; + break; + + case stripos($query, 'UPDATE') !== false: + $keywords = ['UPDATE', 'SET', 'WHERE', 'ORDER BY', 'LIMIT']; + $required = 2; + break; + + case stripos($query, 'INSERT') !== false: + $keywords = ['INSERT', 'INTO', 'VALUE', 'VALUES']; + $required = 2; + break; + + // If there's no match so far just truncate it to the maximum allowed by the interface + default: + $result = substr($query, 0, $this->maxCharWidth); + } + + // If we had a match then we should minify it + if ($result === '') { + $result = $this->composeMiniQuery($query, $keywords, $required); + } + + return $result; + } + + /** + * Escape parameters of a SQL query + * DON'T USE THIS FUNCTION OUTSIDE ITS INTENDED SCOPE + * + * @internal + * + * @param mixed $parameter + * + * @return string + */ + public static function escapeFunction($parameter) + { + $result = $parameter; + + switch (true) { + // Check if result is non-unicode string using PCRE_UTF8 modifier + case is_string($result) && ! preg_match('//u', $result): + $result = '0x' . strtoupper(bin2hex($result)); + break; + + case is_string($result): + $result = "'" . addslashes($result) . "'"; + break; + + case is_array($result): + foreach ($result as &$value) { + $value = static::escapeFunction($value); + } + + $result = implode(', ', $result); + break; + + case is_object($result): + $result = addslashes((string) $result); + break; + + case $result === null: + $result = 'NULL'; + break; + + case is_bool($result): + $result = $result ? '1' : '0'; + break; + } + + return $result; + } + + /** + * Return a query with the parameters replaced + * + * @param string $query + * @param array|Data $parameters + * + * @return string + */ + public function replaceQueryParameters($query, $parameters) + { + if ($parameters instanceof Data) { + $parameters = $parameters->getValue(true); + } + + $i = 0; + + if (! array_key_exists(0, $parameters) && array_key_exists(1, $parameters)) { + $i = 1; + } + + return preg_replace_callback( + '/\?|((?([^"]*+)<\/pre>/Us', '\1', $html); + } else { + $html = SqlFormatter::format($sql); + $html = preg_replace('/
([^"]*+)<\/pre>/Us', '
\2
', $html); + } + + return $html; + } + + /** + * Get the name of the extension + * + * @return string + */ + public function getName() + { + return 'doctrine_dbal_extension'; + } +} diff --git a/tests/BundleTest.php b/tests/BundleTest.php new file mode 100644 index 0000000..c825114 --- /dev/null +++ b/tests/BundleTest.php @@ -0,0 +1,32 @@ +build($container); + + $config = $container->getCompilerPassConfig(); + $passes = $config->getBeforeOptimizationPasses(); + + $foundSchemaFilter = false; + + foreach ($passes as $pass) { + if ($pass instanceof DbalSchemaFilterPass) { + $foundSchemaFilter = true; + break; + } + } + + $this->assertTrue($foundSchemaFilter, 'DbalSchemaFilterPass was not found'); + } +} diff --git a/tests/Command/CreateDatabaseCommandTest.php b/tests/Command/CreateDatabaseCommandTest.php new file mode 100644 index 0000000..7a5a17c --- /dev/null +++ b/tests/Command/CreateDatabaseCommandTest.php @@ -0,0 +1,121 @@ + sys_get_temp_dir() . '/' . $dbName, + 'driver' => 'pdo_sqlite', + ]; + + $registry = $this->createRegistry($connectionName, $params); + + $application = new Application(); + $application->add(new CreateDatabaseCommand($registry)); + + $command = $application->find('doctrine:database:create'); + + $commandTester = new CommandTester($command); + $commandTester->execute( + array_merge(['command' => $command->getName()]) + ); + + $this->assertStringContainsString('Created database ' . sys_get_temp_dir() . '/' . $dbName . ' for connection named ' . $connectionName, $commandTester->getDisplay()); + } + + public function testExecuteWithShardOption() + { + $connectionName = 'foo'; + $params = [ + 'dbname' => 'test', + 'memory' => true, + 'driver' => 'pdo_sqlite', + 'global' => [ + 'driver' => 'pdo_sqlite', + 'dbname' => 'test', + 'path' => sys_get_temp_dir() . '/global', + ], + 'shards' => [ + 'foo' => [ + 'id' => 1, + 'path' => sys_get_temp_dir() . '/shard_1', + 'driver' => 'pdo_sqlite', + ], + 'bar' => [ + 'id' => 2, + 'path' => sys_get_temp_dir() . '/shard_2', + 'driver' => 'pdo_sqlite', + ], + ], + ]; + + $registry = $this->createRegistry($connectionName, $params); + + $application = new Application(); + $application->add(new CreateDatabaseCommand($registry)); + + $command = $application->find('doctrine:database:create'); + + $commandTester = new CommandTester($command); + $commandTester->execute(['command' => $command->getName(), '--shard' => 1]); + + $this->assertStringContainsString('Created database ' . sys_get_temp_dir() . '/shard_1 for connection named ' . $connectionName, $commandTester->getDisplay()); + + $commandTester = new CommandTester($command); + $commandTester->execute(['command' => $command->getName(), '--shard' => 2]); + + $this->assertStringContainsString('Created database ' . sys_get_temp_dir() . '/shard_2 for connection named ' . $connectionName, $commandTester->getDisplay()); + } + + /** + * @param string $connectionName Connection name + * @param mixed[]|null $params Connection parameters + * + * @return MockObject&ConnectionRegistry + */ + private function createRegistry($connectionName, $params = null) + { + $registry = $this->createMock(ConnectionRegistry::class); + + $registry->expects($this->any()) + ->method('getDefaultConnectionName') + ->willReturn($connectionName); + + $mockConnection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->setMethods(['getParams']) + ->getMockForAbstractClass(); + + $mockConnection->expects($this->any()) + ->method('getParams') + ->withAnyParameters() + ->willReturn($params); + + $registry->expects($this->any()) + ->method('getConnection') + ->withAnyParameters() + ->willReturn($mockConnection); + + return $registry; + } +} diff --git a/tests/Command/DropDatabaseCommandTest.php b/tests/Command/DropDatabaseCommandTest.php new file mode 100644 index 0000000..43633bf --- /dev/null +++ b/tests/Command/DropDatabaseCommandTest.php @@ -0,0 +1,110 @@ + 'sqlite:///' . sys_get_temp_dir() . '/test.db', + 'path' => sys_get_temp_dir() . '/' . $dbName, + 'driver' => 'pdo_sqlite', + ]; + + $registry = $this->createRegistry($connectionName, $params); + + $application = new Application(); + $application->add(new DropDatabaseCommand($registry)); + + $command = $application->find('doctrine:database:drop'); + + $commandTester = new CommandTester($command); + $commandTester->execute( + array_merge(['command' => $command->getName(), '--force' => true]) + ); + + $this->assertStringContainsString( + sprintf( + 'Dropped database %s for connection named %s', + sys_get_temp_dir() . '/' . $dbName, + $connectionName + ), + $commandTester->getDisplay() + ); + } + + public function testExecuteWithoutOptionForceWillFailWithAttentionMessage() + { + $connectionName = 'default'; + $dbName = 'test'; + $params = [ + 'path' => sys_get_temp_dir() . '/' . $dbName, + 'driver' => 'pdo_sqlite', + ]; + + $registry = $this->createRegistry($connectionName, $params); + + $application = new Application(); + $application->add(new DropDatabaseCommand($registry)); + + $command = $application->find('doctrine:database:drop'); + + $commandTester = new CommandTester($command); + $commandTester->execute( + array_merge(['command' => $command->getName()]) + ); + + $this->assertStringContainsString( + sprintf( + 'Would drop the database %s for connection named %s.', + sys_get_temp_dir() . '/' . $dbName, + $connectionName + ), + $commandTester->getDisplay() + ); + $this->assertStringContainsString('Please run the operation with --force to execute', $commandTester->getDisplay()); + } + + /** + * @param string $connectionName Connection name + * @param mixed[]|null $params Connection parameters + * + * @return MockObject&ConnectionRegistry + */ + private function createRegistry($connectionName, $params = null) + { + $registry = $this->createMock(ConnectionRegistry::class); + + $registry->expects($this->any()) + ->method('getDefaultConnectionName') + ->willReturn($connectionName); + + $mockConnection = $this->getMockBuilder(Connection::class) + ->disableOriginalConstructor() + ->setMethods(['getParams']) + ->getMockForAbstractClass(); + + $mockConnection->expects($this->any()) + ->method('getParams') + ->withAnyParameters() + ->willReturn($params); + + $registry->expects($this->any()) + ->method('getConnection') + ->withAnyParameters() + ->willReturn($mockConnection); + + return $registry; + } +} diff --git a/tests/ConnectionFactoryTest.php b/tests/ConnectionFactoryTest.php new file mode 100644 index 0000000..99a0bbb --- /dev/null +++ b/tests/ConnectionFactoryTest.php @@ -0,0 +1,148 @@ +expectException(DBALException::class); + + $typesConfig = []; + $factory = new ConnectionFactory($typesConfig); + $params = ['driverClass' => FakeDriver::class]; + $config = null; + $eventManager = null; + $mappingTypes = [0]; + $exception = new DriverException('', $this->createMock(Driver\AbstractDriverException::class)); + + // put the mock into the fake driver + FakeDriver::$exception = $exception; + + try { + $factory->createConnection($params, $config, $eventManager, $mappingTypes); + } catch (Exception $e) { + $this->assertTrue(strpos($e->getMessage(), 'can circumvent this by setting') > 0); + throw $e; + } finally { + FakeDriver::$exception = null; + } + } + + public function testDefaultCharset() : void + { + $factory = new ConnectionFactory([]); + $params = [ + 'driverClass' => FakeDriver::class, + 'wrapperClass' => FakeConnection::class, + ]; + + $creationCount = FakeConnection::$creationCount; + $connection = $factory->createConnection($params); + + $this->assertInstanceof(FakeConnection::class, $connection); + $this->assertSame('utf8', $connection->getParams()['charset']); + $this->assertSame(1 + $creationCount, FakeConnection::$creationCount); + } + + public function testDefaultCharsetMySql() : void + { + $factory = new ConnectionFactory([]); + $params = ['driver' => 'pdo_mysql']; + + $connection = $factory->createConnection($params); + + $this->assertSame('utf8mb4', $connection->getParams()['charset']); + } +} + +/** + * FakeDriver class to simulate a problem discussed in DoctrineBundle issue #673 + * In order to not use a real database driver we have to create our own fake/mock implementation. + * + * @link https://github.com/doctrine/DoctrineBundle/issues/673 + */ +class FakeDriver implements Driver +{ + /** + * Exception Mock + * + * @var DriverException + */ + public static $exception; + + /** @var AbstractPlatform|null */ + public static $platform; + + /** + * This method gets called to determine the database version which in our case leeds to the problem. + * So we have to fake the exception a driver would normally throw. + * + * @link https://github.com/doctrine/DoctrineBundle/issues/673 + */ + public function getDatabasePlatform() : AbstractPlatform + { + if (self::$exception !== null) { + throw self::$exception; + } + + return static::$platform ?? new MySqlPlatform(); + } + + // ----- below this line follow only dummy methods to satisfy the interface requirements ---- + + /** + * @param mixed[] $params + * @param string|null $username + * @param string|null $password + * @param mixed[] $driverOptions + */ + public function connect(array $params, $username = null, $password = null, array $driverOptions = []) : void + { + throw new Exception('not implemented'); + } + + public function getSchemaManager(Connection $conn) : void + { + throw new Exception('not implemented'); + } + + public function getName() : string + { + return 'FakeDriver'; + } + + public function getDatabase(Connection $conn) : string + { + return 'fake_db'; + } +} + +class FakeConnection extends Connection +{ + /** @var int */ + public static $creationCount = 0; + + public function __construct( + array $params, + FakeDriver $driver, + ?Configuration $config = null, + ?EventManager $eventManager = null + ) { + ++self::$creationCount; + + parent::__construct($params, $driver, $config, $eventManager); + } +} diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php new file mode 100644 index 0000000..c92ca66 --- /dev/null +++ b/tests/ContainerTest.php @@ -0,0 +1,35 @@ +createXmlBundleTestContainer(); + + $this->assertInstanceOf(DbalLogger::class, $container->get('doctrine.dbal.logger')); + $this->assertInstanceOf(DoctrineDBALDataCollector::class, $container->get('data_collector.doctrine.dbal')); + $this->assertInstanceOf(DBALConfiguration::class, $container->get('doctrine.dbal.default_connection.configuration')); + $this->assertInstanceOf(EventManager::class, $container->get('doctrine.dbal.default_connection.event_manager')); + $this->assertInstanceOf(Connection::class, $container->get('doctrine.dbal.default_connection')); + $this->assertInstanceOf(Connection::class, $container->get('database_connection')); + $this->assertInstanceOf(EventManager::class, $container->get('doctrine.dbal.event_manager')); + + $this->assertSame($container->get('my.platform'), $container->get('doctrine.dbal.default_connection')->getDatabasePlatform()); + + $this->assertTrue(Type::hasType('test')); + + $this->assertFalse($container->has('doctrine.dbal.default_connection.events.mysqlsessioninit')); + } +} diff --git a/tests/ContainerTestCase.php b/tests/ContainerTestCase.php new file mode 100644 index 0000000..3bb7cb9 --- /dev/null +++ b/tests/ContainerTestCase.php @@ -0,0 +1,85 @@ + 'app', + 'kernel.debug' => false, + 'kernel.bundles' => ['XmlBundle' => 'Fixtures\Bundles\XmlBundle\XmlBundle'], + 'kernel.cache_dir' => sys_get_temp_dir(), + 'kernel.environment' => 'test', + 'kernel.root_dir' => __DIR__ . '/../../../../', // src dir + 'kernel.project_dir' => __DIR__ . '/../../../../', // src dir + 'kernel.bundles_metadata' => [], + 'container.build_id' => uniqid(), + ])); + $container->set('annotation_reader', new AnnotationReader()); + + $extension = new DoctrineDBALExtension(); + $container->registerExtension($extension); + $extension->load([ + [ + 'connections' => [ + 'default' => [ + 'driver' => 'pdo_mysql', + 'charset' => 'UTF8', + 'platform-service' => 'my.platform', + ], + ], + 'default_connection' => 'default', + 'types' => [ + 'test' => [ + 'class' => TestType::class, + 'commented' => false, + ], + ], + ], + ], $container); + + $container->setDefinition('my.platform', new Definition('Doctrine\DBAL\Platforms\MySqlPlatform'))->setPublic(true); + + $container->getCompilerPassConfig()->setOptimizationPasses([new ResolveChildDefinitionsPass()]); + $container->getCompilerPassConfig()->setRemovingPasses([]); + // make all Doctrine services public, so we can fetch them in the test + $container->getCompilerPassConfig()->addPass(new TestCaseAllPublicCompilerPass()); + $container->compile(); + + return $container; + } +} + +class TestCaseAllPublicCompilerPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + foreach ($container->getDefinitions() as $id => $definition) { + if (strpos($id, 'doctrine') === false) { + continue; + } + + $definition->setPublic(true); + } + + foreach ($container->getAliases() as $id => $alias) { + if (strpos($id, 'doctrine') === false) { + continue; + } + + $alias->setPublic(true); + } + } +} diff --git a/tests/DBAL/Logging/BacktraceLoggerTest.php b/tests/DBAL/Logging/BacktraceLoggerTest.php new file mode 100644 index 0000000..9fdb36d --- /dev/null +++ b/tests/DBAL/Logging/BacktraceLoggerTest.php @@ -0,0 +1,20 @@ +startQuery('SELECT column FROM table'); + $currentQuery = current($logger->queries); + self::assertSame('SELECT column FROM table', $currentQuery['sql']); + self::assertNull($currentQuery['params']); + self::assertNull($currentQuery['types']); + self::assertGreaterThan(0, $currentQuery['backtrace']); + } +} diff --git a/tests/DBAL/RegexSchemaAssetFilterTest.php b/tests/DBAL/RegexSchemaAssetFilterTest.php new file mode 100644 index 0000000..b7fd509 --- /dev/null +++ b/tests/DBAL/RegexSchemaAssetFilterTest.php @@ -0,0 +1,17 @@ +assertTrue($filter('do_not_t_ignore_me')); + $this->assertFalse($filter('t_ignore_me')); + } +} diff --git a/tests/DBAL/SchemaAssetsFilterManagerTest.php b/tests/DBAL/SchemaAssetsFilterManagerTest.php new file mode 100644 index 0000000..1166547 --- /dev/null +++ b/tests/DBAL/SchemaAssetsFilterManagerTest.php @@ -0,0 +1,23 @@ +assertSame( + ['do_not_filter'], + array_values(array_filter($tables, $manager)) + ); + } +} diff --git a/tests/DataCollector/DoctrineDBALDataCollectorTest.php b/tests/DataCollector/DoctrineDBALDataCollectorTest.php new file mode 100644 index 0000000..c652196 --- /dev/null +++ b/tests/DataCollector/DoctrineDBALDataCollectorTest.php @@ -0,0 +1,63 @@ +getMockBuilder(DebugStack::class)->getMock(); + $logger->queries = []; + $logger->queries[] = [ + 'sql' => 'SELECT * FROM foo WHERE bar = :bar', + 'params' => [':bar' => 1], + 'types' => null, + 'executionMS' => 32, + ]; + $logger->queries[] = [ + 'sql' => 'SELECT * FROM foo WHERE bar = :bar', + 'params' => [':bar' => 2], + 'types' => null, + 'executionMS' => 25, + ]; + $collector = $this->createCollector(); + $collector->addLogger('default', $logger); + $collector->collect(new Request(), new Response()); + $groupedQueries = $collector->getGroupedQueries(); + $this->assertCount(1, $groupedQueries['default']); + $this->assertSame('SELECT * FROM foo WHERE bar = :bar', $groupedQueries['default'][0]['sql']); + $this->assertSame(2, $groupedQueries['default'][0]['count']); + + $logger->queries[] = [ + 'sql' => 'SELECT * FROM bar', + 'params' => [], + 'types' => null, + 'executionMS' => 25, + ]; + $collector->collect(new Request(), new Response()); + $groupedQueries = $collector->getGroupedQueries(); + $this->assertCount(2, $groupedQueries['default']); + $this->assertSame('SELECT * FROM bar', $groupedQueries['default'][1]['sql']); + $this->assertSame(1, $groupedQueries['default'][1]['count']); + } + + private function createCollector() : DoctrineDBALDataCollector + { + $registry = $this->createMock(ConnectionRegistry::class); + $registry + ->expects($this->any()) + ->method('getConnection') + ->with('default') + ->will($this->returnValue($this->createMock(Connection::class))); + + return new DoctrineDBALDataCollector($registry, ['default' => 'doctrine.dbal.default_connection']); + } +} diff --git a/tests/DependencyInjection/AbstractDoctrineExtensionTest.php b/tests/DependencyInjection/AbstractDoctrineExtensionTest.php new file mode 100644 index 0000000..164499f --- /dev/null +++ b/tests/DependencyInjection/AbstractDoctrineExtensionTest.php @@ -0,0 +1,454 @@ +loadContainer('dbal_service_multiple_connections'); + + // doctrine.dbal.mysql_connection + $config = $container->getDefinition('doctrine.dbal.mysql_connection')->getArgument(0); + + $this->assertEquals('mysql_s3cr3t', $config['password']); + $this->assertEquals('mysql_user', $config['user']); + $this->assertEquals('mysql_db', $config['dbname']); + $this->assertEquals('/path/to/mysqld.sock', $config['unix_socket']); + + // doctrine.dbal.sqlite_connection + $config = $container->getDefinition('doctrine.dbal.sqlite_connection')->getArgument(0); + $this->assertSame('pdo_sqlite', $config['driver']); + $this->assertSame('sqlite_db', $config['dbname']); + $this->assertSame('sqlite_user', $config['user']); + $this->assertSame('sqlite_s3cr3t', $config['password']); + $this->assertSame('/tmp/db.sqlite', $config['path']); + $this->assertTrue($config['memory']); + + // doctrine.dbal.oci8_connection + $config = $container->getDefinition('doctrine.dbal.oci_connection')->getArgument(0); + $this->assertSame('oci8', $config['driver']); + $this->assertSame('oracle_db', $config['dbname']); + $this->assertSame('oracle_user', $config['user']); + $this->assertSame('oracle_s3cr3t', $config['password']); + $this->assertSame('oracle_service', $config['servicename']); + $this->assertTrue($config['service']); + $this->assertTrue($config['pooled']); + $this->assertSame('utf8', $config['charset']); + + // doctrine.dbal.ibmdb2_connection + $config = $container->getDefinition('doctrine.dbal.ibmdb2_connection')->getArgument(0); + $this->assertSame('ibm_db2', $config['driver']); + $this->assertSame('ibmdb2_db', $config['dbname']); + $this->assertSame('ibmdb2_user', $config['user']); + $this->assertSame('ibmdb2_s3cr3t', $config['password']); + $this->assertSame('TCPIP', $config['protocol']); + + // doctrine.dbal.pgsql_connection + $config = $container->getDefinition('doctrine.dbal.pgsql_connection')->getArgument(0); + $this->assertSame('pdo_pgsql', $config['driver']); + $this->assertSame('pgsql_schema', $config['dbname']); + $this->assertSame('pgsql_user', $config['user']); + $this->assertSame('pgsql_s3cr3t', $config['password']); + $this->assertSame('pgsql_db', $config['default_dbname']); + $this->assertSame('require', $config['sslmode']); + $this->assertSame('postgresql-ca.pem', $config['sslrootcert']); + $this->assertSame('postgresql-cert.pem', $config['sslcert']); + $this->assertSame('postgresql-key.pem', $config['sslkey']); + $this->assertSame('postgresql.crl', $config['sslcrl']); + $this->assertSame('utf8', $config['charset']); + + // doctrine.dbal.sqlanywhere_connection + $config = $container->getDefinition('doctrine.dbal.sqlanywhere_connection')->getArgument(0); + $this->assertSame('sqlanywhere', $config['driver']); + $this->assertSame('localhost', $config['host']); + $this->assertSame(2683, $config['port']); + $this->assertSame('sqlanywhere_server', $config['server']); + $this->assertSame('sqlanywhere_db', $config['dbname']); + $this->assertSame('sqlanywhere_user', $config['user']); + $this->assertSame('sqlanywhere_s3cr3t', $config['password']); + $this->assertTrue($config['persistent']); + $this->assertSame('utf8', $config['charset']); + } + + public function testDbalLoadFromXmlSingleConnections() + { + $container = $this->loadContainer('dbal_service_single_connection'); + + // doctrine.dbal.mysql_connection + $config = $container->getDefinition('doctrine.dbal.default_connection')->getArgument(0); + + $this->assertEquals('mysql_s3cr3t', $config['password']); + $this->assertEquals('mysql_user', $config['user']); + $this->assertEquals('mysql_db', $config['dbname']); + $this->assertEquals('/path/to/mysqld.sock', $config['unix_socket']); + $this->assertEquals('5.6.20', $config['serverVersion']); + } + + public function testDbalLoadSingleMasterSlaveConnection() + { + $container = $this->loadContainer('dbal_service_single_master_slave_connection'); + + // doctrine.dbal.mysql_connection + $param = $container->getDefinition('doctrine.dbal.default_connection')->getArgument(0); + + $this->assertEquals('Doctrine\\DBAL\\Connections\\MasterSlaveConnection', $param['wrapperClass']); + $this->assertTrue($param['keepSlave']); + $this->assertEquals( + [ + 'user' => 'mysql_user', + 'password' => 'mysql_s3cr3t', + 'port' => null, + 'dbname' => 'mysql_db', + 'host' => 'localhost', + 'unix_socket' => '/path/to/mysqld.sock', + ], + $param['master'] + ); + $this->assertEquals( + [ + 'user' => 'slave_user', + 'password' => 'slave_s3cr3t', + 'port' => null, + 'dbname' => 'slave_db', + 'host' => 'localhost', + 'unix_socket' => '/path/to/mysqld_slave.sock', + ], + $param['slaves']['slave1'] + ); + $this->assertEquals(['engine' => 'InnoDB'], $param['defaultTableOptions']); + } + + public function testDbalLoadPoolShardingConnection() + { + $container = $this->loadContainer('dbal_service_pool_sharding_connection'); + + // doctrine.dbal.mysql_connection + $param = $container->getDefinition('doctrine.dbal.default_connection')->getArgument(0); + + $this->assertEquals('Doctrine\\DBAL\\Sharding\\PoolingShardConnection', $param['wrapperClass']); + $this->assertEquals(new Reference('foo.shard_choser'), $param['shardChoser']); + $this->assertEquals( + [ + 'user' => 'mysql_user', + 'password' => 'mysql_s3cr3t', + 'port' => null, + 'dbname' => 'mysql_db', + 'host' => 'localhost', + 'unix_socket' => '/path/to/mysqld.sock', + ], + $param['global'] + ); + $this->assertEquals( + [ + 'user' => 'shard_user', + 'password' => 'shard_s3cr3t', + 'port' => null, + 'dbname' => 'shard_db', + 'host' => 'localhost', + 'unix_socket' => '/path/to/mysqld_shard.sock', + 'id' => 1, + ], + $param['shards'][0] + ); + $this->assertEquals(['engine' => 'InnoDB'], $param['defaultTableOptions']); + } + + public function testDbalLoadSavepointsForNestedTransactions() + { + $container = $this->loadContainer('dbal_savepoints'); + + $calls = $container->getDefinition('doctrine.dbal.savepoints_connection')->getMethodCalls(); + $this->assertCount(1, $calls); + $this->assertEquals('setNestTransactionsWithSavepoints', $calls[0][0]); + $this->assertTrue($calls[0][1][0]); + + $calls = $container->getDefinition('doctrine.dbal.nosavepoints_connection')->getMethodCalls(); + $this->assertCount(0, $calls); + + $calls = $container->getDefinition('doctrine.dbal.notset_connection')->getMethodCalls(); + $this->assertCount(0, $calls); + } + + public function testLoadLogging() + { + $container = $this->loadContainer('dbal_logging'); + + $definition = $container->getDefinition('doctrine.dbal.log_connection.configuration'); + $this->assertDICDefinitionMethodCallOnce($definition, 'setSQLLogger', [new Reference('doctrine.dbal.logger')]); + + $definition = $container->getDefinition('doctrine.dbal.profile_connection.configuration'); + $this->assertDICDefinitionMethodCallOnce($definition, 'setSQLLogger', [new Reference('doctrine.dbal.logger.profiling.profile')]); + + $definition = $container->getDefinition('doctrine.dbal.profile_with_backtrace_connection.configuration'); + $this->assertDICDefinitionMethodCallOnce($definition, 'setSQLLogger', [new Reference('doctrine.dbal.logger.backtrace.profile_with_backtrace')]); + + $definition = $container->getDefinition('doctrine.dbal.backtrace_without_profile_connection.configuration'); + $this->assertDICDefinitionNoMethodCall($definition, 'setSQLLogger'); + + $definition = $container->getDefinition('doctrine.dbal.both_connection.configuration'); + $this->assertDICDefinitionMethodCallOnce($definition, 'setSQLLogger', [new Reference('doctrine.dbal.logger.chain.both')]); + } + + public function testSetTypes() + { + $container = $this->loadContainer('dbal_types'); + + $this->assertEquals( + ['test' => ['class' => TestType::class]], + $container->getParameter('doctrine.dbal.connection_factory.types') + ); + $this->assertEquals('%doctrine.dbal.connection_factory.types%', $container->getDefinition('doctrine.dbal.connection_factory')->getArgument(0)); + } + + public function testDbalAutoCommit() + { + $container = $this->loadContainer('dbal_auto_commit'); + + $definition = $container->getDefinition('doctrine.dbal.default_connection.configuration'); + $this->assertDICDefinitionMethodCallOnce($definition, 'setAutoCommit', [false]); + } + + public function testDbalOracleConnectstring() + { + $container = $this->loadContainer('dbal_oracle_connectstring'); + + $config = $container->getDefinition('doctrine.dbal.default_connection')->getArgument(0); + $this->assertSame('scott@sales-server:1521/sales.us.example.com', $config['connectstring']); + } + + public function testDbalOracleInstancename() + { + $container = $this->loadContainer('dbal_oracle_instancename'); + + $config = $container->getDefinition('doctrine.dbal.default_connection')->getArgument(0); + $this->assertSame('mySuperInstance', $config['instancename']); + } + + public function testDbalSchemaFilterNewConfig() + { + $container = $this->getContainer([]); + $loader = new DoctrineDBALExtension(); + $container->registerExtension($loader); + $container->addCompilerPass(new WellKnownSchemaFilterPass()); + $container->addCompilerPass(new DbalSchemaFilterPass()); + + // ignore table1 table on "default" connection + $container->register('dummy_filter1', DummySchemaAssetsFilter::class) + ->setArguments(['table1']) + ->addTag('doctrine.dbal.schema_filter'); + + // ignore table2 table on "connection2" connection + $container->register('dummy_filter2', DummySchemaAssetsFilter::class) + ->setArguments(['table2']) + ->addTag('doctrine.dbal.schema_filter', ['connection' => 'connection2']); + + $this->loadFromFile($container, 'dbal_schema_filter'); + + $assetNames = ['table1', 'table2', 'table3', 't_ignored']; + $expectedConnectionAssets = [ + // ignores table1 + schema_filter applies + 'connection1' => ['table2', 'table3'], + // ignores table2, no schema_filter applies + 'connection2' => ['table1', 'table3', 't_ignored'], + // connection3 has no ignores, handled separately + ]; + + $this->compileContainer($container); + + $getConfiguration = static function (string $connectionName) use ($container) : Configuration { + return $container->get(sprintf('doctrine.dbal.%s_connection', $connectionName))->getConfiguration(); + }; + + foreach ($expectedConnectionAssets as $connectionName => $expectedTables) { + $connConfig = $getConfiguration($connectionName); + $this->assertSame($expectedTables, array_values(array_filter($assetNames, $connConfig->getSchemaAssetsFilter())), sprintf('Filtering for connection "%s"', $connectionName)); + } + + $this->assertNull($connConfig = $getConfiguration('connection3')->getSchemaAssetsFilter()); + } + + public function testWellKnownSchemaFilterDefaultTables() + { + $container = $this->getContainer([]); + $loader = new DoctrineDBALExtension(); + $container->registerExtension($loader); + $container->addCompilerPass(new WellKnownSchemaFilterPass()); + $container->addCompilerPass(new DbalSchemaFilterPass()); + + $this->loadFromFile($container, 'well_known_schema_filter_default_tables'); + + $this->compileContainer($container); + + $definition = $container->getDefinition('doctrine.dbal.well_known_schema_asset_filter'); + + $this->assertSame([['cache_items', 'lock_keys', 'sessions', 'messenger_messages']], $definition->getArguments()); + $this->assertSame([['connection' => 'connection1'], ['connection' => 'connection2'], ['connection' => 'connection3']], $definition->getTag('doctrine.dbal.schema_filter')); + + $definition = $container->getDefinition('doctrine.dbal.connection1_schema_asset_filter_manager'); + + $this->assertEquals([new Reference('doctrine.dbal.well_known_schema_asset_filter'), new Reference('doctrine.dbal.connection1_regex_schema_filter')], $definition->getArgument(0)); + + $filter = $container->get('well_known_filter'); + + $this->assertFalse($filter('sessions')); + $this->assertFalse($filter('cache_items')); + $this->assertFalse($filter('lock_keys')); + $this->assertFalse($filter('messenger_messages')); + $this->assertTrue($filter('anything_else')); + } + + public function testWellKnownSchemaFilterOverriddenTables() + { + $container = $this->getContainer([]); + $loader = new DoctrineDBALExtension(); + $container->registerExtension($loader); + $container->addCompilerPass(new WellKnownSchemaFilterPass()); + $container->addCompilerPass(new DbalSchemaFilterPass()); + + $this->loadFromFile($container, 'well_known_schema_filter_overridden_tables'); + + $this->compileContainer($container); + + $filter = $container->get('well_known_filter'); + + $this->assertFalse($filter('app_session')); + $this->assertFalse($filter('app_cache')); + $this->assertFalse($filter('app_locks')); + $this->assertFalse($filter('app_messages')); + $this->assertTrue($filter('sessions')); + $this->assertTrue($filter('cache_items')); + $this->assertTrue($filter('lock_keys')); + $this->assertTrue($filter('messenger_messages')); + } + + private function loadContainer($fixture) + { + $container = $this->getContainer([]); + $container->registerExtension(new DoctrineDBALExtension()); + + $this->loadFromFile($container, $fixture); + $this->compileContainer($container); + + return $container; + } + + private function getContainer() + { + $container = new ContainerBuilder(new ParameterBag([ + 'kernel.name' => 'app', + 'kernel.debug' => false, + 'kernel.bundles' => [], + 'kernel.cache_dir' => sys_get_temp_dir(), + 'kernel.environment' => 'test', + 'kernel.root_dir' => __DIR__ . '/../../', // src dir + 'kernel.project_dir' => __DIR__ . '/../../', // src dir + 'kernel.bundles_metadata' => [], + 'container.build_id' => uniqid(), + ])); + + $container->registerExtension(new DoctrineDBALExtension()); + + // Register dummy cache services so we don't have to load the FrameworkExtension + $container->setDefinition('cache.system', (new Definition(ArrayAdapter::class))->setPublic(true)); + $container->setDefinition('cache.app', (new Definition(ArrayAdapter::class))->setPublic(true)); + + return $container; + } + + /** + * Assertion for the DI Container, check if the given definition contains a method call with the given parameters. + * + * @param string $methodName + * @param array $params + */ + private function assertDICDefinitionMethodCallOnce(Definition $definition, $methodName, array $params = null) + { + $calls = $definition->getMethodCalls(); + $called = false; + foreach ($calls as $call) { + if ($call[0] !== $methodName) { + continue; + } + + if ($called) { + $this->fail("Method '" . $methodName . "' is expected to be called only once, a second call was registered though."); + } else { + $called = true; + if ($params !== null) { + $this->assertEquals($params, $call[1], "Expected parameters to methods '" . $methodName . "' do not match the actual parameters."); + } + } + } + if ($called) { + return; + } + + $this->fail("Method '" . $methodName . "' is expected to be called once, definition does not contain a call though."); + } + + /** + * Assertion for the DI Container, check if the given definition does not contain a method call with the given parameters. + * + * @param string $methodName + * @param array $params + */ + private function assertDICDefinitionNoMethodCall(Definition $definition, $methodName, array $params = null) + { + $calls = $definition->getMethodCalls(); + foreach ($calls as $call) { + if ($call[0] !== $methodName) { + continue; + } + + if ($params !== null) { + $this->assertNotEquals($params, $call[1], "Method '" . $methodName . "' is not expected to be called with the given parameters."); + } else { + $this->fail("Method '" . $methodName . "' is not expected to be called"); + } + } + } + + private function compileContainer(ContainerBuilder $container) + { + $container->getCompilerPassConfig()->setOptimizationPasses([new ResolveChildDefinitionsPass()]); + $container->getCompilerPassConfig()->setRemovingPasses([]); + $container->compile(); + } +} + +class DummySchemaAssetsFilter +{ + /** @var string */ + private $tableToIgnore; + + public function __construct(string $tableToIgnore) + { + $this->tableToIgnore = $tableToIgnore; + } + + public function __invoke($assetName) : bool + { + if ($assetName instanceof AbstractAsset) { + $assetName = $assetName->getName(); + } + + return $assetName !== $this->tableToIgnore; + } +} diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_auto_commit.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_auto_commit.xml new file mode 100644 index 0000000..d20d4b1 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_auto_commit.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_logging.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_logging.xml new file mode 100644 index 0000000..2dc481d --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_logging.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_oracle_connectstring.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_oracle_connectstring.xml new file mode 100644 index 0000000..d6bef4b --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_oracle_connectstring.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_oracle_instancename.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_oracle_instancename.xml new file mode 100644 index 0000000..cef7b5c --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_oracle_instancename.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_savepoints.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_savepoints.xml new file mode 100644 index 0000000..317dea8 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_savepoints.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_schema_filter.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_schema_filter.xml new file mode 100644 index 0000000..67d9675 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_schema_filter.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_service_multiple_connections.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_service_multiple_connections.xml new file mode 100644 index 0000000..59a4ad2 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_service_multiple_connections.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_service_pool_sharding_connection.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_service_pool_sharding_connection.xml new file mode 100644 index 0000000..b5afd13 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_service_pool_sharding_connection.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + InnoDB + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_service_single_connection.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_service_single_connection.xml new file mode 100644 index 0000000..a4d0f85 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_service_single_connection.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_service_single_master_slave_connection.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_service_single_master_slave_connection.xml new file mode 100644 index 0000000..031ccb3 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_service_single_master_slave_connection.xml @@ -0,0 +1,13 @@ + + + + + + + InnoDB + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/dbal_types.xml b/tests/DependencyInjection/Fixtures/config/xml/dbal_types.xml new file mode 100644 index 0000000..a8aaed8 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/dbal_types.xml @@ -0,0 +1,13 @@ + + + + + + Doctrine\Bundle\DBALBundle\Tests\Fixtures\TestType + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/well_known_schema_filter_default_tables.xml b/tests/DependencyInjection/Fixtures/config/xml/well_known_schema_filter_default_tables.xml new file mode 100644 index 0000000..83b1654 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/well_known_schema_filter_default_tables.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/config/xml/well_known_schema_filter_overridden_tables.xml b/tests/DependencyInjection/Fixtures/config/xml/well_known_schema_filter_overridden_tables.xml new file mode 100644 index 0000000..a8a4dc6 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/xml/well_known_schema_filter_overridden_tables.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + app_cache + + + + + + app_session + + + + + + app_locks + + + + + app_messages + + + + diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_auto_commit.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_auto_commit.yml new file mode 100644 index 0000000..a692a41 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_auto_commit.yml @@ -0,0 +1,2 @@ +doctrine_dbal: + auto_commit: false diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_logging.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_logging.yml new file mode 100644 index 0000000..b78ab04 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_logging.yml @@ -0,0 +1,20 @@ +doctrine_dbal: + default_connection: mysql + connections: + log: + logging: true + profiling: false + profile: + logging: false + profiling: true + profile_with_backtrace: + logging: false + profiling: true + profiling_collect_backtrace: true + backtrace_without_profile: + logging: false + profiling: false + profiling_collect_backtrace: true + both: + logging: true + profiling: true diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_oracle_connectstring.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_oracle_connectstring.yml new file mode 100644 index 0000000..edad0b7 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_oracle_connectstring.yml @@ -0,0 +1,2 @@ +doctrine_dbal: + connectstring: scott@sales-server:1521/sales.us.example.com diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_oracle_instancename.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_oracle_instancename.yml new file mode 100644 index 0000000..14e7e3f --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_oracle_instancename.yml @@ -0,0 +1,2 @@ +doctrine_dbal: + instancename: mySuperInstance diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_savepoints.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_savepoints.yml new file mode 100644 index 0000000..973e05d --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_savepoints.yml @@ -0,0 +1,9 @@ +doctrine_dbal: + default_connection: savepoints + connections: + savepoints: + use_savepoints: true + nosavepoints: + use_savepoints: false + notset: + user: root diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_schema_filter.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_schema_filter.yml new file mode 100644 index 0000000..b49ec1b --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_schema_filter.yml @@ -0,0 +1,7 @@ +doctrine_dbal: + default_connection: connection1 + connections: + connection1: + schema_filter: ~^(?!t_)~ + connection2: [] + connection3: [] diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_service_multiple_connections.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_service_multiple_connections.yml new file mode 100644 index 0000000..a8af0f3 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_service_multiple_connections.yml @@ -0,0 +1,52 @@ +doctrine_dbal: + default_connection: mysql + connections: + mysql: + dbname: mysql_db + user: mysql_user + password: mysql_s3cr3t + unix_socket: /path/to/mysqld.sock + sqlite: + driver: pdo_sqlite + dbname: sqlite_db + user: sqlite_user + password: sqlite_s3cr3t + path: /tmp/db.sqlite + memory: true + oci: + driver: oci8 + dbname: oracle_db + user: oracle_user + password: oracle_s3cr3t + servicename: oracle_service + service: true + pooled: true + charset: utf8 + ibmdb2: + driver: ibm_db2 + dbname: ibmdb2_db + user: ibmdb2_user + password: ibmdb2_s3cr3t + protocol: TCPIP + pgsql: + driver: pdo_pgsql + dbname: pgsql_schema + user: pgsql_user + password: pgsql_s3cr3t + default_dbname: pgsql_db + sslmode: require + sslrootcert: postgresql-ca.pem + sslcert: postgresql-cert.pem + sslkey: postgresql-key.pem + sslcrl: postgresql.crl + charset: utf8 + sqlanywhere: + driver: sqlanywhere + host: localhost + port: 2683 + server: sqlanywhere_server + dbname: sqlanywhere_db + user: sqlanywhere_user + password: sqlanywhere_s3cr3t + persistent: true + charset: utf8 diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_service_pool_sharding_connection.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_service_pool_sharding_connection.yml new file mode 100644 index 0000000..b89a214 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_service_pool_sharding_connection.yml @@ -0,0 +1,19 @@ +doctrine_dbal: + dbname: mysql_db + user: mysql_user + password: mysql_s3cr3t + unix_socket: /path/to/mysqld.sock + shard_choser_service: foo.shard_choser + default_table_options: + engine: InnoDB + shards: + - + id: 1 + user: shard_user + dbname: shard_db + password: shard_s3cr3t + unix_socket: /path/to/mysqld_shard.sock + +services: + foo.shard_choser: + class: stdClass diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_service_single_connection.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_service_single_connection.yml new file mode 100644 index 0000000..b176bfc --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_service_single_connection.yml @@ -0,0 +1,6 @@ +doctrine_dbal: + dbname: mysql_db + user: mysql_user + password: mysql_s3cr3t + unix_socket: /path/to/mysqld.sock + server_version: 5.6.20 diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_service_single_master_slave_connection.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_service_single_master_slave_connection.yml new file mode 100644 index 0000000..b031bc8 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_service_single_master_slave_connection.yml @@ -0,0 +1,14 @@ +doctrine_dbal: + dbname: mysql_db + user: mysql_user + password: mysql_s3cr3t + unix_socket: /path/to/mysqld.sock + keep_slave: true + default_table_options: + engine: InnoDB + slaves: + slave1: + user: slave_user + dbname: slave_db + password: slave_s3cr3t + unix_socket: /path/to/mysqld_slave.sock diff --git a/tests/DependencyInjection/Fixtures/config/yml/dbal_types.yml b/tests/DependencyInjection/Fixtures/config/yml/dbal_types.yml new file mode 100644 index 0000000..fc84299 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/dbal_types.yml @@ -0,0 +1,6 @@ +doctrine_dbal: + default_connection: default + types: + test: Doctrine\Bundle\DBALBundle\Tests\Fixtures\TestType + connections: + default: ~ diff --git a/tests/DependencyInjection/Fixtures/config/yml/well_known_schema_filter_default_tables.yml b/tests/DependencyInjection/Fixtures/config/yml/well_known_schema_filter_default_tables.yml new file mode 100644 index 0000000..d2ad3d8 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/well_known_schema_filter_default_tables.yml @@ -0,0 +1,24 @@ +doctrine_dbal: + default_connection: connection1 + connections: + connection1: + schema_filter: ~^(?!t_)~ + connection2: [] + connection3: [] + +services: + well_known_filter: + alias: 'doctrine.dbal.well_known_schema_asset_filter' + public: true + + symfony.cache: + class: 'Symfony\Component\Cache\Adapter\PdoAdapter' + + symfony.session: + class: 'Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler' + + symfony.lock: + class: 'Symfony\Component\Lock\Store\PdoStore' + + symfony.messenger: + class: 'Symfony\Component\Messenger\Transport\Doctrine\Connection' diff --git a/tests/DependencyInjection/Fixtures/config/yml/well_known_schema_filter_overridden_tables.yml b/tests/DependencyInjection/Fixtures/config/yml/well_known_schema_filter_overridden_tables.yml new file mode 100644 index 0000000..9c9f975 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/config/yml/well_known_schema_filter_overridden_tables.yml @@ -0,0 +1,37 @@ +doctrine_dbal: + default_connection: connection1 + connections: + connection1: + schema_filter: ~^(?!t_)~ + connection2: [] + connection3: [] + +services: + well_known_filter: + alias: 'doctrine.dbal.well_known_schema_asset_filter' + public: true + + symfony.cache: + class: 'Symfony\Component\Cache\Adapter\PdoAdapter' + arguments: + - ~ + - ~ + - ~ + - db_table: app_cache + + symfony.session: + class: 'Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler' + arguments: + - ~ + - db_table: app_session + + symfony.lock: + class: 'Symfony\Component\Lock\Store\PdoStore' + arguments: + - ~ + - db_table: app_locks + + symfony.messenger: + class: 'Symfony\Component\Messenger\Transport\Doctrine\Connection' + arguments: + - table_name: app_messages diff --git a/tests/DependencyInjection/XmlDoctrineExtensionTest.php b/tests/DependencyInjection/XmlDoctrineExtensionTest.php new file mode 100644 index 0000000..c8b10f2 --- /dev/null +++ b/tests/DependencyInjection/XmlDoctrineExtensionTest.php @@ -0,0 +1,16 @@ +load($file . '.xml'); + } +} diff --git a/tests/DependencyInjection/YamlDoctrineExtensionTest.php b/tests/DependencyInjection/YamlDoctrineExtensionTest.php new file mode 100644 index 0000000..0884f25 --- /dev/null +++ b/tests/DependencyInjection/YamlDoctrineExtensionTest.php @@ -0,0 +1,16 @@ +load($file . '.yml'); + } +} diff --git a/tests/Fixtures/TestType.php b/tests/Fixtures/TestType.php new file mode 100644 index 0000000..85d62d6 --- /dev/null +++ b/tests/Fixtures/TestType.php @@ -0,0 +1,19 @@ +logger = new DebugStack(); + $registry = $this->createMock(ConnectionRegistry::class); + $this->collector = new DoctrineDBALDataCollector($registry, []); + $this->collector->addLogger('foo', $this->logger); + + $twigLoaderFilesystem = new FilesystemLoader(__DIR__ . '/../src/Resources/views/Collector'); + $twigLoaderFilesystem->addPath(__DIR__ . '/../vendor/symfony/web-profiler-bundle/Resources/views', 'WebProfiler'); + $this->twig = new Environment($twigLoaderFilesystem, ['debug' => true, 'strict_variables' => true]); + + $fragmentHandler = $this->createMock(FragmentHandler::class); + $fragmentHandler->method('render')->willReturn(''); + + $kernelRuntime = new HttpKernelRuntime($fragmentHandler); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturn(''); + + $this->twig->addExtension(new CodeExtension('', '', '')); + $this->twig->addExtension(new RoutingExtension($urlGenerator)); + $this->twig->addExtension(new HttpKernelExtension($fragmentHandler)); + $this->twig->addExtension(new WebProfilerExtension()); + $this->twig->addExtension(new DoctrineDBALExtension()); + + $loader = $this->createMock(RuntimeLoaderInterface::class); + $loader->method('load')->willReturn($kernelRuntime); + $this->twig->addRuntimeLoader($loader); + } + + public function testRender() + { + $this->logger->queries = [ + [ + 'sql' => 'SELECT * FROM foo WHERE bar IN (?, ?)', + 'params' => ['foo', 'bar'], + 'types' => null, + 'executionMS' => 1, + ], + ]; + + $this->collector->collect($request = new Request(['group' => '0']), $response = new Response()); + + $profile = new Profile('foo'); + + $output = $this->twig->render('panel.html.twig', [ + 'request' => $request, + 'token' => 'foo', + 'page' => 'foo', + 'profile' => $profile, + 'collector' => $this->collector, + 'queries' => $this->logger->queries, + ]); + + $output = str_replace(["\e[37m", "\e[0m", "\e[32;1m", "\e[34;1m"], '', $output); + $this->assertStringContainsString("SELECT * FROM foo WHERE bar IN ('foo', 'bar');", $output); + } +} diff --git a/tests/Psr11ConnectionRegistryTest.php b/tests/Psr11ConnectionRegistryTest.php new file mode 100644 index 0000000..2d8b039 --- /dev/null +++ b/tests/Psr11ConnectionRegistryTest.php @@ -0,0 +1,72 @@ + */ + private $connections; + + protected function setUp() : void + { + /** @var Connection&MockObject $fooConnection */ + $fooConnection = $this->createMock(Connection::class); + /** @var Connection&MockObject $barConnection */ + $barConnection = $this->createMock(Connection::class); + + $this->connections = [ + 'foo' => $fooConnection, + 'bar' => $barConnection, + ]; + + $container = new Container(); + $container->set('foo', $fooConnection); + $container->set('bar', $barConnection); + + $this->registry = new Psr11ConnectionRegistry($container, 'bar', array_keys($this->connections)); + } + + public function testGetDefaultConnection() : void + { + $this->assertSame($this->connections['bar'], $this->registry->getConnection()); + } + + public function testGetConnectionByName() : void + { + $this->assertSame($this->connections['foo'], $this->registry->getConnection('foo')); + $this->assertSame($this->connections['bar'], $this->registry->getConnection('bar')); + } + + public function testGetNotExistentConnection() : void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Connection with name "something" does not exist.'); + $this->registry->getConnection('something'); + } + + public function testGetDefaultConnectionName() : void + { + $this->assertSame('bar', $this->registry->getDefaultConnectionName()); + } + + public function getGetConnections() : void + { + $this->assertSame($this->connections, $this->registry->getConnections()); + } + + public function testGetConnectionNames() : void + { + $this->assertSame(array_keys($this->connections), $this->registry->getConnectionNames()); + } +} diff --git a/tests/Twig/DoctrineDBALExtensionTest.php b/tests/Twig/DoctrineDBALExtensionTest.php new file mode 100644 index 0000000..b6e9176 --- /dev/null +++ b/tests/Twig/DoctrineDBALExtensionTest.php @@ -0,0 +1,106 @@ +replaceQueryParameters($query, $parameters); + $this->assertEquals('a=1 OR (1)::string OR b=2', $result); + } + + public function testReplaceQueryParametersWithStartingIndexAtOne() + { + $extension = new DoctrineDBALExtension(); + $query = 'a=? OR b=?'; + $parameters = [ + 1 => 1, + 2 => 2, + ]; + + $result = $extension->replaceQueryParameters($query, $parameters); + $this->assertEquals('a=1 OR b=2', $result); + } + + public function testReplaceQueryParameters() + { + $extension = new DoctrineDBALExtension(); + $query = 'a=? OR b=?'; + $parameters = [ + 1, + 2, + ]; + + $result = $extension->replaceQueryParameters($query, $parameters); + $this->assertEquals('a=1 OR b=2', $result); + } + + public function testReplaceQueryParametersWithNamedIndex() + { + $extension = new DoctrineDBALExtension(); + $query = 'a=:a OR b=:b'; + $parameters = [ + 'a' => 1, + 'b' => 2, + ]; + + $result = $extension->replaceQueryParameters($query, $parameters); + $this->assertEquals('a=1 OR b=2', $result); + } + + public function testEscapeBinaryParameter() + { + $binaryString = pack('H*', '9d40b8c1417f42d099af4782ec4b20b6'); + $this->assertEquals('0x9D40B8C1417F42D099AF4782EC4B20B6', DoctrineDBALExtension::escapeFunction($binaryString)); + } + + public function testEscapeStringParameter() + { + $this->assertEquals("'test string'", DoctrineDBALExtension::escapeFunction('test string')); + } + + public function testEscapeArrayParameter() + { + $this->assertEquals("1, NULL, 'test', foo", DoctrineDBALExtension::escapeFunction([1, null, 'test', new DummyClass('foo')])); + } + + public function testEscapeObjectParameter() + { + $object = new DummyClass('bar'); + $this->assertEquals('bar', DoctrineDBALExtension::escapeFunction($object)); + } + + public function testEscapeNullParameter() + { + $this->assertEquals('NULL', DoctrineDBALExtension::escapeFunction(null)); + } + + public function testEscapeBooleanParameter() + { + $this->assertEquals('1', DoctrineDBALExtension::escapeFunction(true)); + } +} + +class DummyClass +{ + /** @var string */ + protected $str; + + public function __construct($str) + { + $this->str = $str; + } + + public function __toString() + { + return $this->str; + } +}