1<?php
2
3namespace Doctrine\DBAL\Connections;
4
5use Doctrine\Common\EventManager;
6use Doctrine\DBAL\Configuration;
7use Doctrine\DBAL\Connection;
8use Doctrine\DBAL\Driver;
9use Doctrine\DBAL\Driver\Connection as DriverConnection;
10use Doctrine\DBAL\Event\ConnectionEventArgs;
11use Doctrine\DBAL\Events;
12use InvalidArgumentException;
13use function array_rand;
14use function assert;
15use function count;
16use function func_get_args;
17
18/**
19 * Master-Slave Connection
20 *
21 * Connection can be used with master-slave setups.
22 *
23 * Important for the understanding of this connection should be how and when
24 * it picks the slave or master.
25 *
26 * 1. Slave if master was never picked before and ONLY if 'getWrappedConnection'
27 *    or 'executeQuery' is used.
28 * 2. Master picked when 'exec', 'executeUpdate', 'insert', 'delete', 'update', 'createSavepoint',
29 *    'releaseSavepoint', 'beginTransaction', 'rollback', 'commit', 'query' or
30 *    'prepare' is called.
31 * 3. If master was picked once during the lifetime of the connection it will always get picked afterwards.
32 * 4. One slave connection is randomly picked ONCE during a request.
33 *
34 * ATTENTION: You can write to the slave with this connection if you execute a write query without
35 * opening up a transaction. For example:
36 *
37 *      $conn = DriverManager::getConnection(...);
38 *      $conn->executeQuery("DELETE FROM table");
39 *
40 * Be aware that Connection#executeQuery is a method specifically for READ
41 * operations only.
42 *
43 * This connection is limited to slave operations using the
44 * Connection#executeQuery operation only, because it wouldn't be compatible
45 * with the ORM or SchemaManager code otherwise. Both use all the other
46 * operations in a context where writes could happen to a slave, which makes
47 * this restricted approach necessary.
48 *
49 * You can manually connect to the master at any time by calling:
50 *
51 *      $conn->connect('master');
52 *
53 * Instantiation through the DriverManager looks like:
54 *
55 * @example
56 *
57 * $conn = DriverManager::getConnection(array(
58 *    'wrapperClass' => 'Doctrine\DBAL\Connections\MasterSlaveConnection',
59 *    'driver' => 'pdo_mysql',
60 *    'master' => array('user' => '', 'password' => '', 'host' => '', 'dbname' => ''),
61 *    'slaves' => array(
62 *        array('user' => 'slave1', 'password', 'host' => '', 'dbname' => ''),
63 *        array('user' => 'slave2', 'password', 'host' => '', 'dbname' => ''),
64 *    )
65 * ));
66 *
67 * You can also pass 'driverOptions' and any other documented option to each of this drivers to pass additional information.
68 */
69class MasterSlaveConnection extends Connection
70{
71    /**
72     * Master and slave connection (one of the randomly picked slaves).
73     *
74     * @var DriverConnection[]|null[]
75     */
76    protected $connections = ['master' => null, 'slave' => null];
77
78    /**
79     * You can keep the slave connection and then switch back to it
80     * during the request if you know what you are doing.
81     *
82     * @var bool
83     */
84    protected $keepSlave = false;
85
86    /**
87     * Creates Master Slave Connection.
88     *
89     * @param mixed[] $params
90     *
91     * @throws InvalidArgumentException
92     */
93    public function __construct(array $params, Driver $driver, ?Configuration $config = null, ?EventManager $eventManager = null)
94    {
95        if (! isset($params['slaves'], $params['master'])) {
96            throw new InvalidArgumentException('master or slaves configuration missing');
97        }
98        if (count($params['slaves']) === 0) {
99            throw new InvalidArgumentException('You have to configure at least one slaves.');
100        }
101
102        $params['master']['driver'] = $params['driver'];
103        foreach ($params['slaves'] as $slaveKey => $slave) {
104            $params['slaves'][$slaveKey]['driver'] = $params['driver'];
105        }
106
107        $this->keepSlave = (bool) ($params['keepSlave'] ?? false);
108
109        parent::__construct($params, $driver, $config, $eventManager);
110    }
111
112    /**
113     * Checks if the connection is currently towards the master or not.
114     *
115     * @return bool
116     */
117    public function isConnectedToMaster()
118    {
119        return $this->_conn !== null && $this->_conn === $this->connections['master'];
120    }
121
122    /**
123     * {@inheritDoc}
124     */
125    public function connect($connectionName = null)
126    {
127        $requestedConnectionChange = ($connectionName !== null);
128        $connectionName            = $connectionName ?: 'slave';
129
130        if ($connectionName !== 'slave' && $connectionName !== 'master') {
131            throw new InvalidArgumentException('Invalid option to connect(), only master or slave allowed.');
132        }
133
134        // If we have a connection open, and this is not an explicit connection
135        // change request, then abort right here, because we are already done.
136        // This prevents writes to the slave in case of "keepSlave" option enabled.
137        if ($this->_conn !== null && ! $requestedConnectionChange) {
138            return false;
139        }
140
141        $forceMasterAsSlave = false;
142
143        if ($this->getTransactionNestingLevel() > 0) {
144            $connectionName     = 'master';
145            $forceMasterAsSlave = true;
146        }
147
148        if (isset($this->connections[$connectionName])) {
149            $this->_conn = $this->connections[$connectionName];
150
151            if ($forceMasterAsSlave && ! $this->keepSlave) {
152                $this->connections['slave'] = $this->_conn;
153            }
154
155            return false;
156        }
157
158        if ($connectionName === 'master') {
159            $this->connections['master'] = $this->_conn = $this->connectTo($connectionName);
160
161            // Set slave connection to master to avoid invalid reads
162            if (! $this->keepSlave) {
163                $this->connections['slave'] = $this->connections['master'];
164            }
165        } else {
166            $this->connections['slave'] = $this->_conn = $this->connectTo($connectionName);
167        }
168
169        if ($this->_eventManager->hasListeners(Events::postConnect)) {
170            $eventArgs = new ConnectionEventArgs($this);
171            $this->_eventManager->dispatchEvent(Events::postConnect, $eventArgs);
172        }
173
174        return true;
175    }
176
177    /**
178     * Connects to a specific connection.
179     *
180     * @param string $connectionName
181     *
182     * @return DriverConnection
183     */
184    protected function connectTo($connectionName)
185    {
186        $params = $this->getParams();
187
188        $driverOptions = $params['driverOptions'] ?? [];
189
190        $connectionParams = $this->chooseConnectionConfiguration($connectionName, $params);
191
192        $user     = $connectionParams['user'] ?? null;
193        $password = $connectionParams['password'] ?? null;
194
195        return $this->_driver->connect($connectionParams, $user, $password, $driverOptions);
196    }
197
198    /**
199     * @param string  $connectionName
200     * @param mixed[] $params
201     *
202     * @return mixed
203     */
204    protected function chooseConnectionConfiguration($connectionName, $params)
205    {
206        if ($connectionName === 'master') {
207            return $params['master'];
208        }
209
210        $config = $params['slaves'][array_rand($params['slaves'])];
211
212        if (! isset($config['charset']) && isset($params['master']['charset'])) {
213            $config['charset'] = $params['master']['charset'];
214        }
215
216        return $config;
217    }
218
219    /**
220     * {@inheritDoc}
221     */
222    public function executeUpdate($query, array $params = [], array $types = [])
223    {
224        $this->connect('master');
225
226        return parent::executeUpdate($query, $params, $types);
227    }
228
229    /**
230     * {@inheritDoc}
231     */
232    public function beginTransaction()
233    {
234        $this->connect('master');
235
236        return parent::beginTransaction();
237    }
238
239    /**
240     * {@inheritDoc}
241     */
242    public function commit()
243    {
244        $this->connect('master');
245
246        return parent::commit();
247    }
248
249    /**
250     * {@inheritDoc}
251     */
252    public function rollBack()
253    {
254        $this->connect('master');
255
256        return parent::rollBack();
257    }
258
259    /**
260     * {@inheritDoc}
261     */
262    public function delete($tableName, array $identifier, array $types = [])
263    {
264        $this->connect('master');
265
266        return parent::delete($tableName, $identifier, $types);
267    }
268
269    /**
270     * {@inheritDoc}
271     */
272    public function close()
273    {
274        unset($this->connections['master'], $this->connections['slave']);
275
276        parent::close();
277
278        $this->_conn       = null;
279        $this->connections = ['master' => null, 'slave' => null];
280    }
281
282    /**
283     * {@inheritDoc}
284     */
285    public function update($tableName, array $data, array $identifier, array $types = [])
286    {
287        $this->connect('master');
288
289        return parent::update($tableName, $data, $identifier, $types);
290    }
291
292    /**
293     * {@inheritDoc}
294     */
295    public function insert($tableName, array $data, array $types = [])
296    {
297        $this->connect('master');
298
299        return parent::insert($tableName, $data, $types);
300    }
301
302    /**
303     * {@inheritDoc}
304     */
305    public function exec($statement)
306    {
307        $this->connect('master');
308
309        return parent::exec($statement);
310    }
311
312    /**
313     * {@inheritDoc}
314     */
315    public function createSavepoint($savepoint)
316    {
317        $this->connect('master');
318
319        parent::createSavepoint($savepoint);
320    }
321
322    /**
323     * {@inheritDoc}
324     */
325    public function releaseSavepoint($savepoint)
326    {
327        $this->connect('master');
328
329        parent::releaseSavepoint($savepoint);
330    }
331
332    /**
333     * {@inheritDoc}
334     */
335    public function rollbackSavepoint($savepoint)
336    {
337        $this->connect('master');
338
339        parent::rollbackSavepoint($savepoint);
340    }
341
342    /**
343     * {@inheritDoc}
344     */
345    public function query()
346    {
347        $this->connect('master');
348        assert($this->_conn instanceof DriverConnection);
349
350        $args = func_get_args();
351
352        $logger = $this->getConfiguration()->getSQLLogger();
353        if ($logger) {
354            $logger->startQuery($args[0]);
355        }
356
357        $statement = $this->_conn->query(...$args);
358
359        $statement->setFetchMode($this->defaultFetchMode);
360
361        if ($logger) {
362            $logger->stopQuery();
363        }
364
365        return $statement;
366    }
367
368    /**
369     * {@inheritDoc}
370     */
371    public function prepare($statement)
372    {
373        $this->connect('master');
374
375        return parent::prepare($statement);
376    }
377}
378