Subversion Repositories oidplus

Rev

Rev 1453 | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
635 daniel-mar 1
<?php
2
 
3
/*
4
 * OIDplus 2.0
1086 daniel-mar 5
 * Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft
635 daniel-mar 6
 *
7
 * Licensed under the Apache License, Version 2.0 (the "License");
8
 * you may not use this file except in compliance with the License.
9
 * You may obtain a copy of the License at
10
 *
11
 *     http://www.apache.org/licenses/LICENSE-2.0
12
 *
13
 * Unless required by applicable law or agreed to in writing, software
14
 * distributed under the License is distributed on an "AS IS" BASIS,
15
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
 * See the License for the specific language governing permissions and
17
 * limitations under the License.
18
 */
19
 
1050 daniel-mar 20
namespace ViaThinkSoft\OIDplus;
635 daniel-mar 21
 
1086 daniel-mar 22
// phpcs:disable PSR1.Files.SideEffects
23
\defined('INSIDE_OIDPLUS') or die;
24
// phpcs:enable PSR1.Files.SideEffects
25
 
635 daniel-mar 26
class OIDplusDatabaseConnectionPDO extends OIDplusDatabaseConnection {
1130 daniel-mar 27
        /**
28
         * @var mixed|null
29
         */
635 daniel-mar 30
        private $conn = null;
1130 daniel-mar 31
 
32
        /**
33
         * @var string|null
34
         */
635 daniel-mar 35
        private $last_error = null; // we need that because PDO divides prepared statement errors and normal query errors, but we have only one "error()" method
1130 daniel-mar 36
 
37
        /**
38
         * @var bool
39
         */
635 daniel-mar 40
        private $transactions_supported = false;
41
 
1116 daniel-mar 42
        /**
1455 daniel-mar 43
         * @var array
1453 daniel-mar 44
         */
45
        private $prepare_cache = [];
46
 
47
        /**
1116 daniel-mar 48
         * @param string $sql
49
         * @param array|null $prepared_args
50
         * @return OIDplusQueryResultPDO
51
         * @throws OIDplusException
52
         */
53
        public function doQuery(string $sql, array $prepared_args=null): OIDplusQueryResult {
635 daniel-mar 54
                $this->last_error = null;
55
                if (is_null($prepared_args)) {
56
                        $res = $this->conn->query($sql);
57
 
58
                        if ($res === false) {
59
                                $this->last_error = $this->conn->errorInfo()[2];
60
                                throw new OIDplusSQLException($sql, $this->error());
61
                        } else {
62
                                return new OIDplusQueryResultPDO($res);
63
                        }
64
                } else {
65
                        foreach ($prepared_args as &$value) {
66
                                // We need to manually convert booleans into strings, because there is a
67
                                // 14 year old bug that hasn't been adressed by the PDO developers:
68
                                // https://bugs.php.net/bug.php?id=57157
69
                                if (is_bool($value)) {
70
                                        if ($this->slangDetectionDone) {
71
                                                $value = $this->getSlang()->getSQLBool($value);
72
                                        } else {
73
                                                // This works for everything except Microsoft Access (which needs -1 and 0)
74
                                                // Note: We are using '1' and '0' instead of 'true' and 'false' because MySQL converts boolean to tinyint(1)
75
                                                $value = $value ? '1' : '0';
76
                                        }
77
                                }
78
                        }
1316 daniel-mar 79
                        unset($value);
635 daniel-mar 80
 
1453 daniel-mar 81
                        if (isset($this->prepare_cache[$sql])) {
82
                                // Attention: Caching prepared statements in PDO and ODBC is risky,
83
                                // because it seems that existing pointers are destroyed
84
                                // when execeute() is called.
85
                                // However, since we always fetch all data (to allow MARS),
86
                                // the testcase "Simultanous prepared statements" works, so we should be fine...?
87
                                $ps = $this->prepare_cache[$sql];
88
                        } else {
89
                                $ps = $this->conn->prepare($sql);
90
                                if (!$ps) $ps = false; // because null will result in isset()=false
91
                                $this->prepare_cache[$sql] = $ps;
92
                        }
635 daniel-mar 93
                        if (!$ps) {
94
                                $this->last_error = $this->conn->errorInfo()[2];
1160 daniel-mar 95
                                if (!$this->last_error) $this->last_error = _L("Error")." ".$this->conn->errorInfo()[0]; // if no message is available, only show the error-code
635 daniel-mar 96
                                throw new OIDplusSQLException($sql, _L('Cannot prepare statement').': '.$this->error());
97
                        }
98
 
1160 daniel-mar 99
                        if (!@$ps->execute($prepared_args)) {
635 daniel-mar 100
                                $this->last_error = $ps->errorInfo()[2];
1160 daniel-mar 101
                                if (!$this->last_error) $this->last_error = _L("Error")." ".$ps->errorInfo()[0]; // if no message is available, only show the error-code
102
                                // TODO:
103
                                // On my test machine with PDO + mysql on XAMPP with PHP 8.2.0, there are two problems with the following code:
104
                                //        $db->query("SELECT * from NONEXISTING", array(''));  // note that there is an additional argument, which is wrong!
105
                                //        $db->error()
106
                                // 1. $ps->errorInfo() is ['HY093', null, null].
107
                                //    The actual error message "Invalid parameter number: number of bound variables does not match number of tokens" is not shown via errorInfo()
108
                                //    => For now, as workaround, we just show the error message "HY093", if there is no driver specific error text available.
109
                                //       However, this means that the test-case will fail, because the table name cannot be found in the error message?!
110
                                // 2. The error "Invalid parameter number: number of bound variables does not match number of tokens" is SHOWN as PHP-warning
111
                                //    It seems like PDO::ERRMODE_SILENT is ignored?! The bug is 11 years old: https://bugs.php.net/bug.php?id=63812
112
                                //    => For now, as workaround, we added "@" in front of $ps->execute ...
113
                                //
114
                                // The following code works fine:
115
                                //        $db->query("SELECT * from NONEXISTING", array());  // note that there the number of arguments is now correct
116
                                //        $db->error()
117
                                // 1. $ps->errorInfo() is ['42S02', '1146', "Table 'oidplus.NONEXISTING' doesn't exist"].
118
                                //    => That's correct!
119
                                // 2. $ps->execute() does not show a warning (if "@" is removed)
120
                                //    => That's correct!
121
 
635 daniel-mar 122
                                throw new OIDplusSQLException($sql, $this->error());
123
                        }
124
                        return new OIDplusQueryResultPDO($ps);
125
                }
126
        }
127
 
1116 daniel-mar 128
        /**
129
         * @return int
130
         * @throws OIDplusException
131
         */
1160 daniel-mar 132
        public function doInsertId(): int {
635 daniel-mar 133
                try {
134
                        $out = @($this->conn->lastInsertId());
1167 daniel-mar 135
                        if ($out === false) return parent::doInsertId(); // fallback method that uses the SQL slang
635 daniel-mar 136
                        return $out;
1050 daniel-mar 137
                } catch (\Exception $e) {
1167 daniel-mar 138
                        return parent::doInsertId(); // fallback method that uses the SQL slang
635 daniel-mar 139
                }
140
        }
141
 
1116 daniel-mar 142
        /**
143
         * @return string
144
         */
635 daniel-mar 145
        public function error(): string {
146
                $err = $this->last_error;
147
                if ($err == null) $err = '';
1171 daniel-mar 148
                return vts_utf8_encode($err);
635 daniel-mar 149
        }
150
 
1116 daniel-mar 151
        /**
152
         * @return void
153
         * @throws OIDplusConfigInitializationException
154
         * @throws OIDplusException
155
         */
635 daniel-mar 156
        protected function doConnect()/*: void*/ {
157
                if (!class_exists('PDO')) throw new OIDplusConfigInitializationException(_L('PHP extension "%1" not installed','PDO'));
158
 
159
                try {
160
                        $options = [
1050 daniel-mar 161
                            \PDO::ATTR_ERRMODE            => \PDO::ERRMODE_SILENT,
162
                            \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
1241 daniel-mar 163
                            \PDO::ATTR_EMULATE_PREPARES   => true
635 daniel-mar 164
                        ];
165
 
166
                        // Try connecting to the database
1216 daniel-mar 167
                        $dsn      = OIDplus::baseConfig()->getValue('PDO_DSN',      'mysql:host=localhost;dbname=oidplus;charset=utf8mb4');
1231 daniel-mar 168
                        $username = OIDplus::baseConfig()->getValue('PDO_USERNAME', (str_starts_with($dsn,'odbc:')) ? '' : 'root');
635 daniel-mar 169
                        $password = OIDplus::baseConfig()->getValue('PDO_PASSWORD', '');
787 daniel-mar 170
 
1217 daniel-mar 171
                        if (stripos($dsn,"charset=") === false) {
1219 daniel-mar 172
                                // Try to extend DSN with charset
1370 daniel-mar 173
                                // Note: For MySQL, must be utf8mb4 or utf8, and not UTF-8
1217 daniel-mar 174
                                try {
175
                                        $this->conn = new \PDO("$dsn;charset=utf8mb4", $username, $password, $options);
176
                                } catch (\Exception $e1) {
177
                                        try {
178
                                                $this->conn = new \PDO("$dsn;charset=utf8", $username, $password, $options);
179
                                        } catch (\Exception $e2) {
180
                                                try {
181
                                                        $this->conn = new \PDO("$dsn;charset=UTF-8", $username, $password, $options);
182
                                                } catch (\Exception $e3) {
183
                                                        $this->conn = new \PDO($dsn, $username, $password, $options);
184
                                                }
185
                                        }
186
                                }
1218 daniel-mar 187
                        } else {
188
                                $this->conn = new \PDO($dsn, $username, $password, $options);
1217 daniel-mar 189
                        }
1050 daniel-mar 190
                } catch (\PDOException $e) {
635 daniel-mar 191
                        $message = $e->getMessage();
1171 daniel-mar 192
                        $message = vts_utf8_encode($message); // Make UTF-8 if it is NOT already UTF-8. Important for German Microsoft Access.
863 daniel-mar 193
                        throw new OIDplusConfigInitializationException(trim(_L('Connection to the database failed!').' '.$message));
635 daniel-mar 194
                }
195
 
196
                $this->last_error = null;
197
 
198
                try {
1216 daniel-mar 199
                        @$this->conn->exec("SET NAMES 'utf-8'");
1050 daniel-mar 200
                } catch (\Exception $e) {
635 daniel-mar 201
                }
202
 
1216 daniel-mar 203
                try {
204
                        @$this->conn->exec("SET CHARACTER SET 'utf-8'");
205
                } catch (\Exception $e) {
206
                }
207
 
208
                try {
209
                        @$this->conn->exec("SET NAMES 'utf8mb4'");
210
                } catch (\Exception $e) {
211
                }
212
 
1241 daniel-mar 213
                $this->detectTransactionSupport();
214
        }
215
 
216
        /**
217
         * @return void
218
         */
219
        private function detectTransactionSupport() {
635 daniel-mar 220
                try {
1241 daniel-mar 221
                        // Attention: Check it after you have already sent a query, because Microsoft Access doesn't seem to allow
222
                        // changing auto commit once a query was executed ("Attribute cannot be set now SQLState: S1011")
223
                        // Note: For some weird reason we *DO* need to redirect the output to "$dummy", otherwise it won't work!
224
                        $sql = "select name from ###config where 1=0";
225
                        $sql = str_replace('###', OIDplus::baseConfig()->getValue('TABLENAME_PREFIX', ''), $sql);
226
                        $dummy = $this->conn->query($sql);
1050 daniel-mar 227
                } catch (\Exception $e) {
1241 daniel-mar 228
                        // Microsoft Access might output that "xyz_config" is not found, if TABLENAME_PREFIX is wrong
229
                        // We didn't had the change to verify the existance of ###config using afterConnectMandatory() at this stage.
230
                        // This try-catch is usually not required because our error mode is set to silent.
231
                }
232
 
233
                // Note for Firebird: If Firebird uses auto-transactions via PDO, it doesn't allow an explicit transaction after a query has been
234
                // executed once in auto-commit mode. For some reason, the query was auto-committed, but after the auto-comit, a new transaction is
235
                // automatically opened, so new explicit transaction are denied with the error messag ethat a transaction is still open. A bug?!
236
                // If we explicit commit the implicitly opened transaction, we can use explicit transactions, but once
237
                // we want to run a normal query, Firebird denies it, saying that no transaction is open (because it asserts that an implicit
238
                // opened transaction is available).
239
                // The only solution would be to disable auto-commit and do everything ourselves, but this is a complex and risky task,
240
                // so we just let Firebird run in Transaction-Disabled-Mode.
241
 
242
                try {
243
                        if (!$this->conn->beginTransaction()) {
244
                                $this->transactions_supported = false;
245
                        } else {
246
                                $this->conn->rollBack();
247
                                $this->transactions_supported = true;
248
                        }
249
                } catch (\Exception $e) {
635 daniel-mar 250
                        $this->transactions_supported = false;
251
                }
252
        }
253
 
1116 daniel-mar 254
        /**
255
         * @return void
256
         */
635 daniel-mar 257
        protected function doDisconnect()/*: void*/ {
1241 daniel-mar 258
                /*
259
                if (!$this->conn->getAttribute(\PDO::ATTR_AUTOCOMMIT)) {
260
                        try {
261
                                $this->conn->commit();
262
                        } catch (\Exception $e) {
263
                        }
264
                }
265
                */
635 daniel-mar 266
                $this->conn = null; // the connection will be closed by removing the reference
267
        }
268
 
1116 daniel-mar 269
        /**
270
         * @var bool
271
         */
635 daniel-mar 272
        private $intransaction = false;
273
 
1116 daniel-mar 274
        /**
275
         * @return bool
276
         */
635 daniel-mar 277
        public function transaction_supported(): bool {
278
                return $this->transactions_supported;
279
        }
280
 
1116 daniel-mar 281
        /**
282
         * @return int
283
         */
635 daniel-mar 284
        public function transaction_level(): int {
285
                if (!$this->transaction_supported()) {
286
                        // TODO?
287
                        return 0;
288
                }
289
                return $this->intransaction ? 1 : 0;
290
        }
291
 
1116 daniel-mar 292
        /**
293
         * @return void
294
         * @throws OIDplusException
295
         */
635 daniel-mar 296
        public function transaction_begin()/*: void*/ {
297
                if (!$this->transaction_supported()) {
298
                        // TODO?
299
                        return;
300
                }
301
                if ($this->intransaction) throw new OIDplusException(_L('Nested transactions are not supported by this database plugin.'));
302
                $this->conn->beginTransaction();
303
                $this->intransaction = true;
304
        }
305
 
1116 daniel-mar 306
        /**
307
         * @return void
308
         */
635 daniel-mar 309
        public function transaction_commit()/*: void*/ {
310
                if (!$this->transaction_supported()) {
311
                        // TODO?
312
                        return;
313
                }
314
                $this->conn->commit();
315
                $this->intransaction = false;
316
        }
317
 
1116 daniel-mar 318
        /**
319
         * @return void
320
         */
635 daniel-mar 321
        public function transaction_rollback()/*: void*/ {
322
                if (!$this->transaction_supported()) {
323
                        // TODO?
324
                        return;
325
                }
326
                $this->conn->rollBack();
327
                $this->intransaction = false;
328
        }
329
 
1220 daniel-mar 330
        /**
331
         * @return array
332
         */
333
        public function getExtendedInfo(): array {
334
                $dsn = OIDplus::baseConfig()->getValue('PDO_DSN', 'mysql:host=localhost;dbname=oidplus;charset=utf8mb4');
1231 daniel-mar 335
                $dsn = preg_replace('@(Password|PWD)=(.+);@ismU', '('._L('redacted').');', $dsn);
1220 daniel-mar 336
                return array(
337
                        _L('DSN') => $dsn
338
                );
339
        }
340
 
635 daniel-mar 341
}