Subversion Repositories oidplus

Rev

Rev 1231 | Rev 1370 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

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