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