Home » Php » php – Symfony2, Dynamic DB Connection/Early override of Doctrine Service

php – Symfony2, Dynamic DB Connection/Early override of Doctrine Service

Posted by: admin July 12, 2020 Leave a comment

Questions:

I have a Core config Database, each row is an ‘App’ with some basic config etc.
Once you have chosen your app, I want to connect to a database using a property of that row (ID), and the host may also change based on the row.

What I want is to register a service that sets up the Doctrine service using these details if you are in a place on the site that it’s required (which I know based on URI).

I am using the Entity manager, and various Doctrine Listeners/Event subs

I’ve played around with the ConnectionFactory, but this appears to cause problems with the subscribers.

What is the best way to hook something up that will transparently modify the Doctrine service, so that the controllers can act without any knowledge of which DB host and DB name they are connecting to?

Each DB of this type will have the same structure so all Entity mapping is correct.

I’m looking for a really clean implementation, hopefully using the Service Container to avoid any ‘hacks’.

Does anyone have any knowledge of doing this?

How to&Answers:

Combined, these two postings helped me solve my own very similar problem. Here is my solution, maybe it is useful for someone else:

<?php

namespace Calitarus\CollaborationBundle\EventListener;

use Symfony\Component\HttpFoundation\Request;
use Doctrine\DBAL\Connection;
use Exception;
use Monolog\Logger;



class DatabaseSwitcherEventListener {

    private $request;
    private $connection;
    private $logger;

    public function __construct(Request $request, Connection $connection, Logger $logger) {
        $this->request = $request;
        $this->connection = $connection;
        $this->logger = $logger;
    }


    public function onKernelRequest() {
        if ($this->request->attributes->has('_site')) {
            $site = $this->request->attributes->get('_site');

            $connection = $this->connection;
            $params     = $this->connection->getParams();

            $db_name = 'br_'.$this->request->attributes->get('_site');
            // TODO: validate that this site exists
            if ($db_name != $params['dbname']) {
                $this->logger->debug('switching connection from '.$params['dbname'].' to '.$db_name);
                $params['dbname'] = $db_name;
                if ($connection->isConnected()) {
                    $connection->close();
                }
                $connection->__construct(
                    $params, $connection->getDriver(), $connection->getConfiguration(),
                    $connection->getEventManager()
                );

                try {
                    $connection->connect();
                } catch (Exception $e) {
                    // log and handle exception
                }
            }
        }
    }
}

To get this to work, I set up services.yml as follows:

services:
    cc.database_switcher:
        class:      Calitarus\CollaborationBundle\EventListener\DatabaseSwitcherEventListener
        arguments:  [@request, @doctrine.dbal.default_connection, @logger]
        scope:      request
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

and I have this routing configuration to get the _site parameter, which in my case is part of the URL, but you can probably get it in other ways depending on your setup:

resource: "@CCollabBundle/Controller"
type:     annotation
prefix:   /{_site}
defaults:
 _site: default

Answer:

Here is the new and improved non-reflection version

#services.yml
acme_app.dynamic_connection:
    class: %acme.dynamic_doctrine_connection.class%
    calls:
        - [setDoctrineConnection, [@doctrine.dbal.default_connection]]


<?php

namespace Acme\Bundle\AppBundle;

use Doctrine\DBAL\Connection;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Exception;

class DynamicDoctrineConnection
{
    /**
     * @var Connection
     */
    private $connection;

    /**
     * Sets the DB Name prefix to use when selecting the database to connect to
     *
     * @param  Connection       $connection
     * @return SiteDbConnection $this
     */
    public function setDoctrineConnection(Connection $connection)
    {
        $this->connection = $connection;

        return $this;
    }

    public function setUpAppConnection()
    {
        if ($this->request->attributes->has('appId')) {
            $connection = $this->connection;
            $params     = $this->connection->getParams();

            // we also check if the current connection needs to be closed based on various things
            // have left that part in for information here
            // $appId changed from that in the connection?
            // if ($connection->isConnected()) {
            //     $connection->close();
            // }

            // Set default DB connection using appId
            //$params['host']   = $someHost;
            $params['dbname'] = 'Acme_App'.$this->request->attributes->get('appId');

            // Set up the parameters for the parent
            $connection->__construct(
                $params, $connection->getDriver(), $connection->getConfiguration(),
                $connection->getEventManager()
            );

            try {
                $connection->connect();
            } catch (Exception $e) {
                // log and handle exception
            }
        }

        return $this;
    }
}

Answer:

In symfony 4, you can pull it off with a wrapper class:

# doctrine.yaml
doctrine:
    dbal:
      connections:
        default:
          wrapper_class: App\Service\Database\DynamicConnection

The class simply extends the original Connection:

class DynamicConnection extends \Doctrine\DBAL\Connection
{

    public function changeDatabase(string $dbName)
    {
        $params = $this->getParams();

        if ($this->isConnected())
            $this->close();

        if (isset($params['url'])) {
            $params['url'] = preg_replace(
                sprintf("/(?<=\/)%s/", preg_quote($this->getDatabase())),
                $dbName,
                $params['url']
            );
        }

        if (isset($params['dbname']))
            $params['dbname'] = $dbName;

        parent::__construct(
            $params,
            $this->_driver,
            $this->_config,
            $this->_eventManager
        );

    }
}

Answer:

Symfony 4

The cleanest way by using a decorator pattern in the service definition:

First create a custom class like App\Factory\Authentication\DatabaseConnectionFactory
Then this class will instantiate with an instance of doctrine.dbal.connection_factory.

#services.xml
App\Factory\Authentication\DatabaseConnectionFactory:
        decorates: doctrine.dbal.connection_factory
        arguments:
            $wrappedConnectionFactory: '@App\Factory\Authentication\DatabaseConnectionFactory.inner'

In our custom connection factory class mock the createConnection() function and execute the original createConnection() logic by calling the function on the wrappedConnectionFactory (= doctrine.dbal.connection_factory).

/** App\Factory\Authentication\DatabaseConnectionFactory
 * @param array              $params
 * @param Configuration|null $config
 * @param EventManager|null  $eventManager
 * @param array              $mappingTypes
 *
 * @throws \DomainException
 *
 * @return mixed
 */
public function createConnection(array $params, Configuration $config = null, EventManager $eventManager = null, array $mappingTypes = [])
{
    $params['url'] = $this->databaseConnectionUrlService->getDatabaseConnectionUrlForApiUser($this->apiUser, $params['url'] );

    return $this->wrappedConnectionFactory->createConnection($params, $config, $eventManager, $mappingTypes);
}

Answer:

I took a look at your service and tried to implement it, but it looks like you were missing some arguments that needed passed into your constructor. Here is an updated version that should work:

#services.yml
parameters:
    acme_page.dynamic_doctrine_connection.class: Acme\Bundle\PageBundle\DynamicDoctrineConnection

services:
    acme_page.dynamic_doctrine_connection:
        class:      %acme_page.dynamic_doctrine_connection.class%
        arguments:  [@request, @doctrine.dbal.client_connection, @doctrine]
        scope:      request
        calls:
            - [setContainer, [@service_container]]
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

//DynamicDoctrineConnection.php
<?php

namespace Acme\Bundle\PageBundle;

use Symfony\Component\HttpFoundation\Request;
use Doctrine\DBAL\Connection;
use Doctrine\Bundle\DoctrineBundle\Registry;

/**
 * Creates a Doctrine connection from attributes in the Request
 */
class DynamicDoctrineConnection
{
    private $request;
    private $defaultConnection;
    private $doctrine;

    public function __construct(Request $request, Connection $defaultConnection, Registry $doctrine)
    {
        $this->request           = $request;
        $this->defaultConnection = $defaultConnection;
        $this->doctrine          = $doctrine;
    }

    public function onKernelRequest()
    {
        if ($this->request->attributes->has('appId')) {

            $dbName             = 'Acme_App_'.$this->request->attributes->get('appId');

            $this->defaultConnection->close();

            $reflectionConn     = new \ReflectionObject($this->defaultConnection);
            $reflectionParams   = $reflectionConn->getProperty('_params');
            $reflectionParams->setAccessible(true);

            $params             = $reflectionParams->getValue($this->defaultConnection);
            $params['dbname']   = $dbName;

            $reflectionParams->setValue($this->defaultConnection, $params);
            $reflectionParams->setAccessible(false);

            $this->doctrine->resetEntityManager('default');
    }
}