diff --git a/src/Configuration/Connections/MasterSlaveConnection.php b/src/Configuration/Connections/MasterSlaveConnection.php new file mode 100644 index 00000000..88aa823b --- /dev/null +++ b/src/Configuration/Connections/MasterSlaveConnection.php @@ -0,0 +1,115 @@ +resolvedBaseSettings = $resolvedBaseSettings; + } + + /** + * {@inheritdoc} + */ + public function resolve(array $settings = []) + { + $driver = $this->resolvedBaseSettings['driver']; + + return [ + 'wrapperClass' => MasterSlaveDoctrineWrapper::class, + 'driver' => $driver, + 'master' => $this->getConnectionData(isset($settings['write']) ? $settings['write'] : [], $driver), + 'slaves' => $this->getSlavesConfig($settings['read'], $driver), + ]; + } + + /** + * Returns config for slave connections. + * + * @param array $slaves + * @param string $driver + * + * @return array + */ + public function getSlavesConfig(array $slaves, $driver) + { + $handledSlaves = []; + foreach ($slaves as $slave) { + $handledSlaves[] = $this->getConnectionData($slave, $driver); + } + + return $handledSlaves; + } + + /** + * Returns single connection (slave or master) config. + * + * @param array $connection + * @param string $driver + * + * @return array + */ + private function getConnectionData(array $connection, $driver) + { + $connection = $this->replaceKeyIfExists($connection, 'database', $driver === 'pdo_sqlite' ? 'path' : 'dbname'); + $connection = $this->replaceKeyIfExists($connection, 'username', 'user'); + + return array_merge($this->getFilteredConfig(), $connection); + } + + /** + * Returns filtered configuration to use in slaves/masters. + * + * @return array + */ + private function getFilteredConfig() + { + return array_diff_key($this->resolvedBaseSettings, array_flip($this->masterSlaveConfigIgnored)); + } + + /** + * Replaces key in array if it exists. + * + * @param array $array + * @param string $oldKey + * @param string $newKey + * + * @return array + */ + private function replaceKeyIfExists(array $array, $oldKey, $newKey) + { + if (!isset($array[$oldKey])) { + return $array; + } + + $array[$newKey] = $array[$oldKey]; + unset($array[$oldKey]); + + return $array; + } +} diff --git a/src/EntityManagerFactory.php b/src/EntityManagerFactory.php index 1f069ce2..51b31d8d 100644 --- a/src/EntityManagerFactory.php +++ b/src/EntityManagerFactory.php @@ -14,6 +14,7 @@ use InvalidArgumentException; use LaravelDoctrine\ORM\Configuration\Cache\CacheManager; use LaravelDoctrine\ORM\Configuration\Connections\ConnectionManager; +use LaravelDoctrine\ORM\Configuration\Connections\MasterSlaveConnection; use LaravelDoctrine\ORM\Configuration\LaravelNamingStrategy; use LaravelDoctrine\ORM\Configuration\MetaData\MetaData; use LaravelDoctrine\ORM\Configuration\MetaData\MetaDataManager; @@ -109,6 +110,11 @@ public function create(array $settings = []) $driver ); + if ($this->isMasterSlaveConfigured($driver)) { + $this->hasValidMasterSlaveConfig($driver); + $connection = (new MasterSlaveConnection($this->config, $connection))->resolve($driver); + } + $this->setNamingStrategy($settings, $configuration); $this->setCustomFunctions($configuration); $this->setCacheSettings($configuration); @@ -440,4 +446,40 @@ protected function registerMappingTypes(array $settings = [], EntityManagerInter $manager->getConnection()->getDatabasePlatform()->registerDoctrineTypeMapping($dbType, $doctrineType); } } + + /** + * Check if master slave connection was being configured. + * + * @param array $driverConfig + * + * @return bool + */ + private function isMasterSlaveConfigured(array $driverConfig) + { + // Setting read is mandatory for master/slave configuration. Setting write is optional. + // But if write was set and read wasn't, it means configuration is incorrect and we must inform the user. + return isset($driverConfig['read']) || isset($driverConfig['write']); + } + + /** + * Check if slave configuration is valid. + * + * @param array $driverConfig + */ + private function hasValidMasterSlaveConfig(array $driverConfig) + { + if (!isset($driverConfig['read'])) { + throw new \InvalidArgumentException("Parameter 'read' must be set for read/write config."); + } + + $slaves = $driverConfig['read']; + + if (!is_array($slaves) || in_array(false, array_map('is_array', $slaves))) { + throw new \InvalidArgumentException("Parameter 'read' must be an array containing multiple arrays."); + } + + if (($key = array_search(0, array_map('count', $slaves))) !== false) { + throw new \InvalidArgumentException("Parameter 'read' config no. {$key} is empty."); + } + } } diff --git a/tests/Configuration/Connections/MasterSlaveConnectionTest.php b/tests/Configuration/Connections/MasterSlaveConnectionTest.php new file mode 100644 index 00000000..f4446af2 --- /dev/null +++ b/tests/Configuration/Connections/MasterSlaveConnectionTest.php @@ -0,0 +1,362 @@ +getResolvedMysqlConfig(), $this->getInputConfig(), $this->getExpectedConfig()]; + + // Case #1. Configuration is only set in the read/write nodes. + $out[] = [['driver' => 'pdo_mysql'], $this->getNodesInputConfig(), $this->getNodesExpectedConfig()]; + + // Case #2. Simple valid configuration with oracle base settings. + $out[] = [$this->getResolvedOracleConfig(), $this->getInputConfig(), $this->getOracleExpectedConfig()]; + + // Case #3. Simple valid configuration with pgqsql base settings. + $out[] = [$this->getResolvedPgqsqlConfig(), $this->getInputConfig(), $this->getPgsqlExpectedConfig()]; + + // Case #4. Simple valid configuration with sqlite base settings. + $out[] = [$this->getResolvedSqliteConfig(), $this->getSqliteInputConfig(), $this->getSqliteExpectedConfig()]; + + return $out; + } + + /** + * Check if master slave connection manages configuration well. + * + * @param array $resolvedBaseSettings + * @param array $settings + * @param $expectedOutput + * + * @dataProvider getMasterSlaveConnectionData + */ + public function testMasterSlaveConnection(array $resolvedBaseSettings, array $settings, array $expectedOutput) + { + $this->assertEquals( + $expectedOutput, + (new MasterSlaveConnection(m::mock(Repository::class), $resolvedBaseSettings))->resolve($settings) + ); + } + + /** + * Returns dummy input configuration for testing. + * + * @return array + */ + private function getInputConfig() + { + return [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => '3306', + 'database' => 'test', + 'username' => 'homestead', + 'password' => 'secret', + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + 'strict' => false, + 'engine' => null, + 'write' => [ + 'port' => 3307, + 'user' => 'homestead1', + 'password' => 'secret1', + ], + 'read' => [ + [ + 'port' => 3308, + 'database' => 'test2', + ], + [ + 'host' => 'localhost2', + 'port' => 3309 + ], + ], + ]; + } + + /** + * Returns dummy expected result configuration for testing. + * + * @return array + */ + private function getExpectedConfig() + { + return [ + 'wrapperClass' => MasterSlaveDoctrineWrapper::class, + 'driver' => 'pdo_mysql', + 'slaves' => [ + [ + 'host' => 'localhost', + 'user' => 'homestead', + 'password' => 'secret', + 'dbname' => 'test2', + 'port' => '3308', + 'charset' => 'charset', + 'unix_socket' => 'unix_socket', + 'prefix' => 'prefix' + ], + [ + 'host' => 'localhost2', + 'user' => 'homestead', + 'password' => 'secret', + 'dbname' => 'test', + 'port' => '3309', + 'charset' => 'charset', + 'unix_socket' => 'unix_socket', + 'prefix' => 'prefix' + ] + ], + 'master' => [ + 'host' => 'localhost', + 'user' => 'homestead1', + 'password' => 'secret1', + 'dbname' => 'test', + 'port' => '3307', + 'charset' => 'charset', + 'unix_socket' => 'unix_socket', + 'prefix' => 'prefix' + ], + ]; + } + + /** + * Returns dummy input configuration where configuration is only set in read and write nodes. + * + * @return array + */ + private function getNodesInputConfig() + { + return [ + 'write' => [ + 'port' => 3307, + 'password' => 'secret1', + 'host' => 'localhost', + 'database' => 'test', + 'username' => 'homestead' + ], + 'read' => [ + [ + 'port' => 3308, + 'database' => 'test2', + 'host' => 'localhost', + 'username' => 'homestead', + 'password' => 'secret' + ], + [ + 'host' => 'localhost2', + 'port' => 3309, + 'database' => 'test', + 'username' => 'homestead', + 'password' => 'secret' + ], + ], + ]; + } + + /** + * Returns dummy expected output configuration where configuration is only set in read and write nodes. + * + * @return array + */ + private function getNodesExpectedConfig() + { + return [ + 'wrapperClass' => MasterSlaveDoctrineWrapper::class, + 'driver' => 'pdo_mysql', + 'slaves' => [ + [ + 'host' => 'localhost', + 'user' => 'homestead', + 'password' => 'secret', + 'dbname' => 'test2', + 'port' => '3308', + ], + [ + 'host' => 'localhost2', + 'user' => 'homestead', + 'password' => 'secret', + 'dbname' => 'test', + 'port' => '3309', + ] + ], + 'master' => [ + 'host' => 'localhost', + 'user' => 'homestead', + 'password' => 'secret1', + 'dbname' => 'test', + 'port' => '3307', + ], + ]; + } + + /** + * Returns dummy expected result configuration for testing oracle connections. + * + * @return array + */ + private function getOracleExpectedConfig() + { + $expectedConfigOracle = $this->getNodesExpectedConfig(); + $expectedConfigOracle['driver'] = 'oci8'; + $expectedConfigOracle['master']['user'] = 'homestead1'; + + return $expectedConfigOracle; + } + + /** + * Returns dummy expected result configuration for testing pgsql connections. + * + * @return array + */ + private function getPgsqlExpectedConfig() + { + $expectedConfigPgsql = $this->getNodesExpectedConfig(); + $expectedConfigPgsql['driver'] = 'pgsql'; + $expectedConfigPgsql['master']['user'] = 'homestead1'; + $expectedConfigPgsql['master']['sslmode'] = 'sslmode'; + $expectedConfigPgsql['slaves'][0]['sslmode'] = 'sslmode'; + $expectedConfigPgsql['slaves'][1]['sslmode'] = 'sslmode'; + + return $expectedConfigPgsql; + } + + /** + * Returns dummy expected result configuration for testing Sqlite connections. + * + * @return array + */ + private function getSqliteExpectedConfig() + { + return [ + 'wrapperClass' => MasterSlaveDoctrineWrapper::class, + 'driver' => 'pdo_sqlite', + 'slaves' => [ + [ + 'user' => 'homestead', + 'password' => 'secret', + 'port' => 3308, + 'path' => ':memory', + 'memory' => true, + ], + [ + 'host' => 'localhost2', + 'user' => 'homestead', + 'password' => 'secret', + 'port' => 3309, + 'path' => ':memory', + 'memory' => true, + ] + ], + 'master' => [ + 'user' => 'homestead1', + 'password' => 'secret1', + 'port' => 3307, + 'memory' => true, + 'path' => ':memory', + ], + ]; + } + + /** + * Returns dummy input configuration for testing Sqlite connections. + * + * @return array + */ + private function getSqliteInputConfig() + { + $inputConfigSqlite = $this->getInputConfig(); + unset($inputConfigSqlite['read'][0]['database']); + unset($inputConfigSqlite['read'][1]['database']); + unset($inputConfigSqlite['write']['database']); + + return $inputConfigSqlite; + } + + /** + * Returns already resolved mysql configuration. + * + * @return array + */ + private function getResolvedMysqlConfig() + { + return [ + 'driver' => 'pdo_mysql', + 'host' => 'localhost', + 'dbname' => 'test', + 'user' => 'homestead', + 'password' => 'secret', + 'charset' => 'charset', + 'port' => 'port', + 'unix_socket' => 'unix_socket', + 'prefix' => 'prefix' + ]; + } + + /** + * Returns already resolved oci configuration. + * + * @return array + */ + private function getResolvedOracleConfig() + { + return [ + 'driver' => 'oci8', + 'host' => 'localhost', + 'dbname' => 'test', + 'user' => 'homestead', + 'password' => 'secret', + 'port' => 'port', + ]; + } + + /** + * Returns already resolved sqlite configuration. + * + * @return array + */ + private function getResolvedSqliteConfig() + { + return [ + 'driver' => 'pdo_sqlite', + 'path' => ':memory', + 'user' => 'homestead', + 'password' => 'secret', + 'memory' => true + ]; + } + + /** + * Returns already resolved pgsql configuration. + * + * @return array + */ + private function getResolvedPgqsqlConfig() + { + return [ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'dbname' => 'test', + 'user' => 'homestead', + 'password' => 'secret', + 'port' => 'port', + 'sslmode' => 'sslmode', + ]; + } +} diff --git a/tests/EntityManagerFactoryTest.php b/tests/EntityManagerFactoryTest.php index ce7920b3..d04a924c 100644 --- a/tests/EntityManagerFactoryTest.php +++ b/tests/EntityManagerFactoryTest.php @@ -484,8 +484,11 @@ public function test_can_set_repository_factory() /** * MOCKS + * + * @param array $driverConfig + * @param bool $strictCallCountChecking */ - protected function mockConfig() + protected function mockConfig($driverConfig = ['driver' => 'mysql'], $strictCallCountChecking = true) { $this->config = m::mock(Repository::class); @@ -495,10 +498,11 @@ protected function mockConfig() ->andReturn('array'); foreach ($this->caches as $cache) { - $this->config->shouldReceive('get') + $expectation = $this->config->shouldReceive('get') ->with('doctrine.cache.' . $cache . '.driver', 'array') - ->atLeast()->once() ->andReturn('array'); + + $strictCallCountChecking ? $expectation->once() : $expectation->never(); } $this->config->shouldReceive('has') @@ -509,21 +513,25 @@ protected function mockConfig() $this->config->shouldReceive('get') ->with('database.connections.mysql') ->once() - ->andReturn([ - 'driver' => 'mysql' - ]); + ->andReturn($driverConfig); - $this->config->shouldReceive('get') + $expectation = $this->config->shouldReceive('get') ->with('doctrine.custom_datetime_functions') - ->once()->andReturn(['datetime']); + ->andReturn(['datetime']); - $this->config->shouldReceive('get') + $strictCallCountChecking ? $expectation->once() : $expectation->never(); + + $expectation = $this->config->shouldReceive('get') ->with('doctrine.custom_numeric_functions') - ->once()->andReturn(['numeric']); + ->andReturn(['numeric']); - $this->config->shouldReceive('get') + $strictCallCountChecking ? $expectation->once() : $expectation->never(); + + $expectation = $this->config->shouldReceive('get') ->with('doctrine.custom_string_functions') - ->once()->andReturn(['string']); + ->andReturn(['string']); + + $strictCallCountChecking ? $expectation->once() : $expectation->never(); } protected function mockCache() @@ -693,10 +701,168 @@ protected function enableLaravelNamingStrategy() $this->configuration->shouldReceive('setNamingStrategy')->once()->with($strategy); } + /** + * Data provider for testMasterSlaveConnection. + * + * @return array + */ + public function getTestMasterSlaveConnectionData() + { + $out = []; + + // Case #0. Simple valid configuration, everything should go well. + $out[] = [$this->getDummyBaseInputConfig()]; + + //Case #1. No read DBs set. + $inputConfig = $this->getDummyBaseInputConfig(); + unset($inputConfig['read']); + + $out[] = [ + $inputConfig, + \InvalidArgumentException::class, + "Parameter 'read' must be set for read/write config." + ]; + + //Case #2. 'read' isn't an array + $inputConfig = $this->getDummyBaseInputConfig(); + $inputConfig['read'] = 'test'; + + $out[] = [ + $inputConfig, + \InvalidArgumentException::class, + "Parameter 'read' must be an array containing multiple arrays." + ]; + + //Case #3. 'read' has non array entries. + $inputConfig = $this->getDummyBaseInputConfig(); + $inputConfig['read'][] = 'test'; + + $out[] = [ + $inputConfig, + \InvalidArgumentException::class, + "Parameter 'read' must be an array containing multiple arrays." + ]; + + //Case #4. 'read' has empty entries. + $inputConfig = $this->getDummyBaseInputConfig(); + $inputConfig['read'][] = []; + + $out[] = [ + $inputConfig, + \InvalidArgumentException::class, + "Parameter 'read' config no. 2 is empty." + ]; + + //Case #5. 'read' has empty first entry. (reported by maxbrokman.) + $inputConfig = $this->getDummyBaseInputConfig(); + $inputConfig['read'][0] = []; + + $out[] = [ + $inputConfig, + \InvalidArgumentException::class, + "Parameter 'read' config no. 0 is empty." + ]; + + return $out; + } + + /** + * Check if config is handled correctly. + * + * @param array $inputConfig + * @param string $expectedException + * @param string $msg + * + * @dataProvider getTestMasterSlaveConnectionData + */ + public function testMasterSlaveConnection( + array $inputConfig, + $expectedException = '', + $msg = '' + ) { + m::resetContainer(); + + $this->mockApp(); + $this->mockResolver(); + $this->mockConfig($inputConfig, empty($expectedException)); + + $this->cache = m::mock(CacheManager::class); + $this->cache->shouldReceive('driver') + ->times(empty($expectedException) ? 4 : 1) + ->andReturn(new ArrayCache()); + + $this->setup = m::mock(Setup::class); + $this->setup->shouldReceive('createConfiguration')->once()->andReturn($this->configuration); + + $this->connection = m::mock(ConnectionManager::class); + $this->connection->shouldReceive('driver') + ->once() + ->with('mysql', $inputConfig) + ->andReturn(['driver' => 'pdo_mysql']); + + $factory = new EntityManagerFactory( + $this->container, + $this->setup, + $this->meta, + $this->connection, + $this->cache, + $this->config, + $this->listenerResolver + ); + + if (!empty($expectedException)) { + $this->setExpectedException($expectedException, $msg); + } else { + $this->disableDebugbar(); + $this->disableCustomCacheNamespace(); + $this->disableSecondLevelCaching(); + $this->disableCustomFunctions(); + $this->enableLaravelNamingStrategy(); + } + + $this->settings['connection'] = 'mysql'; + $factory->create($this->settings); + } + protected function tearDown() { m::close(); } + + /** + * Returns dummy base config for testing. + * + * @return array + */ + private function getDummyBaseInputConfig() + { + return [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => '3306', + 'database' => 'test', + 'username' => 'homestead', + 'password' => 'secret', + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + 'strict' => false, + 'engine' => null, + 'write' => [ + 'port' => 3307, + ], + 'read' => [ + [ + 'port' => 3308, + 'database' => 'test2', + ], + [ + 'host' => 'localhost2', + 'port' => 3309 + ], + ], + ]; + } } class FilterStub