1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Persistence\Sql;
6:
7: use Atk4\Core\DiContainerTrait;
8: use Doctrine\DBAL\Configuration;
9: use Doctrine\DBAL\Connection as DbalConnection;
10: use Doctrine\DBAL\ConnectionException as DbalConnectionException;
11: use Doctrine\DBAL\Driver as DbalDriver;
12: use Doctrine\DBAL\Driver\Connection as DbalDriverConnection;
13: use Doctrine\DBAL\Driver\Middleware as DbalMiddleware;
14: use Doctrine\DBAL\DriverManager;
15: use Doctrine\DBAL\Platforms\AbstractPlatform;
16: use Doctrine\DBAL\Result as DbalResult;
17: use Doctrine\DBAL\Schema\AbstractSchemaManager;
18:
19: /**
20: * Class for establishing and maintaining connection with your database.
21: */
22: abstract class Connection
23: {
24: use DiContainerTrait;
25:
26: /** @var class-string<Expression> */
27: protected string $expressionClass;
28: /** @var class-string<Query> */
29: protected string $queryClass;
30:
31: /** @var DbalConnection */
32: private $_connection;
33:
34: /** @var array<string, class-string<self>> */
35: protected static $connectionClassRegistry = [
36: 'pdo_sqlite' => Sqlite\Connection::class,
37: 'pdo_mysql' => Mysql\Connection::class,
38: 'mysqli' => Mysql\Connection::class,
39: 'pdo_pgsql' => Postgresql\Connection::class,
40: 'pdo_sqlsrv' => Mssql\Connection::class,
41: 'pdo_oci' => Oracle\Connection::class,
42: 'oci8' => Oracle\Connection::class,
43: ];
44:
45: /**
46: * @param array<string, mixed> $defaults
47: */
48: protected function __construct(array $defaults = [])
49: {
50: $this->setDefaults($defaults);
51: }
52:
53: public function __destruct()
54: {
55: // needed for DBAL connection to be released immeditelly
56: if ($this->_connection !== null) {
57: $this->getConnection()->close();
58: }
59: }
60:
61: public function getConnection(): DbalConnection
62: {
63: return $this->_connection;
64: }
65:
66: /**
67: * Normalize DSN connection string or DBAL connection params described in:
68: * https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html .
69: *
70: * Returns normalized DSN as array ['driver', 'host', 'user', 'password', 'dbname', 'charset', ...].
71: *
72: * @param array<string, string>|string $dsn
73: * @param string $user Optional username, this takes precedence over dsn string
74: * @param string $password Optional password, this takes precedence over dsn string
75: *
76: * @return array<string, string>
77: */
78: public static function normalizeDsn($dsn, $user = null, $password = null)
79: {
80: // BC for 2.4 - 3.0 accepted DSN input
81: if (is_string($dsn)) {
82: $dsn = ['dsn' => $dsn];
83: }
84: if (isset($dsn['dsn'])) {
85: if (str_contains($dsn['dsn'], '://')) {
86: /** @var array<string, string> https://github.com/phpstan/phpstan/issues/8638 */
87: $parsed = array_filter(parse_url($dsn['dsn'])); // @phpstan-ignore-line
88: $dsn['dsn'] = str_replace('-', '_', $parsed['scheme']) . ':';
89: unset($parsed['scheme']);
90: foreach ($parsed as $k => $v) {
91: if ($k === 'pass') {
92: unset($parsed[$k]);
93: $k = 'password';
94: } elseif ($k === 'path') {
95: unset($parsed[$k]);
96: $k = 'dbname';
97: $v = preg_replace('~^/~', '', $v);
98: }
99: $parsed[$k] = $k . '=' . $v;
100: }
101: $dsn['dsn'] .= implode(';', $parsed);
102: }
103:
104: $parts = explode(':', $dsn['dsn'], 2);
105: $dsn = ['driver' => strtolower($parts[0])];
106: if ($dsn['driver'] === 'sqlite') {
107: if (trim($parts[1], ':') === 'memory') {
108: $dsn['memory'] = true;
109: } else {
110: $dsn['path'] = trim($parts[1], ':');
111: }
112: } else {
113: foreach (explode(';', $parts[1] ?? '') as $part) {
114: [$k, $v] = str_contains($part, '=') ? explode('=', $part, 2) : [$part, null];
115: if ($k === 'query' || str_contains($part, '[')) {
116: parse_str($k === 'query' ? $v : $part, $partRes);
117: foreach ($partRes as $pK => $pV) {
118: $dsn[$pK] = $pV;
119: }
120: } else {
121: $dsn[$k] = $v;
122: }
123: }
124: if (isset($dsn['host']) && str_contains($dsn['host'], ':')) {
125: [$dsn['host'], $port] = explode(':', $dsn['host'], 2);
126: $dsn['port'] = $port;
127: }
128: }
129: }
130:
131: if ($user !== null) {
132: $dsn['user'] = $user;
133: }
134:
135: if ($password !== null) {
136: $dsn['password'] = $password;
137: }
138:
139: // BC for 2.4 - 3.1 accepted schema/driver names
140: $dsn['driver'] = [
141: 'sqlite' => 'pdo_sqlite',
142: 'mysql' => 'mysqli',
143: 'pgsql' => 'pdo_pgsql',
144: 'sqlsrv' => 'pdo_sqlsrv',
145: 'oci' => 'oci8',
146: ][$dsn['driver']] ?? $dsn['driver'];
147:
148: return $dsn;
149: }
150:
151: /**
152: * Adds connection class to the registry for resolving in Connection::resolve method.
153: *
154: * Can be used as:
155: * Connection::registerConnection(MySQL\Connection::class, 'pdo_mysql')
156: *
157: * @param class-string<self> $connectionClass
158: */
159: public static function registerConnectionClass(string $connectionClass, string $driverName): void
160: {
161: self::$connectionClassRegistry[$driverName] = $connectionClass;
162: }
163:
164: /**
165: * Resolves the connection class to use based on driver type.
166: *
167: * @return class-string<self>
168: */
169: public static function resolveConnectionClass(string $driverName): string
170: {
171: if (!isset(self::$connectionClassRegistry[$driverName])) {
172: throw (new Exception('Driver schema is not registered'))
173: ->addMoreInfo('driver_schema', $driverName);
174: }
175:
176: return self::$connectionClassRegistry[$driverName];
177: }
178:
179: /**
180: * Connect to database and return connection instance.
181: *
182: * @param string|array<string, string>|DbalConnection|DbalDriverConnection $dsn
183: * @param string|null $user
184: * @param string|null $password
185: * @param array<string, mixed> $defaults
186: */
187: public static function connect($dsn, $user = null, $password = null, $defaults = []): self
188: {
189: if ($dsn instanceof DbalConnection) {
190: $driverName = self::getDriverNameFromDbalDriverConnection($dsn->getWrappedConnection()); // @phpstan-ignore-line https://github.com/doctrine/dbal/issues/5199
191: $connectionClass = self::resolveConnectionClass($driverName);
192: $dbalConnection = $dsn;
193: } elseif ($dsn instanceof DbalDriverConnection) {
194: $driverName = self::getDriverNameFromDbalDriverConnection($dsn);
195: $connectionClass = self::resolveConnectionClass($driverName);
196: $dbalConnection = $connectionClass::connectFromDbalDriverConnection($dsn);
197: } else {
198: $dsn = static::normalizeDsn($dsn, $user, $password);
199: $connectionClass = self::resolveConnectionClass($dsn['driver']);
200: $dbalDriverConnection = $connectionClass::connectFromDsn($dsn);
201: $dbalConnection = $connectionClass::connectFromDbalDriverConnection($dbalDriverConnection);
202: }
203:
204: $dbalConnection->setNestTransactionsWithSavepoints(true); // remove once DBAL 3.x support is dropped
205:
206: $connection = new $connectionClass($defaults);
207: $connection->_connection = $dbalConnection;
208:
209: return $connection;
210: }
211:
212: /**
213: * @return 'pdo_sqlite'|'pdo_mysql'|'pdo_pgsql'|'pdo_sqlsrv'|'pdo_oci'|'mysqli'|'oci8'
214: */
215: private static function getDriverNameFromDbalDriverConnection(DbalDriverConnection $connection): string
216: {
217: $driver = $connection->getNativeConnection();
218:
219: if ($driver instanceof \PDO) {
220: return 'pdo_' . $driver->getAttribute(\PDO::ATTR_DRIVER_NAME);
221: } elseif ($driver instanceof \mysqli) {
222: return 'mysqli';
223: } elseif (is_resource($driver) && get_resource_type($driver) === 'oci8 connection') {
224: return 'oci8';
225: }
226:
227: return null; // @phpstan-ignore-line
228: }
229:
230: protected static function createDbalConfiguration(): Configuration
231: {
232: $configuration = new Configuration();
233: $configuration->setMiddlewares([
234: new class() implements DbalMiddleware {
235: #[\Override]
236: public function wrap(DbalDriver $driver): DbalDriver
237: {
238: return new DbalDriverMiddleware($driver);
239: }
240: },
241: ]);
242:
243: return $configuration;
244: }
245:
246: /**
247: * @param array<string, string> $dsn
248: */
249: protected static function connectFromDsn(array $dsn): DbalDriverConnection
250: {
251: $dsn = static::normalizeDsn($dsn);
252: if ($dsn['driver'] === 'pdo_mysql' || $dsn['driver'] === 'mysqli') {
253: $dsn['charset'] = 'utf8mb4';
254: } elseif ($dsn['driver'] === 'pdo_oci' || $dsn['driver'] === 'oci8') {
255: $dsn['charset'] = 'AL32UTF8';
256: }
257:
258: $dbalConnection = DriverManager::getConnection(
259: $dsn, // @phpstan-ignore-line
260: (static::class)::createDbalConfiguration()
261: );
262:
263: return $dbalConnection->getWrappedConnection(); // @phpstan-ignore-line https://github.com/doctrine/dbal/issues/5199
264: }
265:
266: protected static function connectFromDbalDriverConnection(DbalDriverConnection $dbalDriverConnection): DbalConnection
267: {
268: $dbalConnection = DriverManager::getConnection(
269: ['driver' => self::getDriverNameFromDbalDriverConnection($dbalDriverConnection)],
270: (static::class)::createDbalConfiguration()
271: );
272: \Closure::bind(static function () use ($dbalConnection, $dbalDriverConnection): void {
273: $dbalConnection->_conn = $dbalDriverConnection;
274: }, null, \Doctrine\DBAL\Connection::class)();
275:
276: return $dbalConnection;
277: }
278:
279: /**
280: * Create new Expression with connection already set.
281: *
282: * @param string|array<string, mixed> $template
283: * @param array<mixed> $arguments
284: */
285: public function expr($template = [], array $arguments = []): Expression
286: {
287: $class = $this->expressionClass;
288: $e = new $class($template, $arguments);
289: $e->connection = $this;
290:
291: return $e;
292: }
293:
294: /**
295: * Create new Query with connection already set.
296: *
297: * @param string|array<string, mixed> $defaults
298: */
299: public function dsql($defaults = []): Query
300: {
301: $class = $this->queryClass;
302: $q = new $class($defaults);
303: $q->connection = $this;
304:
305: return $q;
306: }
307:
308: /**
309: * Execute Expression by using this connection and return result.
310: */
311: public function executeQuery(Expression $expr): DbalResult
312: {
313: if ($this->_connection === null) {
314: throw new Exception('DBAL connection is not set');
315: }
316:
317: return $expr->executeQuery($this->getConnection());
318: }
319:
320: /**
321: * Execute Expression by using this connection and return affected rows.
322: *
323: * @return int<0, max>
324: */
325: public function executeStatement(Expression $expr): int
326: {
327: if ($this->_connection === null) {
328: throw new Exception('DBAL connection is not set');
329: }
330:
331: return $expr->executeStatement($this->getConnection());
332: }
333:
334: /**
335: * Atomic executes operations within one begin/end transaction, so if
336: * the code inside callback will fail, then all of the transaction
337: * will be also rolled back.
338: *
339: * @template T
340: *
341: * @param \Closure(): T $fx
342: *
343: * @return T
344: */
345: public function atomic(\Closure $fx)
346: {
347: $this->beginTransaction();
348: try {
349: $res = $fx();
350: $this->commit();
351:
352: return $res;
353: } catch (\Throwable $e) {
354: $this->rollBack();
355:
356: throw $e;
357: }
358: }
359:
360: /**
361: * Starts new transaction.
362: *
363: * Database driver supports statements for starting and committing
364: * transactions. Unfortunately most of them don't allow to nest
365: * transactions and commit gradually.
366: * With this method you have some implementation of nested transactions.
367: *
368: * When you call it for the first time it will begin transaction. If you
369: * call it more times, it will do nothing but will increase depth counter.
370: * You will need to call commit() for each execution of beginTransactions()
371: * and only the last commit will perform actual commit in database.
372: *
373: * So, if you have been working with the database and got un-handled
374: * exception in the middle of your code, everything will be rolled back.
375: */
376: public function beginTransaction(): void
377: {
378: try {
379: $this->getConnection()->beginTransaction();
380: } catch (DbalConnectionException $e) {
381: throw new Exception('Begin transaction failed', 0, $e);
382: }
383: }
384:
385: /**
386: * Will return true if currently running inside a transaction.
387: * This is useful if you are logging anything into a database. If you are
388: * inside a transaction, don't log or it may be rolled back.
389: * Perhaps use a hook for this?
390: */
391: public function inTransaction(): bool
392: {
393: return $this->getConnection()->isTransactionActive();
394: }
395:
396: /**
397: * Commits transaction.
398: *
399: * Each occurrence of beginTransaction() must be matched with commit().
400: * Only when same amount of commits are executed, the actual commit will be
401: * issued to the database.
402: */
403: public function commit(): void
404: {
405: try {
406: $this->getConnection()->commit();
407: } catch (DbalConnectionException $e) {
408: throw new Exception('Commit failed', 0, $e);
409: }
410: }
411:
412: /**
413: * Rollbacks queries since beginTransaction and resets transaction depth.
414: */
415: public function rollBack(): void
416: {
417: try {
418: $this->getConnection()->rollBack();
419: } catch (DbalConnectionException $e) {
420: throw new Exception('Rollback failed', 0, $e);
421: }
422: }
423:
424: /**
425: * Return last inserted ID value.
426: *
427: * Drivers like PostgreSQL need to receive sequence name to get ID because PDO doesn't support this method.
428: */
429: public function lastInsertId(string $sequence = null): string
430: {
431: $res = $this->getConnection()->lastInsertId($sequence);
432:
433: return is_int($res) ? (string) $res : $res;
434: }
435:
436: public function getDatabasePlatform(): AbstractPlatform
437: {
438: return $this->getConnection()->getDatabasePlatform();
439: }
440:
441: /**
442: * @phpstan-return AbstractSchemaManager<AbstractPlatform>
443: */
444: public function createSchemaManager(): AbstractSchemaManager
445: {
446: return $this->getConnection()->createSchemaManager();
447: }
448: }
449: