PageRenderTime 65ms CodeModel.GetById 29ms RepoModel.GetById 0ms app.codeStats 1ms

/classes/fSQLTranslation.php

https://bitbucket.org/wbond/flourish/
PHP | 1064 lines | 622 code | 162 blank | 280 comment | 95 complexity | 068fd613148026eaae99a53317a3ce27 MD5 | raw file
  1. <?php
  2. /**
  3. * Takes a subset of SQL from IBM DB2, MySQL, PostgreSQL, Oracle, SQLite and MSSQL and translates into the various dialects allowing for cross-database code
  4. *
  5. * @copyright Copyright (c) 2007-2011 Will Bond
  6. * @author Will Bond [wb] <will@flourishlib.com>
  7. * @license http://flourishlib.com/license
  8. *
  9. * @package Flourish
  10. * @link http://flourishlib.com/fSQLTranslation
  11. *
  12. * @version 1.0.0b20
  13. * @changes 1.0.0b20 Added fix for PostgreSQL to handle `INSERT` statements that don't specify any columns or values [wb, 2011-09-06]
  14. * @changes 1.0.0b19 Removed the stray method ::removeSQLiteIndexes() that was left over from moving code into fSQLSchemaTranslation [wb, 2011-05-17]
  15. * @changes 1.0.0b18 Fixed `LENGTH()` and `SUBSTR()` functions for non-ascii characters being stored in MySQL, SQLite and DB2, moved `CREATE TABLE` support to fSQLSchemaTranslation [wb, 2011-05-09]
  16. * @changes 1.0.0b17 Internal Backwards Compatiblity Break - changed the array keys for translated queries returned from ::translate() to include a number plus `:` before the original SQL, preventing duplicate keys [wb, 2010-07-14]
  17. * @changes 1.0.0b16 Added IBM DB2 support [wb, 2010-04-13]
  18. * @changes 1.0.0b15 Fixed a bug with MSSQL national character conversion when running a SQL statement with a sub-select containing joins [wb, 2009-12-18]
  19. * @changes 1.0.0b14 Changed PostgreSQL to cast columns in LOWER() calls to VARCHAR to allow UUID columns (which are treated as a VARCHAR by fSchema) to work with default primary key ordering in fRecordSet [wb, 2009-12-16]
  20. * @changes 1.0.0b13 Added a parameter to ::enableCaching() to provide a key token that will allow cached values to be shared between multiple databases with the same schema [wb, 2009-10-28]
  21. * @changes 1.0.0b12 Backwards Compatibility Break - Removed date translation functionality, changed the signature of ::translate(), updated to support quoted identifiers, added support for PostgreSQL, MSSQL and Oracle schemas [wb, 2009-10-22]
  22. * @changes 1.0.0b11 Fixed a bug with translating MSSQL national columns over an ODBC connection [wb, 2009-09-18]
  23. * @changes 1.0.0b10 Changed last bug fix to support PHP 5.1.6 [wb, 2009-09-18]
  24. * @changes 1.0.0b9 Fixed another bug with parsing table aliases for MSSQL national columns [wb, 2009-09-18]
  25. * @changes 1.0.0b8 Fixed a bug with parsing table aliases that occurs when handling MSSQL national columns [wb, 2009-09-09]
  26. * @changes 1.0.0b7 Fixed a bug with translating `NOT LIKE` operators in PostgreSQL [wb, 2009-07-15]
  27. * @changes 1.0.0b6 Changed replacement values in preg_replace() calls to be properly escaped [wb, 2009-06-11]
  28. * @changes 1.0.0b5 Update code to only translate data types inside of `CREATE TABLE` queries [wb, 2009-05-22]
  29. * @changes 1.0.0b4 Added the missing ::__get() method for callback support [wb, 2009-05-06]
  30. * @changes 1.0.0b3 Added Oracle and caching support, various bug fixes [wb, 2009-05-04]
  31. * @changes 1.0.0b2 Fixed a notice with SQLite foreign key constraints having no `ON` clauses [wb, 2009-02-21]
  32. * @changes 1.0.0b The initial implementation [wb, 2007-09-25]
  33. */
  34. class fSQLTranslation
  35. {
  36. // The following constants allow for nice looking callbacks to static methods
  37. const sqliteCotangent = 'fSQLTranslation::sqliteCotangent';
  38. const sqliteLogBaseFirst = 'fSQLTranslation::sqliteLogBaseFirst';
  39. const sqliteSign = 'fSQLTranslation::sqliteSign';
  40. /**
  41. * Composes text using fText if loaded
  42. *
  43. * @param string $message The message to compose
  44. * @param mixed $component A string or number to insert into the message
  45. * @param mixed ...
  46. * @return string The composed and possible translated message
  47. */
  48. static protected function compose($message)
  49. {
  50. $args = array_slice(func_get_args(), 1);
  51. if (class_exists('fText', FALSE)) {
  52. return call_user_func_array(
  53. array('fText', 'compose'),
  54. array($message, $args)
  55. );
  56. } else {
  57. return vsprintf($message, $args);
  58. }
  59. }
  60. /**
  61. * Takes the `FROM` clause from ::parseSelectSQL() and returns all of the tables and each one's alias
  62. *
  63. * @param string $clause The SQL `FROM` clause to parse
  64. * @return array The tables in the `FROM` clause, in the format `{table_alias} => {table_name}`
  65. */
  66. static private function parseTableAliases($sql)
  67. {
  68. $aliases = array();
  69. // Turn comma joins into cross joins
  70. if (preg_match('#^(?:"?:?\w+"?(?:\s+(?:as\s+)?(?:"?\w+"?))?)(?:\s*,\s*(?:"?\w+"?(?:\s+(?:as\s+)?(?:"?\w+"?))?))*$#isD', $sql)) {
  71. $sql = str_replace(',', ' CROSS JOIN ', $sql);
  72. }
  73. $tables = preg_split('#\s+((?:(?:CROSS|INNER|OUTER|LEFT|RIGHT)?\s+)*?JOIN)\s+#i', $sql);
  74. foreach ($tables as $table) {
  75. // This grabs the table name and alias (if there is one)
  76. preg_match('#^\s*([":\w.]+|\(((?:[^()]+|\((?2)\))*)\))(?:\s+(?:as\s+)?((?!ON|USING)["\w.]+))?\s*(?:(?:ON|USING)\s+(.*))?\s*$#im', $table, $parts);
  77. $table_name = $parts[1];
  78. $table_alias = (!empty($parts[3])) ? $parts[3] : $parts[1];
  79. $table_name = str_replace('"', '', $table_name);
  80. $table_alias = str_replace('"', '', $table_alias);
  81. $aliases[$table_alias] = $table_name;
  82. }
  83. return $aliases;
  84. }
  85. /**
  86. * Callback for custom SQLite function; calculates the cotangent of a number
  87. *
  88. * @internal
  89. *
  90. * @param numeric $x The number to calculate the cotangent of
  91. * @return numeric The contangent of `$x`
  92. */
  93. static public function sqliteCotangent($x)
  94. {
  95. return 1/tan($x);
  96. }
  97. /**
  98. * Callback for custom SQLite function; returns the current date
  99. *
  100. * @internal
  101. *
  102. * @return string The current date
  103. */
  104. static public function sqliteDate()
  105. {
  106. return date('Y-m-d');
  107. }
  108. /**
  109. * Callback for custom SQLite length function; returns the number of UTF-8 characters in a string
  110. *
  111. * @internal
  112. *
  113. * @param string $string The string to measure
  114. * @return string The current date
  115. */
  116. static public function sqliteLength($string)
  117. {
  118. return fUTF8::len($string);
  119. }
  120. /**
  121. * Callback for custom SQLite function; calculates the log to a specific base of a number
  122. *
  123. * @internal
  124. *
  125. * @param integer $base The base for the log calculation
  126. * @param numeric $num The number to calculate the logarithm of
  127. * @return numeric The logarithm of `$num` to `$base`
  128. */
  129. static public function sqliteLogBaseFirst($base, $num)
  130. {
  131. return log($num, $base);
  132. }
  133. /**
  134. * Callback for custom SQLite function; returns the sign of the number
  135. *
  136. * @internal
  137. *
  138. * @param numeric $x The number to change the sign of
  139. * @return numeric `-1` if a negative sign, `0` if zero, `1` if positive sign
  140. */
  141. static public function sqliteSign($x)
  142. {
  143. if ($x == 0) {
  144. return 0;
  145. }
  146. if ($x > 0) {
  147. return 1;
  148. }
  149. return -1;
  150. }
  151. /**
  152. * Callback for custom SQLite function; creates a substring
  153. *
  154. * @internal
  155. *
  156. * @param string $string The string to take a substring of
  157. * @param integer $start The one-based position to start the substring at
  158. * @param integer $length The length of the substring to take
  159. * @return string The substring
  160. */
  161. static public function sqliteSubstr($string, $start, $length)
  162. {
  163. return fUTF8::sub($string, $start-1, $length);
  164. }
  165. /**
  166. * Callback for custom SQLite function; returns the current time
  167. *
  168. * @internal
  169. *
  170. * @return string The current time
  171. */
  172. static public function sqliteTime()
  173. {
  174. return date('H:i:s');
  175. }
  176. /**
  177. * Callback for custom SQLite function; returns the current timestamp
  178. *
  179. * @internal
  180. *
  181. * @return string The current date
  182. */
  183. static public function sqliteTimestamp()
  184. {
  185. return date('Y-m-d H:i:s');
  186. }
  187. /**
  188. * The fCache object to cache schema info and, optionally, translated queries to
  189. *
  190. * @var fCache
  191. */
  192. private $cache;
  193. /**
  194. * The cache prefix to use for cache entries
  195. *
  196. * @var string
  197. */
  198. private $cache_prefix;
  199. /**
  200. * The fDatabase instance
  201. *
  202. * @var fDatabase
  203. */
  204. private $database;
  205. /**
  206. * If debugging is enabled
  207. *
  208. * @var boolean
  209. */
  210. private $debug;
  211. /**
  212. * Database-specific schema information needed for translation
  213. *
  214. * @var array
  215. */
  216. private $schema_info;
  217. /**
  218. * An instance of fSQLSchemaTranslation for when DDL statements are run
  219. *
  220. * This object is only loaded when needed since it is so large.
  221. *
  222. * @var fSQLSchemaTranslation
  223. */
  224. private $schema_translation;
  225. /**
  226. * Sets up the class and creates functions for SQLite databases
  227. *
  228. * @param fDatabase $database The database being translated for
  229. * @return fSQLTranslation
  230. */
  231. public function __construct($database)
  232. {
  233. $this->database = $database;
  234. if (method_exists($database, 'inject')) {
  235. $this->database->inject($this);
  236. }
  237. if ($database->getType() == 'sqlite') {
  238. $this->createSQLiteFunctions();
  239. }
  240. $this->schema_info = array();
  241. }
  242. /**
  243. * All requests that hit this method should be requests for callbacks
  244. *
  245. * @internal
  246. *
  247. * @param string $method The method to create a callback for
  248. * @return callback The callback for the method requested
  249. */
  250. public function __get($method)
  251. {
  252. return array($this, $method);
  253. }
  254. /**
  255. * Clears all of the schema info out of the object and, if set, the fCache object
  256. *
  257. * @return void
  258. */
  259. public function clearCache()
  260. {
  261. $this->schema_info = array();
  262. if ($this->cache) {
  263. $prefix = $this->makeCachePrefix();
  264. $this->cache->delete($prefix . 'schema_info');
  265. }
  266. }
  267. /**
  268. * Adds a number of math functions to SQLite that MSSQL, MySQL and PostgreSQL have by default
  269. *
  270. * @return void
  271. */
  272. private function createSQLiteFunctions()
  273. {
  274. $function = array();
  275. $functions[] = array('acos', 'acos', 1);
  276. $functions[] = array('asin', 'asin', 1);
  277. $functions[] = array('atan', 'atan', 1);
  278. $functions[] = array('atan2', 'atan2', 2);
  279. $functions[] = array('ceil', 'ceil', 1);
  280. $functions[] = array('ceiling', 'ceil', 1);
  281. $functions[] = array('cos', 'cos', 1);
  282. $functions[] = array('cot', array('fSQLTranslation', 'sqliteCotangent'), 1);
  283. $functions[] = array('degrees', 'rad2deg', 1);
  284. $functions[] = array('exp', 'exp', 1);
  285. $functions[] = array('floor', 'floor', 1);
  286. $functions[] = array('ln', 'log', 1);
  287. $functions[] = array('log', array('fSQLTranslation', 'sqliteLogBaseFirst'), 2);
  288. $functions[] = array('ltrim', 'ltrim', 1);
  289. $functions[] = array('pi', 'pi', 0);
  290. $functions[] = array('power', 'pow', 2);
  291. $functions[] = array('radians', 'deg2rad', 1);
  292. $functions[] = array('rtrim', 'rtrim', 1);
  293. $functions[] = array('sign', array('fSQLTranslation', 'sqliteSign'), 1);
  294. $functions[] = array('sqrt', 'sqrt', 1);
  295. $functions[] = array('sin', 'sin', 1);
  296. $functions[] = array('tan', 'tan', 1);
  297. $functions[] = array('trim', 'trim', 1);
  298. if ($this->database->getExtension() == 'sqlite') {
  299. $functions[] = array('current_date', array('fSQLTranslation', 'sqliteDate'), 0);
  300. $functions[] = array('current_time', array('fSQLTranslation', 'sqliteTime'), 0);
  301. $functions[] = array('current_timestamp', array('fSQLTranslation', 'sqliteTimestamp'), 0);
  302. // If SQLite was compiled with ISO-8859-* string handling, we override as best we can
  303. // with custom functions that return the correct values. We can't fix LIKE and GLOB
  304. // but they don't matter as much since the encoding only affects case transformations.
  305. if (strtolower(sqlite_libencoding()) != 'utf-8') {
  306. $functions[] = array('length', array('fSQLTranslation', 'sqliteLength'), 1);
  307. $functions[] = array('substr', array('fSQLTranslation', 'sqliteSubstr'), 3);
  308. }
  309. }
  310. foreach ($functions as $function) {
  311. if ($this->database->getExtension() == 'pdo') {
  312. $this->database->getConnection()->sqliteCreateFunction($function[0], $function[1], $function[2]);
  313. } else {
  314. sqlite_create_function($this->database->getConnection(), $function[0], $function[1], $function[2]);
  315. }
  316. }
  317. }
  318. /**
  319. * Sets the schema info to be cached to the fCache object specified
  320. *
  321. * @param fCache $cache The cache to cache to
  322. * @return void
  323. */
  324. public function enableCaching($cache, $key_token=NULL)
  325. {
  326. $this->cache = $cache;
  327. if ($key_token !== NULL) {
  328. $this->cache_prefix = 'fSQLTranslation::' . $this->database->getType() . '::' . $key_token . '::';
  329. }
  330. $this->schema_info = $this->cache->get($this->makeCachePrefix() . 'schema_info', array());
  331. }
  332. /**
  333. * Sets if debug messages should be shown
  334. *
  335. * @param boolean $flag If debugging messages should be shown
  336. * @return void
  337. */
  338. public function enableDebugging($flag)
  339. {
  340. $this->debug = (boolean) $flag;
  341. }
  342. /**
  343. * Fixes pulling unicode data out of national data type MSSQL columns
  344. *
  345. * @param string $sql The SQL to fix
  346. * @return string The fixed SQL
  347. */
  348. private function fixMSSQLNationalColumns($sql)
  349. {
  350. if (!preg_match_all('#select((?:(?:(?!\sfrom\s)[^()])+|\(((?:[^()]+|\((?2)\))*)\))*\s)from((?:(?:(?!\sunion\s|\swhere\s|\sgroup by\s|\slimit\s|\sorder by\s)[^()])+|\(((?:[^()]+|\((?4)\))*)\))*)(?=\swhere\s|\sgroup by\s|\slimit\s|\sorder by\s|\sunion\s|\)|$)#i', $sql, $matches, PREG_SET_ORDER)) {
  351. return $sql;
  352. }
  353. if (!isset($this->schema_info['national_columns'])) {
  354. $result = $this->database->query(
  355. "SELECT
  356. c.table_schema AS \"schema\",
  357. c.table_name AS \"table\",
  358. c.column_name AS \"column\",
  359. c.data_type AS \"type\"
  360. FROM
  361. INFORMATION_SCHEMA.COLUMNS AS c
  362. WHERE
  363. (c.data_type = 'nvarchar' OR
  364. c.data_type = 'ntext' OR
  365. c.data_type = 'nchar') AND
  366. c.table_catalog = DB_NAME()
  367. ORDER BY
  368. lower(c.table_name) ASC,
  369. lower(c.column_name) ASC"
  370. );
  371. $national_columns = array();
  372. $national_types = array();
  373. foreach ($result as $row) {
  374. if (!isset($national_columns[$row['table']])) {
  375. $national_columns[$row['table']] = array();
  376. $national_types[$row['table']] = array();
  377. $national_columns[$row['schema'] . '.' . $row['table']] = array();
  378. $national_types[$row['schema'] . '.' . $row['table']] = array();
  379. }
  380. $national_columns[$row['table']][] = $row['column'];
  381. $national_types[$row['table']][$row['column']] = $row['type'];
  382. $national_columns[$row['schema'] . '.' . $row['table']][] = $row['column'];
  383. $national_types[$row['schema'] . '.' . $row['table']][$row['column']] = $row['type'];
  384. }
  385. $this->schema_info['national_columns'] = $national_columns;
  386. $this->schema_info['national_types'] = $national_types;
  387. if ($this->cache) {
  388. $this->cache->set($this->makeCachePrefix() . 'schema_info', $this->schema_info);
  389. }
  390. } else {
  391. $national_columns = $this->schema_info['national_columns'];
  392. $national_types = $this->schema_info['national_types'];
  393. }
  394. $additions = array();
  395. foreach ($matches as $select) {
  396. $select_clause = trim($select[1]);
  397. $from_clause = trim($select[3]);
  398. $sub_selects = array();
  399. if (preg_match_all('#\((\s*SELECT\s+((?:[^()]+|\((?2)\))*))\)#i', $from_clause, $from_matches)) {
  400. $sub_selects = $from_matches[0];
  401. foreach ($sub_selects as $i => $sub_select) {
  402. $from_clause = preg_replace('#' . preg_quote($sub_select, '#') . '#', ':sub_select_' . $i, $from_clause, 1);
  403. }
  404. }
  405. $table_aliases = self::parseTableAliases($from_clause);
  406. preg_match_all('#([^,()]+|\((?:(?1)|,)*\))+#i', $select_clause, $selections);
  407. $selections = array_map('trim', $selections[0]);
  408. $to_fix = array();
  409. foreach ($selections as $selection) {
  410. // We just skip CASE statements since we can't really do those reliably
  411. if (preg_match('#^case#i', $selection)) {
  412. continue;
  413. }
  414. if (preg_match('#(("?\w+"?\.)"?\w+"?)\.\*#i', $selection, $match)) {
  415. $match[1] = str_replace('"', '', $match[1]);
  416. $table = $table_aliases[$match[1]];
  417. if (empty($national_columns[$table])) {
  418. continue;
  419. }
  420. if (!isset($to_fix[$table])) {
  421. $to_fix[$table] = array();
  422. }
  423. $to_fix[$table] = array_merge($to_fix[$table], $national_columns[$table]);
  424. } elseif (preg_match('#\*#', $selection, $match)) {
  425. foreach ($table_aliases as $alias => $table) {
  426. if (empty($national_columns[$table])) {
  427. continue;
  428. }
  429. if (!isset($to_fix[$table])) {
  430. $to_fix[$table] = array();
  431. }
  432. $to_fix[$table] = array_merge($to_fix[$table], $national_columns[$table]);
  433. }
  434. } elseif (preg_match('#^(?:((?:"?\w+"?\.)?"?\w+"?)\.("?\w+"?)|((?:min|max|trim|rtrim|ltrim|substring|replace)\(((?:"?\w+"?\.)"?\w+"?)\.("?\w+"?).*?\)))(?:\s+as\s+("?\w+"?))?$#iD', $selection, $match)) {
  435. $table = $match[1] . ((isset($match[4])) ? $match[4] : '');
  436. $column = $match[2] . ((isset($match[5])) ? $match[5] : '');;
  437. // Unquote identifiers
  438. $table = str_replace('"', '', $table);
  439. $column = str_replace('"', '', $column);
  440. $table = $table_aliases[$table];
  441. if (empty($national_columns[$table]) || !in_array($column, $national_columns[$table])) {
  442. continue;
  443. }
  444. if (!isset($to_fix[$table])) {
  445. $to_fix[$table] = array();
  446. }
  447. // Handle column aliasing
  448. if (!empty($match[6])) {
  449. $column = array('column' => $column, 'alias' => str_replace('"', '', $match[6]));
  450. }
  451. if (!empty($match[3])) {
  452. if (!is_array($column)) {
  453. $column = array('column' => $column);
  454. }
  455. $column['expression'] = $match[3];
  456. }
  457. $to_fix[$table] = array_merge($to_fix[$table], array($column));
  458. // Match unqualified column names
  459. } elseif (preg_match('#^(?:("?\w+"?)|((?:min|max|trim|rtrim|ltrim|substring|replace)\(("?\w+"?).*?\)))(?:\s+as\s+("?\w+"?))?$#iD', $selection, $match)) {
  460. $column = $match[1] . ((isset($match[3])) ? $match[3] : '');
  461. // Unquote the identifiers
  462. $column = str_replace('"', '', $column);
  463. foreach ($table_aliases as $alias => $table) {
  464. if (empty($national_columns[$table])) {
  465. continue;
  466. }
  467. if (!in_array($column, $national_columns[$table])) {
  468. continue;
  469. }
  470. if (!isset($to_fix[$table])) {
  471. $to_fix[$table] = array();
  472. }
  473. // Handle column aliasing
  474. if (!empty($match[4])) {
  475. $column = array('column' => $column, 'alias' => str_replace('"', '', $match[4]));
  476. }
  477. if (!empty($match[2])) {
  478. if (!is_array($column)) {
  479. $column = array('column' => $column);
  480. }
  481. $column['expression'] = $match[2];
  482. }
  483. $to_fix[$table] = array_merge($to_fix[$table], array($column));
  484. }
  485. }
  486. }
  487. $reverse_table_aliases = array_flip($table_aliases);
  488. foreach ($to_fix as $table => $columns) {
  489. $columns = array_unique($columns);
  490. $alias = $reverse_table_aliases[$table];
  491. foreach ($columns as $column) {
  492. if (is_array($column)) {
  493. if (isset($column['alias'])) {
  494. $as = ' AS fmssqln__' . $column['alias'];
  495. } else {
  496. $as = ' AS fmssqln__' . $column['column'];
  497. }
  498. if (isset($column['expression'])) {
  499. $expression = $column['expression'];
  500. } else {
  501. $expression = '"' . $alias . '"."' . $column['column'] . '"';
  502. }
  503. $column = $column['column'];
  504. } else {
  505. $as = ' AS fmssqln__' . $column;
  506. $expression = '"' . $alias . '"."' . $column . '"';
  507. }
  508. if ($national_types[$table][$column] == 'ntext') {
  509. $cast = 'CAST(' . $expression . ' AS IMAGE)';
  510. } else {
  511. $cast = 'CAST(' . $expression . ' AS VARBINARY(MAX))';
  512. }
  513. $additions[] = $cast . $as;
  514. }
  515. }
  516. foreach ($sub_selects as $i => $sub_select) {
  517. $sql = preg_replace(
  518. '#:sub_select_' . $i . '\b#',
  519. strtr(
  520. $this->fixMSSQLNationalColumns($sub_select),
  521. array('\\' => '\\\\', '$' => '\\$')
  522. ),
  523. $sql,
  524. 1
  525. );
  526. }
  527. $replace = preg_replace(
  528. '#\bselect\s+' . preg_quote($select_clause, '#') . '#i',
  529. 'SELECT ' . strtr(
  530. join(', ', array_merge($selections, $additions)),
  531. array('\\' => '\\\\', '$' => '\\$')
  532. ),
  533. $select
  534. );
  535. $sql = str_replace($select, $replace, $sql);
  536. }
  537. return $sql;
  538. }
  539. /**
  540. * Fixes empty string comparisons in Oracle
  541. *
  542. * @param string $sql The SQL to fix
  543. * @return string The fixed SQL
  544. */
  545. private function fixOracleEmptyStrings($sql)
  546. {
  547. if (preg_match('#^(UPDATE\s+(?:(?:"?\w+"?\.)?"?\w+"?\.)?"?\w+"?\s+)(SET((?:(?:(?!\bwhere\b|\breturning\b)[^()])+|\(((?:[^()]+|\((?3)\))*)\))*))(.*)$#i', $sql, $set_match)) {
  548. $sql = $set_match[1] . ':set_clause ' . $set_match[5];
  549. $set_clause = $set_match[2];
  550. } else {
  551. $set_clause = FALSE;
  552. }
  553. $sql = preg_replace('#(?<=[\sa-z"])=\s*\'\'(?=[^\']|$)#', 'IS NULL', $sql);
  554. $sql = preg_replace('#(?<=[\sa-z"])(!=|<>)\s*\'\'(?=[^\']|$)#', 'IS NOT NULL', $sql);
  555. if ($set_clause) {
  556. $sql = preg_replace('#:set_clause\b#', strtr($set_clause, array('\\' => '\\\\', '$' => '\\$')), $sql, 1);
  557. }
  558. return $sql;
  559. }
  560. /**
  561. * Creates a unique cache prefix to help prevent cache conflicts
  562. *
  563. * @return string The cache prefix to use
  564. */
  565. private function makeCachePrefix()
  566. {
  567. if (!$this->cache_prefix) {
  568. $prefix = 'fSQLTranslation::' . $this->database->getType() . '::';
  569. if ($this->database->getHost()) {
  570. $prefix .= $this->database->getHost() . '::';
  571. }
  572. if ($this->database->getPort()) {
  573. $prefix .= $this->database->getPort() . '::';
  574. }
  575. $prefix .= $this->database->getDatabase() . '::';
  576. if ($this->database->getUsername()) {
  577. $prefix .= $this->database->getUsername() . '::';
  578. }
  579. $this->cache_prefix = $prefix;
  580. }
  581. return $this->cache_prefix;
  582. }
  583. /**
  584. * Translates Flourish SQL into the dialect for the current database
  585. *
  586. * @internal
  587. *
  588. * @param array $statements The SQL statements to translate
  589. * @param array &$rollback_statements SQL statements to rollback the returned SQL statements if something goes wrong - only applicable for MySQL `ALTER TABLE` statements
  590. * @return array The translated SQL statements all ready for execution. Statements that have been translated will have string key of the number, `:` and the original SQL, all other will have a numeric key.
  591. */
  592. public function translate($statements, &$rollback_statements=NULL)
  593. {
  594. $output = array();
  595. foreach ($statements as $number => $sql) {
  596. $new_sql = $this->translateBasicSyntax($sql);
  597. $new_sql = $this->translateCastClauses($new_sql);
  598. if (in_array($this->database->getType(), array('mssql', 'oracle', 'db2'))) {
  599. $new_sql = $this->translateLimitOffsetToRowNumber($new_sql);
  600. }
  601. // SQL Server does not like to give unicode results back to PHP without some coersion
  602. if ($this->database->getType() == 'mssql') {
  603. $new_sql = $this->fixMSSQLNationalColumns($new_sql);
  604. }
  605. if ($this->database->getType() == 'oracle') {
  606. // Oracle has this nasty habit of silently translating empty strings to null
  607. $new_sql = $this->fixOracleEmptyStrings($new_sql);
  608. $new_sql = $this->uppercaseIdentifiers($new_sql);
  609. }
  610. if ($this->database->getType() == 'db2') {
  611. $new_sql = $this->uppercaseIdentifiers($new_sql);
  612. }
  613. $extra_statements = array();
  614. if (preg_match('#^\s*(CREATE|DROP|ALTER|COMMENT)\b#i', $new_sql)) {
  615. if (!isset($this->schema_translation)) {
  616. $this->schema_translation = new fSQLSchemaTranslation($this->database);
  617. }
  618. list($new_sql, $extra_statements) = $this->schema_translation->translate($new_sql, $rollback_statements);
  619. }
  620. if ($sql != $new_sql || $extra_statements) {
  621. fCore::debug(
  622. self::compose(
  623. "Original SQL:%s",
  624. "\n" . $sql
  625. ),
  626. $this->debug
  627. );
  628. $translated_sql = $new_sql;
  629. if ($extra_statements) {
  630. $translated_sql .= '; ' . join('; ', $extra_statements);
  631. }
  632. fCore::debug(
  633. self::compose(
  634. "Translated SQL:%s",
  635. "\n" . $translated_sql
  636. ),
  637. $this->debug
  638. );
  639. }
  640. $output = array_merge($output, array($number . ':' . $sql => $new_sql), array_values($extra_statements));
  641. }
  642. return $output;
  643. }
  644. /**
  645. * Translates basic syntax differences of the current database
  646. *
  647. * @param string $sql The SQL to translate
  648. * @return string The translated SQL
  649. */
  650. private function translateBasicSyntax($sql)
  651. {
  652. if ($this->database->getType() == 'db2') {
  653. $regex = array(
  654. '#\brandom\(#i' => 'RAND(',
  655. '#\bceil\(#i' => 'CEILING(',
  656. '#\btrue\b#i' => "'1'",
  657. '#\bfalse\b#i' => "'0'",
  658. '#\bpi\(\)#i' => '3.14159265358979',
  659. '#\bcot\(\s*((?>[^()\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s*\)#i' => '(1/TAN(\1))',
  660. '#\blength\(\s*((?>[^()\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s*\)#i' => 'CHARACTER_LENGTH(\1, CODEUNITS32)',
  661. '#\bsubstr\(\s*((?>[^(),]+|\((?1)(?:,(?1))?\)|\(\))+)\s*,\s*((?>[^(),]+|\((?2)(?:,(?2))?\)|\(\))+)\s*,\s*((?>[^(),]+|\((?3)(?:,(?3))?\)|\(\))+)\s*\)#i' => 'SUBSTRING(\1, \2, \3, CODEUNITS32)',
  662. '#(?:\b|^)((?>[^()%\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s*%(?!\d+\$[lbdfristp]\b)\s*((?>[^()\s]+|\(((?:[^()]+|\((?4)\))*)\))+)(?:\b|$)#i' => 'MOD(\1, \3)',
  663. '#(?<!["\w.])((?>[^()\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s+(NOT\s+)?LIKE\s+((?>[^()\s]+|\(((?:[^()]+|\((?4)\))*)\))+)(?:\b|$)#i' => 'LOWER(\1) \3LIKE LOWER(\4)',
  664. '#\blog\(\s*((?>[^(),]+|\((?1)(?:,(?1))?\)|\(\))+)\s*,\s*((?>[^(),]+|\((?2)(?:,(?2))?\)|\(\))+)\s*\)#i' => '(LN(\2)/LN(\1))'
  665. );
  666. } elseif ($this->database->getType() == 'mssql') {
  667. $regex = array(
  668. '#\bbegin\s*(?!tran)#i' => 'BEGIN TRANSACTION ',
  669. '#\brandom\(#i' => 'RAND(',
  670. '#\batan2\(#i' => 'ATN2(',
  671. '#\bceil\(#i' => 'CEILING(',
  672. '#\bln\(#i' => 'LOG(',
  673. '#\blength\(#i' => 'LEN(',
  674. '#\bsubstr\(#i' => 'SUBSTRING(',
  675. '#\btrue\b#i' => "'1'",
  676. '#\bfalse\b#i' => "'0'",
  677. '#\|\|#i' => '+',
  678. '#\btrim\(\s*((?>[^(),]+|\((?1)\)|\(\))+)\s*\)#i' => 'RTRIM(LTRIM(\1))',
  679. '#\bround\(\s*((?>[^(),]+|\((?1)\)|\(\))+)\s*\)#i' => 'round(\1, 0)',
  680. '#\blog\(\s*((?>[^(),]+|\((?1)(?:,(?1))?\)|\(\))+)\s*,\s*((?>[^(),]+|\((?2)(?:,(?2))?\)|\(\))+)\s*\)#i' => '(LOG(\2)/LOG(\1))'
  681. );
  682. } elseif ($this->database->getType() == 'mysql') {
  683. $regex = array(
  684. '#\brandom\(#i' => 'rand(',
  685. '#\bpi\(\)#i' => '(pi()+0.0000000000000)',
  686. '#\blength\(#i' => 'CHAR_LENGTH(',
  687. );
  688. } elseif ($this->database->getType() == 'oracle') {
  689. $regex = array(
  690. '#\btrue\b#i' => '1',
  691. '#\bfalse\b#i' => '0',
  692. '#\bceiling\(#i' => 'CEIL(',
  693. '#\brandom\(\)#i' => '(ABS(DBMS_RANDOM.RANDOM)/2147483647)',
  694. '#\bpi\(\)#i' => '3.14159265358979',
  695. '#\bcot\(\s*((?>[^()\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s*\)#i' => '(1/TAN(\1))',
  696. '#\bdegrees\(\s*((?>[^()\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s*\)#i' => '(\1 * 57.295779513083)',
  697. '#\bradians\(\s*((?>[^()\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s*\)#i' => '(\1 * 0.017453292519943)',
  698. '#(?:\b|^)((?>[^()%\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s*%(?!\d+\$[lbdfristp]\b)\s*((?>[^()\s]+|\(((?:[^()]+|\((?4)\))*)\))+)(?:\b|$)#i' => 'MOD(\1, \3)',
  699. '#(?<!["\w.])((?>[^()\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s+(NOT\s+)?LIKE\s+((?>[^()\s]+|\(((?:[^()]+|\((?4)\))*)\))+)(?:\b|$)#i' => 'LOWER(\1) \3LIKE LOWER(\4)'
  700. );
  701. } elseif ($this->database->getType() == 'postgresql') {
  702. $regex = array(
  703. '#(?<!["\w.])(["\w.]+)\s+(not\s+)?like\b#i' => 'CAST(\1 AS VARCHAR) \2ILIKE',
  704. '#\blower\(\s*(?<!["\w.])(["\w.]+)\s*\)#i' => 'LOWER(CAST(\1 AS VARCHAR))',
  705. '#\blike\b#i' => 'ILIKE',
  706. '#\b(INSERT\s+INTO\s+(?:\w+|"[^"]+")\s+)\(\s*\)\s+VALUES\s+\(\s*\)#i' => '\1DEFAULT VALUES'
  707. );
  708. } elseif ($this->database->getType() == 'sqlite') {
  709. if (version_compare($this->database->getVersion(), 3, '>=')) {
  710. $regex = array(
  711. '#\bcurrent_timestamp\b#i' => "datetime(CURRENT_TIMESTAMP, 'localtime')",
  712. '#\btrue\b#i' => "'1'",
  713. '#\bfalse\b#i' => "'0'",
  714. '#\brandom\(\)#i' => '(ABS(RANDOM())/9223372036854775807)'
  715. );
  716. } else {
  717. $regex = array(
  718. '#\bcurrent_timestamp\b#i' => "CURRENT_TIMESTAMP()",
  719. '#\bcurrent_time\b#i' => "CURRENT_TIME()",
  720. '#\bcurrent_date\b#i' => "CURRENT_DATE()",
  721. '#\btrue\b#i' => "'1'",
  722. '#\bfalse\b#i' => "'0'",
  723. '#\brandom\(\)#i' => '(ABS(RANDOM())/9223372036854775807)',
  724. // SQLite 2 doesn't support CAST, but is also type-less, so we remove it
  725. '#\bcast\(\s*((?:[^()\s]+|\(((?:[^()]+|\((?2)\))*)\))+)\s+as\s+(?:[^()\s]+|\(((?:[^()]+|\((?3)\))*)\))+\s*\)#i' => '\1'
  726. );
  727. }
  728. }
  729. return preg_replace(array_keys($regex), array_values($regex), $sql);
  730. }
  731. /**
  732. * Translates simple CAST() clauses
  733. *
  734. * @param string $sql The SQL to translate
  735. * @return string The translated SQL
  736. */
  737. private function translateCastClauses($sql)
  738. {
  739. if (!preg_match_all('#\b(CAST\(.+?\s+AS\s+)(\w+(\([^)]+\))?\))#i', $sql, $matches, PREG_SET_ORDER)) {
  740. return $sql;
  741. }
  742. foreach ($matches as $match) {
  743. switch ($this->database->getType()) {
  744. case 'db2':
  745. $regex = array(
  746. '#\btext\b#i' => 'CLOB(1 G)',
  747. '#\bblob\b(?!\()#i' => 'BLOB(2 G)'
  748. );
  749. break;
  750. case 'mssql':
  751. $regex = array(
  752. '#\bblob\b#i' => 'IMAGE',
  753. '#\btimestamp\b#i' => 'DATETIME',
  754. '#\btime\b#i' => 'DATETIME',
  755. '#\bdate\b#i' => 'DATETIME',
  756. '#\bboolean\b#i' => 'BIT',
  757. '#\bvarchar\b#i' => 'NVARCHAR',
  758. '#\bchar\b#i' => 'NCHAR',
  759. '#\btext\b#i' => 'NTEXT'
  760. );
  761. break;
  762. case 'mysql':
  763. $regex = array(
  764. '#\btext\b#i' => 'LONGTEXT',
  765. '#\bblob\b#i' => 'LONGBLOB',
  766. '#\btimestamp\b#i' => 'DATETIME'
  767. );
  768. break;
  769. case 'oracle':
  770. $regex = array(
  771. '#\bbigint\b#i' => 'INTEGER',
  772. '#\bboolean\b#i' => 'NUMBER(1)',
  773. '#\btext\b#i' => 'CLOB',
  774. '#\bvarchar\b#i' => 'VARCHAR2',
  775. '#\btime\b#i' => 'TIMESTAMP'
  776. );
  777. break;
  778. case 'postgresql':
  779. $regex = array(
  780. '#\bblob\b#i' => 'BYTEA'
  781. );
  782. break;
  783. case 'sqlite':
  784. $regex = array(
  785. );
  786. break;
  787. }
  788. $sql = str_replace(
  789. $match[0],
  790. $match[1] . preg_replace(array_keys($regex), array_values($regex), $match[2]),
  791. $sql
  792. );
  793. }
  794. return $sql;
  795. }
  796. /**
  797. * Translates `LIMIT x OFFSET x` to `ROW_NUMBER() OVER (ORDER BY)` syntax
  798. *
  799. * @param string $sql The SQL to translate
  800. * @return string The translated SQL
  801. */
  802. private function translateLimitOffsetToRowNumber($sql)
  803. {
  804. if (!preg_match('#\sLIMIT\s#i', $sql)) {
  805. return $sql;
  806. }
  807. // Regex details:
  808. // 1 - The SELECT clause
  809. // 2 - () recursion handler
  810. // 3 - FROM clause
  811. // 4 - () recursion handler
  812. // 5 - ORDER BY clause
  813. // 6 - () recursion handler
  814. // 7 - LIMIT number
  815. // 8 - OFFSET number
  816. preg_match_all(
  817. '#select((?:(?:(?!\sfrom\s)[^()])+|\(((?:[^()]+|\((?2)\))*)\))*\s)(from(?:(?:(?!\slimit\s|\sorder by\s)[^()])+|\(((?:[^()]+|\((?4)\))*)\))*\s)(order by(?:(?:(?!\slimit\s)[^()])+|\(((?:[^()]+|\((?6)\))*)\))*\s)?limit\s+(\d+)(?:\s+offset\s+(\d+))?#i',
  818. $sql,
  819. $matches,
  820. PREG_SET_ORDER
  821. );
  822. foreach ($matches as $match) {
  823. if ($this->database->getType() == 'mssql') {
  824. // This means we have an offset clause
  825. if (!empty($match[8])) {
  826. if ($match[5] === '') {
  827. $match[5] = "ORDER BY rand(1) ASC";
  828. }
  829. $select = $match[1] . ', ROW_NUMBER() OVER (';
  830. $select .= $match[5];
  831. $select .= ') AS flourish__row__num ';
  832. $select .= $match[3];
  833. $replacement = 'SELECT * FROM (SELECT ' . trim($match[1]) . ', ROW_NUMBER() OVER (' . $match[5] . ') AS flourish__row__num ' . $match[3] . ') AS original_query WHERE flourish__row__num > ' . $match[8] . ' AND flourish__row__num <= ' . ($match[7] + $match[8]) . ' ORDER BY flourish__row__num';
  834. // Otherwise we just have a limit
  835. } else {
  836. $replacement = 'SELECT TOP ' . $match[7] . ' ' . trim($match[1] . $match[3] . $match[5]);
  837. }
  838. // While Oracle has the row_number() construct, the rownum pseudo-column is better
  839. } elseif ($this->database->getType() == 'oracle') {
  840. // This means we have an offset clause
  841. if (!empty($match[8])) {
  842. $replacement = 'SELECT * FROM (SELECT flourish__sq.*, rownum flourish__row__num FROM (SELECT' . $match[1] . $match[3] . $match[5] . ') flourish__sq WHERE rownum <= ' . ($match[7] + $match[8]) . ') WHERE flourish__row__num > ' . $match[8];
  843. // Otherwise we just have a limit
  844. } else {
  845. $replacement = 'SELECT * FROM (SELECT' . $match[1] . $match[3] . $match[5] . ') WHERE rownum <= ' . $match[7];
  846. }
  847. } elseif ($this->database->getType() == 'db2') {
  848. // This means we have an offset clause
  849. if (!empty($match[8])) {
  850. if ($match[5] === '') {
  851. $match[5] = "ORDER BY rand(1) ASC";
  852. }
  853. $select = $match[1] . ', ROW_NUMBER() OVER (';
  854. $select .= $match[5];
  855. $select .= ') AS flourish__row__num ';
  856. $select .= $match[3];
  857. $replacement = 'SELECT * FROM (SELECT ' . trim($match[1]) . ', ROW_NUMBER() OVER (' . $match[5] . ') AS flourish__row__num ' . $match[3] . ') AS original_query WHERE flourish__row__num > ' . $match[8] . ' AND flourish__row__num <= ' . ($match[7] + $match[8]) . ' ORDER BY flourish__row__num';
  858. // Otherwise we just have a limit
  859. } else {
  860. $replacement = 'SELECT ' . trim($match[1] . $match[3] . $match[5]) . ' FETCH FIRST ' . $match[7] . ' ROWS ONLY';
  861. }
  862. }
  863. $sql = str_replace($match[0], $replacement, $sql);
  864. }
  865. return $sql;
  866. }
  867. /**
  868. * Oracle and DB2 require that all double-quoted identifiers be upper-case
  869. *
  870. * @param string $sql The SQL to fix
  871. * @return string The fixed SQL
  872. */
  873. private function uppercaseIdentifiers($sql)
  874. {
  875. preg_match_all('#"[^"]+"#i', $sql, $matches, PREG_SET_ORDER);
  876. foreach ($matches as $match) {
  877. $sql = str_replace($match[0], strtoupper($match[0]), $sql);
  878. }
  879. return $sql;
  880. }
  881. }
  882. /**
  883. * Copyright (c) 2007-2011 Will Bond <will@flourishlib.com>
  884. *
  885. * Permission is hereby granted, free of charge, to any person obtaining a copy
  886. * of this software and associated documentation files (the "Software"), to deal
  887. * in the Software without restriction, including without limitation the rights
  888. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  889. * copies of the Software, and to permit persons to whom the Software is
  890. * furnished to do so, subject to the following conditions:
  891. *
  892. * The above copyright notice and this permission notice shall be included in
  893. * all copies or substantial portions of the Software.
  894. *
  895. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  896. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  897. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  898. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  899. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  900. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  901. * THE SOFTWARE.
  902. */