safemysql.class.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. <?php
  2. /**
  3. * @author col.shrapnel@gmail.com
  4. * @link http://phpfaq.ru/safemysql
  5. *
  6. * Safe and convenient way to handle SQL queries utilizing type-hinted placeholders.
  7. *
  8. * Key features
  9. * - set of helper functions to get the desired result right out of query, like in PEAR::DB
  10. * - conditional query building using parse() method to build queries of whatever comlexity,
  11. * while keeping extra safety of placeholders
  12. * - type-hinted placeholders
  13. *
  14. * Type-hinted placeholders are great because
  15. * - safe, as any other [properly implemented] placeholders
  16. * - no need for manual escaping or binding, makes the code extra DRY
  17. * - allows support for non-standard types such as identifier or array, which saves A LOT of pain in the back.
  18. *
  19. * Supported placeholders at the moment are:
  20. *
  21. * ?s ("string") - strings (also DATE, FLOAT and DECIMAL)
  22. * ?i ("integer") - the name says it all
  23. * ?n ("name") - identifiers (table and field names)
  24. * ?a ("array") - complex placeholder for IN() operator (substituted with string of 'a','b','c' format, without parentesis)
  25. * ?u ("update") - complex placeholder for SET operator (substituted with string of `field`='value',`field`='value' format)
  26. * and
  27. * ?p ("parsed") - special type placeholder, for inserting already parsed statements without any processing, to avoid double parsing.
  28. *
  29. * Connection:
  30. *
  31. * $db = new SafeMySQL(); // with default settings
  32. *
  33. * $opts = array(
  34. * 'user' => 'user',
  35. * 'pass' => 'pass',
  36. * 'db' => 'db',
  37. * 'charset' => 'latin1'
  38. * );
  39. * $db = new SafeMySQL($opts); // with some of the default settings overwritten
  40. *
  41. * Alternatively, you can just pass an existing mysqli instance that will be used to run queries
  42. * instead of creating a new connection.
  43. * Excellent choice for migration!
  44. *
  45. * $db = new SafeMySQL(['mysqli' => $mysqli]);
  46. *
  47. * Some examples:
  48. *
  49. * $name = $db->getOne('SELECT name FROM table WHERE id = ?i',$_GET['id']);
  50. * $data = $db->getInd('id','SELECT * FROM ?n WHERE id IN ?a','table', array(1,2));
  51. * $data = $db->getAll("SELECT * FROM ?n WHERE mod=?s LIMIT ?i",$table,$mod,$limit);
  52. *
  53. * $ids = $db->getCol("SELECT id FROM tags WHERE tagname = ?s",$tag);
  54. * $data = $db->getAll("SELECT * FROM table WHERE category IN (?a)",$ids);
  55. *
  56. * $data = array('offers_in' => $in, 'offers_out' => $out);
  57. * $sql = "INSERT INTO stats SET pid=?i,dt=CURDATE(),?u ON DUPLICATE KEY UPDATE ?u";
  58. * $db->query($sql,$pid,$data,$data);
  59. *
  60. * if ($var === NULL) {
  61. * $sqlpart = "field is NULL";
  62. * } else {
  63. * $sqlpart = $db->parse("field = ?s", $var);
  64. * }
  65. * $data = $db->getAll("SELECT * FROM table WHERE ?p", $bar, $sqlpart);
  66. *
  67. */
  68. class SafeMySQL
  69. {
  70. protected $conn;
  71. protected $stats;
  72. protected $emode;
  73. protected $exname;
  74. protected $defaults = array(
  75. 'host' => 'localhost',
  76. 'user' => 'root',
  77. 'pass' => '',
  78. 'db' => 'test',
  79. 'port' => NULL,
  80. 'socket' => NULL,
  81. 'pconnect' => FALSE,
  82. 'charset' => 'utf8',
  83. 'errmode' => 'exception', //or 'error'
  84. 'exception' => 'Exception', //Exception class name
  85. );
  86. const RESULT_ASSOC = MYSQLI_ASSOC;
  87. const RESULT_NUM = MYSQLI_NUM;
  88. function __construct($opt = array())
  89. {
  90. $opt = array_merge($this->defaults,$opt);
  91. $this->emode = $opt['errmode'];
  92. $this->exname = $opt['exception'];
  93. if (isset($opt['mysqli']))
  94. {
  95. if ($opt['mysqli'] instanceof mysqli)
  96. {
  97. $this->conn = $opt['mysqli'];
  98. return;
  99. } else {
  100. $this->error("mysqli option must be valid instance of mysqli class");
  101. }
  102. }
  103. if ($opt['pconnect'])
  104. {
  105. $opt['host'] = "p:".$opt['host'];
  106. }
  107. @$this->conn = mysqli_connect($opt['host'], $opt['user'], $opt['pass'], $opt['db'], $opt['port'], $opt['socket']);
  108. if ( !$this->conn )
  109. {
  110. $this->error(mysqli_connect_errno()." ".mysqli_connect_error());
  111. }
  112. mysqli_set_charset($this->conn, $opt['charset']) or $this->error(mysqli_error($this->conn));
  113. unset($opt); // I am paranoid
  114. }
  115. /**
  116. * Conventional function to run a query with placeholders. A mysqli_query wrapper with placeholders support
  117. *
  118. * Examples:
  119. * $db->query("DELETE FROM table WHERE id=?i", $id);
  120. *
  121. * @param string $query - an SQL query with placeholders
  122. * @param mixed $arg,... unlimited number of arguments to match placeholders in the query
  123. * @return resource|FALSE whatever mysqli_query returns
  124. */
  125. public function query()
  126. {
  127. return $this->rawQuery($this->prepareQuery(func_get_args()));
  128. }
  129. /**
  130. * Conventional function to fetch single row.
  131. *
  132. * @param resource $result - myqli result
  133. * @param int $mode - optional fetch mode, RESULT_ASSOC|RESULT_NUM, default RESULT_ASSOC
  134. * @return array|FALSE whatever mysqli_fetch_array returns
  135. */
  136. public function fetch($result,$mode=self::RESULT_ASSOC)
  137. {
  138. return mysqli_fetch_array($result, $mode);
  139. }
  140. /**
  141. * Conventional function to get number of affected rows.
  142. *
  143. * @return int whatever mysqli_affected_rows returns
  144. */
  145. public function affectedRows()
  146. {
  147. return mysqli_affected_rows ($this->conn);
  148. }
  149. /**
  150. * Conventional function to get last insert id.
  151. *
  152. * @return int whatever mysqli_insert_id returns
  153. */
  154. public function insertId()
  155. {
  156. return mysqli_insert_id($this->conn);
  157. }
  158. /**
  159. * Conventional function to get number of rows in the resultset.
  160. *
  161. * @param resource $result - myqli result
  162. * @return int whatever mysqli_num_rows returns
  163. */
  164. public function numRows($result)
  165. {
  166. return mysqli_num_rows($result);
  167. }
  168. /**
  169. * Conventional function to free the resultset.
  170. */
  171. public function free($result)
  172. {
  173. mysqli_free_result($result);
  174. }
  175. /**
  176. * Helper function to get scalar value right out of query and optional arguments
  177. *
  178. * Examples:
  179. * $name = $db->getOne("SELECT name FROM table WHERE id=1");
  180. * $name = $db->getOne("SELECT name FROM table WHERE id=?i", $id);
  181. *
  182. * @param string $query - an SQL query with placeholders
  183. * @param mixed $arg,... unlimited number of arguments to match placeholders in the query
  184. * @return string|FALSE either first column of the first row of resultset or FALSE if none found
  185. */
  186. public function getOne()
  187. {
  188. $query = $this->prepareQuery(func_get_args());
  189. if ($res = $this->rawQuery($query))
  190. {
  191. $row = $this->fetch($res);
  192. if (is_array($row)) {
  193. return reset($row);
  194. }
  195. $this->free($res);
  196. }
  197. return FALSE;
  198. }
  199. /**
  200. * Helper function to get single row right out of query and optional arguments
  201. *
  202. * Examples:
  203. * $data = $db->getRow("SELECT * FROM table WHERE id=1");
  204. * $data = $db->getRow("SELECT * FROM table WHERE id=?i", $id);
  205. *
  206. * @param string $query - an SQL query with placeholders
  207. * @param mixed $arg,... unlimited number of arguments to match placeholders in the query
  208. * @return array|FALSE either associative array contains first row of resultset or FALSE if none found
  209. */
  210. public function getRow()
  211. {
  212. $query = $this->prepareQuery(func_get_args());
  213. if ($res = $this->rawQuery($query)) {
  214. $ret = $this->fetch($res);
  215. $this->free($res);
  216. return $ret;
  217. }
  218. return FALSE;
  219. }
  220. /**
  221. * Helper function to get single column right out of query and optional arguments
  222. *
  223. * Examples:
  224. * $ids = $db->getCol("SELECT id FROM table WHERE cat=1");
  225. * $ids = $db->getCol("SELECT id FROM tags WHERE tagname = ?s", $tag);
  226. *
  227. * @param string $query - an SQL query with placeholders
  228. * @param mixed $arg,... unlimited number of arguments to match placeholders in the query
  229. * @return array enumerated array of first fields of all rows of resultset or empty array if none found
  230. */
  231. public function getCol()
  232. {
  233. $ret = array();
  234. $query = $this->prepareQuery(func_get_args());
  235. if ( $res = $this->rawQuery($query) )
  236. {
  237. while($row = $this->fetch($res))
  238. {
  239. $ret[] = reset($row);
  240. }
  241. $this->free($res);
  242. }
  243. return $ret;
  244. }
  245. /**
  246. * Helper function to get all the rows of resultset right out of query and optional arguments
  247. *
  248. * Examples:
  249. * $data = $db->getAll("SELECT * FROM table");
  250. * $data = $db->getAll("SELECT * FROM table LIMIT ?i,?i", $start, $rows);
  251. *
  252. * @param string $query - an SQL query with placeholders
  253. * @param mixed $arg,... unlimited number of arguments to match placeholders in the query
  254. * @return array enumerated 2d array contains the resultset. Empty if no rows found.
  255. */
  256. public function getAll()
  257. {
  258. $ret = array();
  259. $query = $this->prepareQuery(func_get_args());
  260. if ( $res = $this->rawQuery($query) )
  261. {
  262. while($row = $this->fetch($res))
  263. {
  264. $ret[] = $row;
  265. }
  266. $this->free($res);
  267. }
  268. return $ret;
  269. }
  270. /**
  271. * Helper function to get all the rows of resultset into indexed array right out of query and optional arguments
  272. *
  273. * Examples:
  274. * $data = $db->getInd("id", "SELECT * FROM table");
  275. * $data = $db->getInd("id", "SELECT * FROM table LIMIT ?i,?i", $start, $rows);
  276. *
  277. * @param string $index - name of the field which value is used to index resulting array
  278. * @param string $query - an SQL query with placeholders
  279. * @param mixed $arg,... unlimited number of arguments to match placeholders in the query
  280. * @return array - associative 2d array contains the resultset. Empty if no rows found.
  281. */
  282. public function getInd()
  283. {
  284. $args = func_get_args();
  285. $index = array_shift($args);
  286. $query = $this->prepareQuery($args);
  287. $ret = array();
  288. if ( $res = $this->rawQuery($query) )
  289. {
  290. while($row = $this->fetch($res))
  291. {
  292. $ret[$row[$index]] = $row;
  293. }
  294. $this->free($res);
  295. }
  296. return $ret;
  297. }
  298. /**
  299. * Helper function to get a dictionary-style array right out of query and optional arguments
  300. *
  301. * Examples:
  302. * $data = $db->getIndCol("name", "SELECT name, id FROM cities");
  303. *
  304. * @param string $index - name of the field which value is used to index resulting array
  305. * @param string $query - an SQL query with placeholders
  306. * @param mixed $arg,... unlimited number of arguments to match placeholders in the query
  307. * @return array - associative array contains key=value pairs out of resultset. Empty if no rows found.
  308. */
  309. public function getIndCol()
  310. {
  311. $args = func_get_args();
  312. $index = array_shift($args);
  313. $query = $this->prepareQuery($args);
  314. $ret = array();
  315. if ( $res = $this->rawQuery($query) )
  316. {
  317. while($row = $this->fetch($res))
  318. {
  319. $key = $row[$index];
  320. unset($row[$index]);
  321. $ret[$key] = reset($row);
  322. }
  323. $this->free($res);
  324. }
  325. return $ret;
  326. }
  327. /**
  328. * Function to parse placeholders either in the full query or a query part
  329. * unlike native prepared statements, allows ANY query part to be parsed
  330. *
  331. * useful for debug
  332. * and EXTREMELY useful for conditional query building
  333. * like adding various query parts using loops, conditions, etc.
  334. * already parsed parts have to be added via ?p placeholder
  335. *
  336. * Examples:
  337. * $query = $db->parse("SELECT * FROM table WHERE foo=?s AND bar=?s", $foo, $bar);
  338. * echo $query;
  339. *
  340. * if ($foo) {
  341. * $qpart = $db->parse(" AND foo=?s", $foo);
  342. * }
  343. * $data = $db->getAll("SELECT * FROM table WHERE bar=?s ?p", $bar, $qpart);
  344. *
  345. * @param string $query - whatever expression contains placeholders
  346. * @param mixed $arg,... unlimited number of arguments to match placeholders in the expression
  347. * @return string - initial expression with placeholders substituted with data.
  348. */
  349. public function parse()
  350. {
  351. return $this->prepareQuery(func_get_args());
  352. }
  353. /**
  354. * function to implement whitelisting feature
  355. * sometimes we can't allow a non-validated user-supplied data to the query even through placeholder
  356. * especially if it comes down to SQL OPERATORS
  357. *
  358. * Example:
  359. *
  360. * $order = $db->whiteList($_GET['order'], array('name','price'));
  361. * $dir = $db->whiteList($_GET['dir'], array('ASC','DESC'));
  362. * if (!$order || !dir) {
  363. * throw new http404(); //non-expected values should cause 404 or similar response
  364. * }
  365. * $sql = "SELECT * FROM table ORDER BY ?p ?p LIMIT ?i,?i"
  366. * $data = $db->getArr($sql, $order, $dir, $start, $per_page);
  367. *
  368. * @param string $iinput - field name to test
  369. * @param array $allowed - an array with allowed variants
  370. * @param string $default - optional variable to set if no match found. Default to false.
  371. * @return string|FALSE - either sanitized value or FALSE
  372. */
  373. public function whiteList($input,$allowed,$default=FALSE)
  374. {
  375. $found = array_search($input,$allowed);
  376. return ($found === FALSE) ? $default : $allowed[$found];
  377. }
  378. /**
  379. * function to filter out arrays, for the whitelisting purposes
  380. * useful to pass entire superglobal to the INSERT or UPDATE query
  381. * OUGHT to be used for this purpose,
  382. * as there could be fields to which user should have no access to.
  383. *
  384. * Example:
  385. * $allowed = array('title','url','body','rating','term','type');
  386. * $data = $db->filterArray($_POST,$allowed);
  387. * $sql = "INSERT INTO ?n SET ?u";
  388. * $db->query($sql,$table,$data);
  389. *
  390. * @param array $input - source array
  391. * @param array $allowed - an array with allowed field names
  392. * @return array filtered out source array
  393. */
  394. public function filterArray($input,$allowed)
  395. {
  396. foreach(array_keys($input) as $key )
  397. {
  398. if ( !in_array($key,$allowed) )
  399. {
  400. unset($input[$key]);
  401. }
  402. }
  403. return $input;
  404. }
  405. /**
  406. * Function to get last executed query.
  407. *
  408. * @return string|NULL either last executed query or NULL if were none
  409. */
  410. public function lastQuery()
  411. {
  412. $last = end($this->stats);
  413. return $last['query'];
  414. }
  415. /**
  416. * Function to get all query statistics.
  417. *
  418. * @return array contains all executed queries with timings and errors
  419. */
  420. public function getStats()
  421. {
  422. return $this->stats;
  423. }
  424. /**
  425. * protected function which actually runs a query against Mysql server.
  426. * also logs some stats like profiling info and error message
  427. *
  428. * @param string $query - a regular SQL query
  429. * @return mysqli result resource or FALSE on error
  430. */
  431. protected function rawQuery($query)
  432. {
  433. $start = microtime(TRUE);
  434. $res = mysqli_query($this->conn, $query);
  435. $timer = microtime(TRUE) - $start;
  436. $this->stats[] = array(
  437. 'query' => $query,
  438. 'start' => $start,
  439. 'timer' => $timer,
  440. );
  441. if (!$res)
  442. {
  443. $error = mysqli_error($this->conn);
  444. end($this->stats);
  445. $key = key($this->stats);
  446. $this->stats[$key]['error'] = $error;
  447. $this->cutStats();
  448. $this->error("$error. Full query: [$query]");
  449. }
  450. $this->cutStats();
  451. return $res;
  452. }
  453. protected function prepareQuery($args)
  454. {
  455. $query = '';
  456. $raw = array_shift($args);
  457. $array = preg_split('~(\?[nsiuap])~u',$raw,-1,PREG_SPLIT_DELIM_CAPTURE);
  458. $anum = count($args);
  459. $pnum = floor(count($array) / 2);
  460. if ( $pnum != $anum )
  461. {
  462. $this->error("Number of args ($anum) doesn't match number of placeholders ($pnum) in [$raw]");
  463. }
  464. foreach ($array as $i => $part)
  465. {
  466. if ( ($i % 2) == 0 )
  467. {
  468. $query .= $part;
  469. continue;
  470. }
  471. $value = array_shift($args);
  472. switch ($part)
  473. {
  474. case '?n':
  475. $part = $this->escapeIdent($value);
  476. break;
  477. case '?s':
  478. $part = $this->escapeString($value);
  479. break;
  480. case '?i':
  481. $part = $this->escapeInt($value);
  482. break;
  483. case '?a':
  484. $part = $this->createIN($value);
  485. break;
  486. case '?u':
  487. $part = $this->createSET($value);
  488. break;
  489. case '?p':
  490. $part = $value;
  491. break;
  492. }
  493. $query .= $part;
  494. }
  495. return $query;
  496. }
  497. protected function escapeInt($value)
  498. {
  499. if ($value === NULL)
  500. {
  501. return 'NULL';
  502. }
  503. if(!is_numeric($value))
  504. {
  505. $this->error("Integer (?i) placeholder expects numeric value, ".gettype($value)." given");
  506. return FALSE;
  507. }
  508. if (is_float($value))
  509. {
  510. $value = number_format($value, 0, '.', ''); // may lose precision on big numbers
  511. }
  512. return $value;
  513. }
  514. protected function escapeString($value)
  515. {
  516. if ($value === NULL)
  517. {
  518. return 'NULL';
  519. }
  520. return "'".mysqli_real_escape_string($this->conn,$value)."'";
  521. }
  522. protected function escapeIdent($value)
  523. {
  524. if ($value)
  525. {
  526. return "`".str_replace("`","``",$value)."`";
  527. } else {
  528. $this->error("Empty value for identifier (?n) placeholder");
  529. }
  530. }
  531. protected function createIN($data)
  532. {
  533. if (!is_array($data))
  534. {
  535. $this->error("Value for IN (?a) placeholder should be array");
  536. return;
  537. }
  538. if (!$data)
  539. {
  540. return 'NULL';
  541. }
  542. $query = $comma = '';
  543. foreach ($data as $value)
  544. {
  545. $query .= $comma.$this->escapeString($value);
  546. $comma = ",";
  547. }
  548. return $query;
  549. }
  550. protected function createSET($data)
  551. {
  552. if (!is_array($data))
  553. {
  554. $this->error("SET (?u) placeholder expects array, ".gettype($data)." given");
  555. return;
  556. }
  557. if (!$data)
  558. {
  559. $this->error("Empty array for SET (?u) placeholder");
  560. return;
  561. }
  562. $query = $comma = '';
  563. foreach ($data as $key => $value)
  564. {
  565. if ( is_int($value) )
  566. {
  567. // Bit types coerced from strings are NOT the same as int from string.
  568. // In any event, int is inherently safe because they are NOT strings!
  569. $query .= $comma.$this->escapeIdent($key).'='.$this->escapeInt($value);
  570. } else {
  571. $query .= $comma.$this->escapeIdent($key).'='.$this->escapeString($value);
  572. }
  573. $comma = ",";
  574. }
  575. return $query;
  576. }
  577. protected function error($err)
  578. {
  579. $err = __CLASS__.": ".$err;
  580. if ( $this->emode == 'error' )
  581. {
  582. $err .= ". Error initiated in ".$this->caller().", thrown";
  583. trigger_error($err,E_USER_ERROR);
  584. } else {
  585. throw new $this->exname($err);
  586. }
  587. }
  588. protected function caller()
  589. {
  590. $trace = debug_backtrace();
  591. $caller = '';
  592. foreach ($trace as $t)
  593. {
  594. if ( isset($t['class']) && $t['class'] == __CLASS__ )
  595. {
  596. $caller = $t['file']." on line ".$t['line'];
  597. } else {
  598. break;
  599. }
  600. }
  601. return $caller;
  602. }
  603. /**
  604. * On a long run we can eat up too much memory with mere statsistics
  605. * Let's keep it at reasonable size, leaving only last 100 entries.
  606. */
  607. protected function cutStats()
  608. {
  609. if ( count($this->stats) > 100 )
  610. {
  611. reset($this->stats);
  612. $first = key($this->stats);
  613. unset($this->stats[$first]);
  614. }
  615. }
  616. }