CommonTableExpressionQueryTest.php 13 KB

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