CommonTableExpressionQueryTest.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
  5. * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  6. *
  7. * Licensed under The MIT License
  8. * For full copyright and license information, please see the LICENSE.txt
  9. * Redistributions of files must retain the above copyright notice.
  10. *
  11. * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  12. * @link https://cakephp.org CakePHP(tm) Project
  13. * @since 4.1.0
  14. * @license https://opensource.org/licenses/mit-license.php MIT License
  15. */
  16. namespace Cake\Test\TestCase\Database\QueryTests;
  17. use Cake\Database\Driver\Mysql;
  18. use Cake\Database\Driver\Sqlite;
  19. use Cake\Database\Driver\Sqlserver;
  20. use Cake\Database\DriverInterface;
  21. use Cake\Database\Expression\CommonTableExpression;
  22. use Cake\Database\Expression\QueryExpression;
  23. use Cake\Database\Query;
  24. use Cake\Database\ValueBinder;
  25. use Cake\Datasource\ConnectionManager;
  26. use Cake\TestSuite\TestCase;
  27. class CommonTableExpressionQueryTest extends TestCase
  28. {
  29. /**
  30. * @inheritDoc
  31. */
  32. protected $fixtures = [
  33. 'core.Articles',
  34. ];
  35. /**
  36. * @var \Cake\Database\Connection
  37. */
  38. protected $connection;
  39. /**
  40. * @var bool
  41. */
  42. protected $autoQuote;
  43. public function setUp(): void
  44. {
  45. parent::setUp();
  46. $this->connection = ConnectionManager::get('test');
  47. $this->autoQuote = $this->connection->getDriver()->isAutoQuotingEnabled();
  48. $this->skipIf(
  49. !$this->connection->getDriver()->supports(DriverInterface::FEATURE_CTE),
  50. 'The current driver does not support common table expressions.'
  51. );
  52. }
  53. public function tearDown(): void
  54. {
  55. parent::tearDown();
  56. unset($this->connection);
  57. }
  58. /**
  59. * Tests with() sql generation.
  60. */
  61. public function testWithCte(): void
  62. {
  63. $query = $this->connection->newQuery()
  64. ->with(new CommonTableExpression('cte', function () {
  65. return $this->connection->newQuery()->select(['col' => 1]);
  66. }))
  67. ->select('col')
  68. ->from('cte');
  69. $this->assertRegExpSql(
  70. 'WITH <cte> AS \(SELECT 1 AS <col>\) SELECT <col> FROM <cte>',
  71. $query->sql(new ValueBinder()),
  72. !$this->autoQuote
  73. );
  74. $expected = [
  75. [
  76. 'col' => '1',
  77. ],
  78. ];
  79. $result = $query->execute();
  80. $this->assertEquals($expected, $result->fetchAll('assoc'));
  81. $result->closeCursor();
  82. }
  83. /**
  84. * Tests calling with() with overwrite clears other CTEs.
  85. */
  86. public function testWithCteOverwrite(): void
  87. {
  88. $query = $this->connection->newQuery()
  89. ->with(new CommonTableExpression('cte', function () {
  90. return $this->connection->newQuery()->select(['col' => '1']);
  91. }))
  92. ->select('col')
  93. ->from('cte');
  94. $this->assertEqualsSql(
  95. 'WITH cte AS (SELECT 1 AS col) SELECT col FROM cte',
  96. $query->sql(new ValueBinder())
  97. );
  98. $query
  99. ->with(new CommonTableExpression('cte2', $this->connection->newQuery()), true)
  100. ->from('cte2', true);
  101. $this->assertEqualsSql(
  102. 'WITH cte2 AS () SELECT col FROM cte2',
  103. $query->sql(new ValueBinder())
  104. );
  105. }
  106. /**
  107. * Tests recursive CTE.
  108. */
  109. public function testWithRecursiveCte(): void
  110. {
  111. $query = $this->connection->newQuery()
  112. ->with(function (CommonTableExpression $cte, Query $query) {
  113. $anchorQuery = $query->select(1);
  114. $recursiveQuery = $query->getConnection()
  115. ->newQuery()
  116. ->select(function (Query $query) {
  117. return $query->newExpr('col + 1');
  118. })
  119. ->from('cte')
  120. ->where(['col !=' => 3], ['col' => 'integer']);
  121. $cteQuery = $anchorQuery->unionAll($recursiveQuery);
  122. return $cte
  123. ->name('cte')
  124. ->field(['col'])
  125. ->query($cteQuery)
  126. ->recursive();
  127. })
  128. ->select('col')
  129. ->from('cte');
  130. if ($this->connection->getDriver() instanceof Sqlserver) {
  131. $expectedSql =
  132. 'WITH cte(col) AS ' .
  133. "(SELECT 1\nUNION ALL SELECT (col + 1) FROM cte WHERE col != :c0) " .
  134. 'SELECT col FROM cte';
  135. } elseif ($this->connection->getDriver() instanceof Sqlite) {
  136. $expectedSql =
  137. 'WITH RECURSIVE cte(col) AS ' .
  138. "(SELECT 1\nUNION ALL SELECT (col + 1) FROM cte WHERE col != :c0) " .
  139. 'SELECT col FROM cte';
  140. } else {
  141. $expectedSql =
  142. 'WITH RECURSIVE cte(col) AS ' .
  143. "((SELECT 1)\nUNION ALL (SELECT (col + 1) FROM cte WHERE col != :c0)) " .
  144. 'SELECT col FROM cte';
  145. }
  146. $this->assertEqualsSql(
  147. $expectedSql,
  148. $query->sql(new ValueBinder())
  149. );
  150. $expected = [
  151. [
  152. 'col' => '1',
  153. ],
  154. [
  155. 'col' => '2',
  156. ],
  157. [
  158. 'col' => '3',
  159. ],
  160. ];
  161. $result = $query->execute();
  162. $this->assertEquals($expected, $result->fetchAll('assoc'));
  163. $result->closeCursor();
  164. }
  165. /**
  166. * Test inserting from CTE.
  167. */
  168. public function testWithInsertQuery(): void
  169. {
  170. $this->skipIf(
  171. ($this->connection->getDriver() instanceof Mysql),
  172. '`WITH ... INSERT INTO` syntax is not supported in MySQL.'
  173. );
  174. // test initial state
  175. $result = $this->connection->newQuery()
  176. ->select('*')
  177. ->from('articles')
  178. ->where(['id' => 4])
  179. ->execute();
  180. $this->assertFalse($result->fetch('assoc'));
  181. $result->closeCursor();
  182. $query = $this->connection
  183. ->newQuery()
  184. ->with(function (CommonTableExpression $cte, Query $query) {
  185. return $cte
  186. ->name('cte')
  187. ->field(['title', 'body'])
  188. ->query($query->newExpr("SELECT 'Fourth Article', 'Fourth Article Body'"));
  189. })
  190. ->insert(['title', 'body'])
  191. ->into('articles')
  192. ->values(
  193. $this->connection
  194. ->newQuery()
  195. ->select('*')
  196. ->from('cte')
  197. );
  198. $this->assertRegExpSql(
  199. "WITH <cte>\(<title>, <body>\) AS \(SELECT 'Fourth Article', 'Fourth Article Body'\) " .
  200. 'INSERT INTO <articles> \(<title>, <body>\)',
  201. $query->sql(new ValueBinder()),
  202. !$this->autoQuote
  203. );
  204. // run insert
  205. $query->execute()->closeCursor();
  206. $expected = [
  207. 'id' => '4',
  208. 'author_id' => null,
  209. 'title' => 'Fourth Article',
  210. 'body' => 'Fourth Article Body',
  211. 'published' => 'N',
  212. ];
  213. // test updated state
  214. $result = $this->connection->newQuery()
  215. ->select('*')
  216. ->from('articles')
  217. ->where(['id' => 4])
  218. ->execute();
  219. $this->assertEquals($expected, $result->fetch('assoc'));
  220. $result->closeCursor();
  221. }
  222. /**
  223. * Tests inserting from CTE as values list.
  224. */
  225. public function testWithInInsertWithValuesQuery(): void
  226. {
  227. $this->skipIf(
  228. ($this->connection->getDriver() instanceof Sqlserver),
  229. '`INSERT INTO ... WITH` syntax is not supported in SQL Server.'
  230. );
  231. $query = $this->connection->newQuery()
  232. ->insert(['title', 'body'])
  233. ->into('articles')
  234. ->values(
  235. $this->connection->newQuery()
  236. ->with(function (CommonTableExpression $cte, Query $query) {
  237. return $cte
  238. ->name('cte')
  239. ->field(['title', 'body'])
  240. ->query($query->newExpr("SELECT 'Fourth Article', 'Fourth Article Body'"));
  241. })
  242. ->select('*')
  243. ->from('cte')
  244. );
  245. $this->assertRegExpSql(
  246. 'INSERT INTO <articles> \(<title>, <body>\) ' .
  247. "WITH <cte>\(<title>, <body>\) AS \(SELECT 'Fourth Article', 'Fourth Article Body'\) SELECT \* FROM <cte>",
  248. $query->sql(new ValueBinder()),
  249. !$this->autoQuote
  250. );
  251. // run insert
  252. $query->execute()->closeCursor();
  253. $expected = [
  254. 'id' => '4',
  255. 'author_id' => null,
  256. 'title' => 'Fourth Article',
  257. 'body' => 'Fourth Article Body',
  258. 'published' => 'N',
  259. ];
  260. // test updated state
  261. $result = $this->connection->newQuery()
  262. ->select('*')
  263. ->from('articles')
  264. ->where(['id' => 4])
  265. ->execute();
  266. $this->assertEquals($expected, $result->fetch('assoc'));
  267. $result->closeCursor();
  268. }
  269. /**
  270. * Tests updating from CTE.
  271. */
  272. public function testWithInUpdateQuery(): void
  273. {
  274. $this->skipIf(
  275. $this->connection->getDriver() instanceof Mysql && $this->connection->getDriver()->isMariadb(),
  276. 'MariaDB does not support CTEs in UPDATE query.'
  277. );
  278. // test initial state
  279. $result = $this->connection->newQuery()
  280. ->select(['count' => 'COUNT(*)'])
  281. ->from('articles')
  282. ->where(['published' => 'Y'])
  283. ->execute();
  284. $this->assertEquals(['count' => '3'], $result->fetch('assoc'));
  285. $result->closeCursor();
  286. $query = $this->connection->newQuery()
  287. ->with(function (CommonTableExpression $cte, Query $query) {
  288. $cteQuery = $query
  289. ->select('articles.id')
  290. ->from('articles')
  291. ->where(['articles.id !=' => 1]);
  292. return $cte
  293. ->name('cte')
  294. ->query($cteQuery);
  295. })
  296. ->update('articles')
  297. ->set('published', 'N')
  298. ->where(function (QueryExpression $exp, Query $query) {
  299. return $exp->in(
  300. 'articles.id',
  301. $query
  302. ->getConnection()
  303. ->newQuery()
  304. ->select('cte.id')
  305. ->from('cte')
  306. );
  307. });
  308. $this->assertEqualsSql(
  309. 'WITH cte AS (SELECT articles.id FROM articles WHERE articles.id != :c0) ' .
  310. 'UPDATE articles SET published = :c1 WHERE id IN (SELECT cte.id FROM cte)',
  311. $query->sql(new ValueBinder())
  312. );
  313. // run update
  314. $query->execute()->closeCursor();
  315. // test updated state
  316. $result = $this->connection->newQuery()
  317. ->select(['count' => 'COUNT(*)'])
  318. ->from('articles')
  319. ->where(['published' => 'Y'])
  320. ->execute();
  321. $this->assertEquals(['count' => '1'], $result->fetch('assoc'));
  322. $result->closeCursor();
  323. }
  324. /**
  325. * Tests deleting from CTE.
  326. */
  327. public function testWithInDeleteQuery(): void
  328. {
  329. $this->skipIf(
  330. $this->connection->getDriver() instanceof Mysql && $this->connection->getDriver()->isMariadb(),
  331. 'MariaDB does not support CTEs in DELETE query.'
  332. );
  333. // test initial state
  334. $result = $this->connection
  335. ->newQuery()
  336. ->select(['count' => 'COUNT(*)'])
  337. ->from('articles')
  338. ->execute();
  339. $this->assertEquals(['count' => '3'], $result->fetch('assoc'));
  340. $result->closeCursor();
  341. $query = $this->connection->newQuery()
  342. ->with(function (CommonTableExpression $cte, Query $query) {
  343. $query->select('articles.id')
  344. ->from('articles')
  345. ->where(['articles.id !=' => 1]);
  346. return $cte
  347. ->name('cte')
  348. ->query($query);
  349. })
  350. ->delete()
  351. ->from(['a' => 'articles'])
  352. ->where(function (QueryExpression $exp, Query $query) {
  353. return $exp->in(
  354. 'a.id',
  355. $query
  356. ->getConnection()
  357. ->newQuery()
  358. ->select('cte.id')
  359. ->from('cte')
  360. );
  361. });
  362. $this->assertEqualsSql(
  363. 'WITH cte AS (SELECT articles.id FROM articles WHERE articles.id != :c0) ' .
  364. 'DELETE FROM articles WHERE id IN (SELECT cte.id FROM cte)',
  365. $query->sql(new ValueBinder())
  366. );
  367. // run delete
  368. $query->execute()->closeCursor();
  369. $expected = [
  370. 'id' => '1',
  371. 'author_id' => '1',
  372. 'title' => 'First Article',
  373. 'body' => 'First Article Body',
  374. 'published' => 'Y',
  375. ];
  376. // test updated state
  377. $result = $this->connection->newQuery()
  378. ->select('*')
  379. ->from('articles')
  380. ->execute();
  381. $this->assertEquals($expected, $result->fetch('assoc'));
  382. $result->closeCursor();
  383. }
  384. }