BioTorrents.de’s version of Gazelle
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

mysql.class.php 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. <?php
  2. #declare(strict_types = 1);
  3. //-----------------------------------------------------------------------------------
  4. /////////////////////////////////////////////////////////////////////////////////////
  5. /*//-- MySQL wrapper class ----------------------------------------------------------
  6. This class provides an interface to mysqli. You should always use this class instead
  7. of the mysql/mysqli functions, because this class provides debugging features and a
  8. bunch of other cool stuff.
  9. Everything returned by this class is automatically escaped for output. This can be
  10. turned off by setting $Escape to false in next_record or to_array.
  11. //--------- Basic usage -------------------------------------------------------------
  12. * Creating the object.
  13. require(SERVER_ROOT.'/classes/mysql.class.php');
  14. $DB = NEW DB_MYSQL;
  15. -----
  16. * Making a query
  17. $DB->query("
  18. SELECT *
  19. FROM table...");
  20. Is functionally equivalent to using mysqli_query("SELECT * FROM table...")
  21. Stores the result set in $this->QueryID
  22. Returns the result set, so you can save it for later (see set_query_id())
  23. -----
  24. * Getting data from a query
  25. $array = $DB->next_record();
  26. Is functionally equivalent to using mysqli_fetch_array($ResultSet)
  27. You do not need to specify a result set - it uses $this-QueryID
  28. -----
  29. * Escaping a string
  30. db_string($str);
  31. Is a wrapper for mysqli_real_escape_string().
  32. USE THIS FUNCTION EVERY TIME YOU QUERY USER-SUPPLIED INPUT!
  33. //--------- Advanced usage ---------------------------------------------------------
  34. * The conventional way of retrieving a row from a result set is as follows:
  35. list($All, $Columns, $That, $You, $Select) = $DB->next_record();
  36. -----
  37. * This is how you loop over the result set:
  38. while (list($All, $Columns, $That, $You, $Select) = $DB->next_record()) {
  39. echo "Do stuff with $All of the ".$Columns.$That.$You.$Select;
  40. }
  41. -----
  42. * There are also a couple more mysqli functions that have been wrapped. They are:
  43. record_count()
  44. Wrapper to mysqli_num_rows()
  45. affected_rows()
  46. Wrapper to mysqli_affected_rows()
  47. inserted_id()
  48. Wrapper to mysqli_insert_id()
  49. close
  50. Wrapper to mysqli_close()
  51. -----
  52. * And, of course, a few handy custom functions.
  53. to_array($Key = false)
  54. Transforms an entire result set into an array (useful in situations where you
  55. can't order the rows properly in the query).
  56. If $Key is set, the function uses $Key as the index (good for looking up a
  57. field). Otherwise, it uses an iterator.
  58. For an example of this function in action, check out forum.php.
  59. collect($Key)
  60. Loops over the result set, creating an array from one of the fields ($Key).
  61. For an example, see forum.php.
  62. set_query_id($ResultSet)
  63. This class can only hold one result set at a time. Using set_query_id allows
  64. you to set the result set that the class is using to the result set in
  65. $ResultSet. This result set should have been obtained earlier by using
  66. $DB->query().
  67. Example:
  68. $FoodRS = $DB->query("
  69. SELECT *
  70. FROM food");
  71. $DB->query("
  72. SELECT *
  73. FROM drink");
  74. $Drinks = $DB->next_record();
  75. $DB->set_query_id($FoodRS);
  76. $Food = $DB->next_record();
  77. Of course, this example is contrived, but you get the point.
  78. -------------------------------------------------------------------------------------
  79. *///---------------------------------------------------------------------------------
  80. if (!extension_loaded('mysqli')) {
  81. error('Mysqli Extension not loaded.');
  82. }
  83. /**
  84. * db_string
  85. * Handles escaping
  86. */
  87. function db_string($String, $DisableWildcards = false)
  88. {
  89. global $DB;
  90. $DB->connect(0);
  91. # Connect and mysqli_real_escape_string()
  92. # Previously called $DB->escape_str, now below
  93. # todo: Fix the bad escapes everywhere; see below
  94. #if (!is_string($String)) { # This is the correct way,
  95. if (is_array($String)) { # but this prevents errors
  96. error('Attempted to escape non-string.', $NoHTML = true);
  97. $String = '';
  98. } else {
  99. $String = mysqli_real_escape_string($DB->LinkID, $String);
  100. }
  101. // Remove user input wildcards
  102. if ($DisableWildcards) {
  103. $String = str_replace(array('%','_'), array('\%','\_'), $String);
  104. }
  105. return $String;
  106. }
  107. /**
  108. * db_array
  109. */
  110. function db_array($Array, $DontEscape = [], $Quote = false)
  111. {
  112. foreach ($Array as $Key => $Val) {
  113. if (!in_array($Key, $DontEscape)) {
  114. if ($Quote) {
  115. $Array[$Key] = '\''.db_string(trim($Val)).'\'';
  116. } else {
  117. $Array[$Key] = db_string(trim($Val));
  118. }
  119. }
  120. }
  121. return $Array;
  122. }
  123. // todo: Revisit access levels once Drone is replaced by ZeRobot
  124. class DB_MYSQL
  125. {
  126. public $LinkID = false;
  127. protected $QueryID = false;
  128. protected $StatementID = false;
  129. protected $PreparedQuery = false;
  130. protected $Record = [];
  131. protected $Row;
  132. protected $Errno = 0;
  133. protected $Error = '';
  134. public $Queries = [];
  135. public $Time = 0.0;
  136. protected $Database = '';
  137. protected $Server = '';
  138. protected $User = '';
  139. protected $Pass = '';
  140. protected $Port = 0;
  141. protected $Socket = '';
  142. /**
  143. * __construct
  144. */
  145. public function __construct($Database = null, $User = null, $Pass = null, $Server = null, $Port = null, $Socket = null)
  146. {
  147. $ENV = ENV::go();
  148. $this->Database = $ENV->getPriv('SQLDB');
  149. $this->User = $ENV->getPriv('SQLLOGIN');
  150. $this->Pass = $ENV->getPriv('SQLPASS');
  151. $this->Server = $ENV->getPriv('SQLHOST');
  152. $this->Port = $ENV->getPriv('SQLPORT');
  153. $this->Socket = $ENV->getPriv('SQLSOCK');
  154. }
  155. /**
  156. * halt
  157. */
  158. public function halt($Msg)
  159. {
  160. global $Debug, $argv;
  161. $DBError = 'MySQL: '.strval($Msg).' SQL error: '.strval($this->Errno).' ('.strval($this->Error).')';
  162. if ($this->Errno === 1194) {
  163. send_irc(ADMIN_CHAN, $this->Error);
  164. }
  165. $Debug->analysis('!dev DB Error', $DBError, 3600 * 24);
  166. if (DEBUG_MODE || check_perms('site_debug') || isset($argv[1])) {
  167. echo '<pre>'.display_str($DBError).'</pre>';
  168. if (DEBUG_MODE || check_perms('site_debug')) {
  169. print_r($this->Queries);
  170. }
  171. error(400, $NoHTML = true);
  172. } else {
  173. error(-1, $NoHTML = true);
  174. }
  175. }
  176. /**
  177. * connect
  178. */
  179. public function connect()
  180. {
  181. if (!$this->LinkID) {
  182. $this->LinkID = mysqli_connect($this->Server, $this->User, $this->Pass, $this->Database, $this->Port, $this->Socket); // defined in config.php
  183. if (!$this->LinkID) {
  184. $this->Errno = mysqli_connect_errno();
  185. $this->Error = mysqli_connect_error();
  186. $this->halt('Connection failed (host:'.$this->Server.':'.$this->Port.')');
  187. }
  188. }
  189. mysqli_set_charset($this->LinkID, "utf8mb4");
  190. }
  191. /**
  192. * prepare_query
  193. */
  194. public function prepare_query($Query, &...$BindVars)
  195. {
  196. $this->connect();
  197. $this->StatementID = mysqli_prepare($this->LinkID, $Query);
  198. if (!empty($BindVars)) {
  199. $Types = '';
  200. $TypeMap = ['string'=>'s', 'double'=>'d', 'integer'=>'i', 'boolean'=>'i'];
  201. foreach ($BindVars as $BindVar) {
  202. $Types .= $TypeMap[gettype($BindVar)] ?? 'b';
  203. }
  204. mysqli_stmt_bind_param($this->StatementID, $Types, ...$BindVars);
  205. }
  206. $this->PreparedQuery = $Query;
  207. return $this->StatementID;
  208. }
  209. /**
  210. * exec_prepared_query
  211. */
  212. public function exec_prepared_query()
  213. {
  214. $QueryStartTime = microtime(true);
  215. mysqli_stmt_execute($this->StatementID);
  216. $this->QueryID = mysqli_stmt_get_result($this->StatementID);
  217. $QueryRunTime = (microtime(true) - $QueryStartTime) * 1000;
  218. $this->Queries[] = [$this->PreppedQuery, $QueryRunTime, null];
  219. $this->Time += $QueryRunTime;
  220. }
  221. /**
  222. * Runs a raw query assuming pre-sanitized input. However, attempting to self sanitize (such
  223. * as via db_string) is still not as safe for using prepared statements so for queries
  224. * involving user input, you really should not use this function (instead opting for
  225. * prepared_query) {@See DB_MYSQL::prepared_query}
  226. *
  227. * When running a batch of queries using the same statement
  228. * with a variety of inputs, it's more performant to reuse the statement
  229. * with {@see DB_MYSQL::prepare} and {@see DB_MYSQL::execute}
  230. *
  231. * @return mysqli_result|bool Returns a mysqli_result object
  232. * for successful SELECT queries,
  233. * or TRUE for other successful DML queries
  234. * or FALSE on failure.
  235. *
  236. * @param $Query
  237. * @param int $AutoHandle
  238. * @return mysqli_result|bool
  239. */
  240. public function query($Query, &...$BindVars)
  241. {
  242. /**
  243. * If there was a previous query, we store the warnings. We cannot do
  244. * this immediately after mysqli_query because mysqli_insert_id will
  245. * break otherwise due to mysqli_get_warnings sending a SHOW WARNINGS;
  246. * query. When sending a query, however, we're sure that we won't call
  247. * mysqli_insert_id (or any similar function, for that matter) later on,
  248. * so we can safely get the warnings without breaking things.
  249. * Note that this means that we have to call $this->warnings manually
  250. * for the last query!
  251. */
  252. global $Debug;
  253. if ($this->QueryID) {
  254. $this->warnings();
  255. }
  256. $QueryStartTime = microtime(true);
  257. $this->connect();
  258. // In the event of a MySQL deadlock, we sleep allowing MySQL time to unlock, then attempt again for a maximum of 5 tries
  259. for ($i = 1; $i < 6; $i++) {
  260. $this->StatementID = mysqli_prepare($this->LinkID, $Query);
  261. if (!empty($BindVars)) {
  262. $Types = '';
  263. $TypeMap = ['string'=>'s', 'double'=>'d', 'integer'=>'i', 'boolean'=>'i'];
  264. foreach ($BindVars as $BindVar) {
  265. $Types .= $TypeMap[gettype($BindVar)] ?? 'b';
  266. }
  267. mysqli_stmt_bind_param($this->StatementID, $Types, ...$BindVars);
  268. }
  269. mysqli_stmt_execute($this->StatementID);
  270. $this->QueryID = mysqli_stmt_get_result($this->StatementID);
  271. if (DEBUG_MODE) {
  272. // In DEBUG_MODE, return the full trace on a SQL error (super useful
  273. // For debugging). do not attempt to retry to query
  274. if (!$this->QueryID) {
  275. echo '<pre>' . mysqli_error($this->LinkID) . '<br><br>';
  276. debug_print_backtrace();
  277. echo '</pre>';
  278. error();
  279. }
  280. }
  281. if (!in_array(mysqli_errno($this->LinkID), array(1213, 1205))) {
  282. break;
  283. }
  284. $Debug->analysis('Non-Fatal Deadlock:', $Query, 3600 * 24);
  285. trigger_error("Database deadlock, attempt $i");
  286. sleep($i * rand(2, 5)); // Wait longer as attempts increase
  287. }
  288. $QueryEndTime = microtime(true);
  289. $this->Queries[] = array($Query, ($QueryEndTime - $QueryStartTime) * 1000, null);
  290. $this->Time += ($QueryEndTime - $QueryStartTime) * 1000;
  291. if (!$this->QueryID && !$this->StatementID) {
  292. $this->Errno = mysqli_errno($this->LinkID);
  293. $this->Error = mysqli_error($this->LinkID);
  294. $this->halt("Invalid Query: $Query");
  295. }
  296. $this->Row = 0;
  297. return $this->QueryID;
  298. }
  299. /**
  300. * inserted_id
  301. */
  302. public function inserted_id()
  303. {
  304. if ($this->LinkID) {
  305. return mysqli_insert_id($this->LinkID);
  306. }
  307. }
  308. /**
  309. * next_record
  310. */
  311. public function next_record($Type = MYSQLI_BOTH, $Escape = true)
  312. { // $Escape can be true, false, or an array of keys to not escape
  313. if ($this->LinkID) {
  314. $this->Record = mysqli_fetch_array($this->QueryID, $Type);
  315. $this->Row++;
  316. if (!is_array($this->Record)) {
  317. $this->QueryID = false;
  318. } elseif ($Escape !== false) {
  319. $this->Record = Misc::display_array($this->Record, $Escape);
  320. }
  321. return $this->Record;
  322. }
  323. }
  324. /**
  325. * close
  326. */
  327. public function close()
  328. {
  329. if ($this->LinkID) {
  330. if (!mysqli_close($this->LinkID)) {
  331. $this->halt('Cannot close connection or connection did not open.');
  332. }
  333. $this->LinkID = false;
  334. }
  335. }
  336. /*
  337. * Returns an integer with the number of rows found
  338. * Returns a string if the number of rows found exceeds MAXINT
  339. */
  340. public function record_count()
  341. {
  342. if ($this->QueryID) {
  343. return mysqli_num_rows($this->QueryID);
  344. }
  345. }
  346. /*
  347. * Returns true if the query exists and there were records found
  348. * Returns false if the query does not exist or if there were 0 records returned
  349. */
  350. public function has_results()
  351. {
  352. return ($this->QueryID && $this->record_count() !== 0);
  353. }
  354. /**
  355. * affected_rows
  356. */
  357. public function affected_rows()
  358. {
  359. if ($this->LinkID) {
  360. return mysqli_affected_rows($this->LinkID);
  361. }
  362. }
  363. /**
  364. * info
  365. */
  366. public function info()
  367. {
  368. return mysqli_get_host_info($this->LinkID);
  369. }
  370. // Creates an array from a result set
  371. // If $Key is set, use the $Key column in the result set as the array key
  372. // Otherwise, use an integer
  373. public function to_array($Key = false, $Type = MYSQLI_BOTH, $Escape = true)
  374. {
  375. $Return = [];
  376. while ($Row = mysqli_fetch_array($this->QueryID, $Type)) {
  377. if ($Escape !== false) {
  378. $Row = Misc::display_array($Row, $Escape);
  379. }
  380. if ($Key !== false) {
  381. $Return[$Row[$Key]] = $Row;
  382. } else {
  383. $Return[] = $Row;
  384. }
  385. }
  386. mysqli_data_seek($this->QueryID, 0);
  387. return $Return;
  388. }
  389. // Loops through the result set, collecting the $ValField column into an array with $KeyField as keys
  390. public function to_pair($KeyField, $ValField, $Escape = true)
  391. {
  392. $Return = [];
  393. while ($Row = mysqli_fetch_array($this->QueryID)) {
  394. if ($Escape) {
  395. $Key = display_str($Row[$KeyField]);
  396. $Val = display_str($Row[$ValField]);
  397. } else {
  398. $Key = $Row[$KeyField];
  399. $Val = $Row[$ValField];
  400. }
  401. $Return[$Key] = $Val;
  402. }
  403. mysqli_data_seek($this->QueryID, 0);
  404. return $Return;
  405. }
  406. // Loops through the result set, collecting the $Key column into an array
  407. public function collect($Key, $Escape = true)
  408. {
  409. $Return = [];
  410. while ($Row = mysqli_fetch_array($this->QueryID)) {
  411. $Return[] = $Escape ? display_str($Row[$Key]) : $Row[$Key];
  412. }
  413. mysqli_data_seek($this->QueryID, 0);
  414. return $Return;
  415. }
  416. /**
  417. * Useful extras from OPS
  418. */
  419. /**
  420. * Runs a prepared_query using placeholders and returns the matched row.
  421. * Stashes the current query id so that this can be used within a block
  422. * that is looping over an active resultset.
  423. *
  424. * @param string $sql The parameterized query to run
  425. * @param mixed $args The values of the placeholders
  426. * @return array resultset or null
  427. */
  428. public function row($Query, &...$BindVars)
  429. {
  430. $qid = $this->get_query_id();
  431. $this->query($Query, ...$BindVars);
  432. $result = $this->next_record(MYSQLI_NUM, false);
  433. $this->set_query_id($qid);
  434. return $result;
  435. }
  436. /**
  437. * Runs a prepared_query using placeholders and returns the first element
  438. * of the first row.
  439. * Stashes the current query id so that this can be used within a block
  440. * that is looping over an active resultset.
  441. *
  442. * @param string $sql The parameterized query to run
  443. * @param mixed $args The values of the placeholders
  444. * @return mixed value or null
  445. */
  446. public function scalar($Query, &...$BindVars)
  447. {
  448. $qid = $this->get_query_id();
  449. $this->query($Query, ...$BindVars);
  450. $result = $this->has_results() ? $this->next_record(MYSQLI_NUM, false) : [null];
  451. $this->set_query_id($qid);
  452. return $result[0];
  453. }
  454. # End OPS additions
  455. /**
  456. * set_query_id
  457. */
  458. public function set_query_id(&$ResultSet)
  459. {
  460. $this->QueryID = $ResultSet;
  461. $this->Row = 0;
  462. }
  463. /**
  464. * get_query_id
  465. */
  466. public function get_query_id()
  467. {
  468. return $this->QueryID;
  469. }
  470. /**
  471. * beginning
  472. */
  473. public function beginning()
  474. {
  475. mysqli_data_seek($this->QueryID, 0);
  476. $this->Row = 0;
  477. }
  478. /**
  479. * This function determines whether the last query caused warning messages
  480. * and stores them in $this->Queries
  481. */
  482. public function warnings()
  483. {
  484. $Warnings = [];
  485. if (!is_bool($this->LinkID) && mysqli_warning_count($this->LinkID)) {
  486. $e = mysqli_get_warnings($this->LinkID);
  487. do {
  488. if ($e->errno === 1592) {
  489. // 1592: Unsafe statement written to the binary log using statement format since BINLOG_FORMAT = STATEMENT
  490. continue;
  491. }
  492. $Warnings[] = 'Code ' . $e->errno . ': ' . display_str($e->message);
  493. } while ($e->next());
  494. }
  495. $this->Queries[count($this->Queries) - 1][2] = $Warnings;
  496. }
  497. /**
  498. * todo: Work this into Bio Gazelle
  499. * @see https://github.com/OPSnet/Gazelle/blob/master/app/DB.php
  500. */
  501. /**
  502. * Soft delete a row from a table <t> by inserting it into deleted_<t> and then delete from <t>
  503. * @param string $schema the schema name
  504. * @param string $table the table name
  505. * @param array $condition Must be an array of arrays, e.g. [[column_name, column_value]] or [[col1, val1], [col2, val2]]
  506. * Will be used to identify the row (or rows) to delete
  507. * @param boolean $delete whether to delete the matched rows
  508. * @return array 2 elements, true/false and message if false
  509. * /
  510. public function softDelete($schema, $table, array $condition, $delete = true)
  511. {
  512. $sql = 'SELECT column_name, column_type FROM information_schema.columns WHERE table_schema = ? AND table_name = ? ORDER BY 1';
  513. $this->db->prepared_query($sql, $schema, $table);
  514. $t1 = $this->db->to_array();
  515. $n1 = count($t1);
  516. $softDeleteTable = 'deleted_' . $table;
  517. $this->db->prepared_query($sql, $schema, $softDeleteTable);
  518. $t2 = $this->db->to_array();
  519. $n2 = count($t2);
  520. if (!$n1) {
  521. return [false, "No such table $table"];
  522. } elseif (!$n2) {
  523. return [false, "No such table $softDeleteTable"];
  524. } elseif ($n1 != $n2) {
  525. // tables do not have the same number of columns
  526. return [false, "$table and $softDeleteTable column count mismatch ($n1 != $n2)"];
  527. }
  528. $column = [];
  529. for ($i = 0; $i < $n1; ++$i) {
  530. // a column does not have the same name or datatype
  531. if (strtolower($t1[$i][0]) != strtolower($t2[$i][0]) || $t1[$i][1] != $t2[$i][1]) {
  532. return [false, "{$table}: column {$t1[$i][0]} name or datatype mismatch {$t1[$i][0]}:{$t2[$i][0]} {$t1[$i][1]}:{$t2[$i][1]}"];
  533. }
  534. $column[] = $t1[$i][0];
  535. }
  536. $columnList = implode(', ', $column);
  537. $conditionList = implode(' AND ', array_map(function ($c) {
  538. return "{$c[0]} = ?";
  539. }, $condition));
  540. $argList = array_map(function ($c) {
  541. return $c[1];
  542. }, $condition);
  543. $sql = "INSERT INTO $softDeleteTable
  544. ($columnList)
  545. SELECT $columnList
  546. FROM $table
  547. WHERE $conditionList";
  548. $this->db->prepared_query($sql, ...$argList);
  549. if ($this->db->affected_rows() == 0) {
  550. return [false, "condition selected 0 rows"];
  551. }
  552. if (!$delete) {
  553. return [true, "rows affected: " . $this->db->affected_rows()];
  554. }
  555. $sql = "DELETE FROM $table WHERE $conditionList";
  556. $this->db->prepared_query($sql, ...$argList);
  557. return [true, "rows deleted: " . $this->db->affected_rows()];
  558. }
  559. */
  560. }