Subversion Repositories oidplus

Rev

Rev 1453 | 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.          * @var array
  44.          */
  45.         private $prepare_cache = [];
  46.  
  47.         /**
  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 {
  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.                         }
  79.                         unset($value);
  80.  
  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.                         }
  93.                         if (!$ps) {
  94.                                 $this->last_error = $this->conn->errorInfo()[2];
  95.                                 if (!$this->last_error) $this->last_error = _L("Error")." ".$this->conn->errorInfo()[0]; // if no message is available, only show the error-code
  96.                                 throw new OIDplusSQLException($sql, _L('Cannot prepare statement').': '.$this->error());
  97.                         }
  98.  
  99.                         if (!@$ps->execute($prepared_args)) {
  100.                                 $this->last_error = $ps->errorInfo()[2];
  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.  
  122.                                 throw new OIDplusSQLException($sql, $this->error());
  123.                         }
  124.                         return new OIDplusQueryResultPDO($ps);
  125.                 }
  126.         }
  127.  
  128.         /**
  129.          * @return int
  130.          * @throws OIDplusException
  131.          */
  132.         public function doInsertId(): int {
  133.                 try {
  134.                         $out = @($this->conn->lastInsertId());
  135.                         if ($out === false) return parent::doInsertId(); // fallback method that uses the SQL slang
  136.                         return $out;
  137.                 } catch (\Exception $e) {
  138.                         return parent::doInsertId(); // fallback method that uses the SQL slang
  139.                 }
  140.         }
  141.  
  142.         /**
  143.          * @return string
  144.          */
  145.         public function error(): string {
  146.                 $err = $this->last_error;
  147.                 if ($err == null) $err = '';
  148.                 return vts_utf8_encode($err);
  149.         }
  150.  
  151.         /**
  152.          * @return void
  153.          * @throws OIDplusConfigInitializationException
  154.          * @throws OIDplusException
  155.          */
  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 = [
  161.                             \PDO::ATTR_ERRMODE            => \PDO::ERRMODE_SILENT,
  162.                             \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
  163.                             \PDO::ATTR_EMULATE_PREPARES   => true
  164.                         ];
  165.  
  166.                         // Try connecting to the database
  167.                         $dsn      = OIDplus::baseConfig()->getValue('PDO_DSN',      'mysql:host=localhost;dbname=oidplus;charset=utf8mb4');
  168.                         $username = OIDplus::baseConfig()->getValue('PDO_USERNAME', (str_starts_with($dsn,'odbc:')) ? '' : 'root');
  169.                         $password = OIDplus::baseConfig()->getValue('PDO_PASSWORD', '');
  170.  
  171.                         if (stripos($dsn,"charset=") === false) {
  172.                                 // Try to extend DSN with charset
  173.                                 // Note: For MySQL, must be utf8mb4 or utf8, and not UTF-8
  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.                                 }
  187.                         } else {
  188.                                 $this->conn = new \PDO($dsn, $username, $password, $options);
  189.                         }
  190.                 } catch (\PDOException $e) {
  191.                         $message = $e->getMessage();
  192.                         $message = vts_utf8_encode($message); // Make UTF-8 if it is NOT already UTF-8. Important for German Microsoft Access.
  193.                         throw new OIDplusConfigInitializationException(trim(_L('Connection to the database failed!').' '.$message));
  194.                 }
  195.  
  196.                 $this->last_error = null;
  197.  
  198.                 try {
  199.                         @$this->conn->exec("SET NAMES 'utf-8'");
  200.                 } catch (\Exception $e) {
  201.                 }
  202.  
  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.  
  213.                 $this->detectTransactionSupport();
  214.         }
  215.  
  216.         /**
  217.          * @return void
  218.          */
  219.         private function detectTransactionSupport() {
  220.                 try {
  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);
  227.                 } catch (\Exception $e) {
  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) {
  250.                         $this->transactions_supported = false;
  251.                 }
  252.         }
  253.  
  254.         /**
  255.          * @return void
  256.          */
  257.         protected function doDisconnect()/*: void*/ {
  258.                 /*
  259.                 if (!$this->conn->getAttribute(\PDO::ATTR_AUTOCOMMIT)) {
  260.                         try {
  261.                                 $this->conn->commit();
  262.                         } catch (\Exception $e) {
  263.                         }
  264.                 }
  265.                 */
  266.                 $this->conn = null; // the connection will be closed by removing the reference
  267.         }
  268.  
  269.         /**
  270.          * @var bool
  271.          */
  272.         private $intransaction = false;
  273.  
  274.         /**
  275.          * @return bool
  276.          */
  277.         public function transaction_supported(): bool {
  278.                 return $this->transactions_supported;
  279.         }
  280.  
  281.         /**
  282.          * @return int
  283.          */
  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.  
  292.         /**
  293.          * @return void
  294.          * @throws OIDplusException
  295.          */
  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.  
  306.         /**
  307.          * @return void
  308.          */
  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.  
  318.         /**
  319.          * @return void
  320.          */
  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.  
  330.         /**
  331.          * @return array
  332.          */
  333.         public function getExtendedInfo(): array {
  334.                 $dsn = OIDplus::baseConfig()->getValue('PDO_DSN', 'mysql:host=localhost;dbname=oidplus;charset=utf8mb4');
  335.                 $dsn = preg_replace('@(Password|PWD)=(.+);@ismU', '('._L('redacted').');', $dsn);
  336.                 return array(
  337.                         _L('DSN') => $dsn
  338.                 );
  339.         }
  340.  
  341. }
  342.