PaginatorTestTrait.php 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311
  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 3.9.0
  14. * @license https://www.opensource.org/licenses/mit-license.php MIT License
  15. */
  16. namespace Cake\Test\TestCase\Datasource\Paging;
  17. use Cake\Core\Configure;
  18. use Cake\Datasource\ConnectionManager;
  19. use Cake\Datasource\EntityInterface;
  20. use Cake\Datasource\Paging\Exception\PageOutOfBoundsException;
  21. use Cake\Datasource\Paging\NumericPaginator;
  22. use Cake\Datasource\RepositoryInterface;
  23. use Cake\ORM\Query\SelectQuery;
  24. use Cake\ORM\ResultSet;
  25. use PHPUnit\Framework\Attributes\DataProvider;
  26. use TestApp\Model\Table\PaginatorPostsTable;
  27. trait PaginatorTestTrait
  28. {
  29. /**
  30. * @var \Cake\Datasource\Paging\NumericPaginator
  31. */
  32. protected $Paginator;
  33. /**
  34. * @var \Cake\Datasource\RepositoryInterface|\PHPUnit\Framework\MockObject\MockObject
  35. */
  36. protected $Post;
  37. /**
  38. * setup
  39. */
  40. public function setUp(): void
  41. {
  42. parent::setUp();
  43. Configure::write('App.namespace', 'TestApp');
  44. $this->Paginator = new class extends NumericPaginator {
  45. public function getDefaults(string $alias, array $settings): array
  46. {
  47. return parent::getDefaults($alias, $settings);
  48. }
  49. public function mergeOptions(array $params, array $settings): array
  50. {
  51. return parent::mergeOptions($params, $settings);
  52. }
  53. public function validateSort(RepositoryInterface $object, array $options): array
  54. {
  55. return parent::validateSort($object, $options);
  56. }
  57. public function checkLimit(array $options): array
  58. {
  59. return parent::checkLimit($options);
  60. }
  61. };
  62. $this->Post = $this->getMockRepository();
  63. }
  64. /**
  65. * tearDown
  66. */
  67. public function tearDown(): void
  68. {
  69. parent::tearDown();
  70. $this->getTableLocator()->clear();
  71. }
  72. /**
  73. * Test that non-numeric values are rejected for page, and limit
  74. */
  75. public function testPageParamCasting(): void
  76. {
  77. $this->Post->expects($this->any())
  78. ->method('getAlias')
  79. ->willReturn('Posts');
  80. $query = $this->_getMockFindQuery();
  81. $this->Post->expects($this->any())
  82. ->method('find')
  83. ->willReturn($query);
  84. $params = ['page' => '1 " onclick="alert(\'xss\');">'];
  85. $settings = ['limit' => 1, 'maxLimit' => 10];
  86. $result = $this->Paginator->paginate($this->Post, $params, $settings);
  87. $pagingParams = $result->pagingParams();
  88. $this->assertSame(1, $pagingParams['currentPage'], 'XSS exploit opened');
  89. }
  90. /**
  91. * test that unknown keys in the default settings are **not**
  92. * passed to the find operations.
  93. */
  94. public function testPaginateExtraParams(): void
  95. {
  96. $params = ['page' => '-1'];
  97. $settings = [
  98. 'PaginatorPosts' => [
  99. 'maxLimit' => 10,
  100. 'order' => ['PaginatorPosts.id' => 'ASC'],
  101. ],
  102. ];
  103. $table = $this->_getMockPosts(['selectQuery']);
  104. $query = $this->_getMockFindQuery();
  105. $table->expects($this->once())
  106. ->method('selectQuery')
  107. ->willReturn($query);
  108. $query->expects($this->once())
  109. ->method('applyOptions')
  110. ->with([
  111. 'limit' => 10,
  112. 'order' => ['PaginatorPosts.id' => 'ASC'],
  113. 'page' => 1,
  114. ]);
  115. $this->Paginator->paginate($table, $params, $settings);
  116. }
  117. /**
  118. * Test to make sure options get sent to custom finder methods via paginate
  119. */
  120. public function testPaginateCustomFinderOptions(): void
  121. {
  122. $settings = [
  123. 'PaginatorPosts' => [
  124. 'finder' => ['author' => ['authorId' => 1]],
  125. ],
  126. ];
  127. $table = $this->getTableLocator()->get('PaginatorPosts');
  128. $expected = $table
  129. ->find('author', ...[
  130. 'conditions' => [
  131. 'PaginatorPosts.author_id' => 1,
  132. ],
  133. ])
  134. ->count();
  135. $result = $this->Paginator->paginate($table, [], $settings)->count();
  136. $this->assertEquals($expected, $result);
  137. }
  138. /**
  139. * Test that nested eager loaders don't trigger invalid SQL errors.
  140. */
  141. public function testPaginateNestedEagerLoader(): void
  142. {
  143. $articles = $this->getTableLocator()->get('Articles');
  144. $tags = $this->getTableLocator()->get('Tags');
  145. $tags->associations()->remove('Authors');
  146. $tags->belongsToMany('Authors');
  147. $articles->getEventManager()->on('Model.beforeFind', function ($event, $query): void {
  148. $query->matching('Tags', function ($q) {
  149. return $q->matching('Authors', function ($q) {
  150. return $q->where(['Authors.name' => 'larry']);
  151. });
  152. });
  153. });
  154. $results = $this->Paginator->paginate($articles);
  155. $result = $results->first();
  156. $this->assertInstanceOf(EntityInterface::class, $result);
  157. $this->assertInstanceOf(EntityInterface::class, $result->_matchingData['Tags']);
  158. $this->assertInstanceOf(EntityInterface::class, $result->_matchingData['Authors']);
  159. }
  160. /**
  161. * test that flat default pagination parameters work.
  162. */
  163. public function testDefaultPaginateParams(): void
  164. {
  165. $settings = [
  166. 'order' => ['PaginatorPosts.id' => 'DESC'],
  167. 'maxLimit' => 10,
  168. ];
  169. $table = $this->_getMockPosts(['selectQuery']);
  170. $query = $this->_getMockFindQuery();
  171. $table->expects($this->once())
  172. ->method('selectQuery')
  173. ->willReturn($query);
  174. $query->expects($this->once())
  175. ->method('applyOptions')
  176. ->with([
  177. 'limit' => 10,
  178. 'page' => 1,
  179. 'order' => ['PaginatorPosts.id' => 'DESC'],
  180. ]);
  181. $result = $this->Paginator->paginate($table, [], $settings)->pagingParams();
  182. $this->assertEquals('PaginatorPosts.id', $result['sort']);
  183. $this->assertEquals('DESC', $result['direction']);
  184. }
  185. /**
  186. * Tests that flat default pagination parameters work for multi order.
  187. */
  188. public function testDefaultPaginateParamsMultiOrder(): void
  189. {
  190. $settings = [
  191. 'order' => ['PaginatorPosts.id' => 'DESC', 'PaginatorPosts.title' => 'ASC'],
  192. ];
  193. $table = $this->_getMockPosts(['selectQuery']);
  194. $query = $this->_getMockFindQuery();
  195. $table->expects($this->once())
  196. ->method('selectQuery')
  197. ->willReturn($query);
  198. $query->expects($this->once())
  199. ->method('applyOptions')
  200. ->with([
  201. 'limit' => 20,
  202. 'page' => 1,
  203. 'order' => $settings['order'],
  204. ]);
  205. $result = $this->Paginator->paginate($table, [], $settings);
  206. $pagingParams = $result->pagingParams();
  207. $this->assertEquals('PaginatorPosts.id', $pagingParams['sortDefault']);
  208. $this->assertEquals('DESC', $pagingParams['directionDefault']);
  209. }
  210. /**
  211. * test that default sort and default direction are injected into request
  212. */
  213. public function testDefaultPaginateParamsIntoRequest(): void
  214. {
  215. $settings = [
  216. 'order' => ['PaginatorPosts.id' => 'DESC'],
  217. 'maxLimit' => 10,
  218. ];
  219. $table = $this->_getMockPosts(['selectQuery']);
  220. $query = $this->_getMockFindQuery();
  221. $table->expects($this->once())
  222. ->method('selectQuery')
  223. ->willReturn($query);
  224. $query->expects($this->once())
  225. ->method('applyOptions')
  226. ->with([
  227. 'limit' => 10,
  228. 'page' => 1,
  229. 'order' => ['PaginatorPosts.id' => 'DESC'],
  230. ]);
  231. $result = $this->Paginator->paginate($table, [], $settings);
  232. $pagingParams = $result->pagingParams();
  233. $this->assertEquals('PaginatorPosts.id', $pagingParams['sortDefault']);
  234. $this->assertEquals('DESC', $pagingParams['directionDefault']);
  235. }
  236. /**
  237. * test that option merging prefers specific models
  238. */
  239. public function testMergeOptionsModelSpecific(): void
  240. {
  241. $settings = [
  242. 'page' => 1,
  243. 'limit' => 20,
  244. 'maxLimit' => 100,
  245. 'Posts' => [
  246. 'page' => 1,
  247. 'limit' => 10,
  248. 'maxLimit' => 50,
  249. ],
  250. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  251. 'scope' => null,
  252. ];
  253. $defaults = $this->Paginator->getDefaults('Silly', $settings);
  254. $result = $this->Paginator->mergeOptions([], $defaults);
  255. $this->assertEquals($settings + [
  256. 'sortableFields' => null,
  257. 'finder' => 'all',
  258. ], $result);
  259. $defaults = $this->Paginator->getDefaults('Posts', $settings);
  260. $result = $this->Paginator->mergeOptions([], $defaults);
  261. $expected = [
  262. 'page' => 1,
  263. 'limit' => 10,
  264. 'maxLimit' => 50,
  265. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  266. 'sortableFields' => null,
  267. 'finder' => 'all',
  268. 'scope' => null,
  269. ];
  270. $this->assertEquals($expected, $result);
  271. }
  272. /**
  273. * test mergeOptions with custom scope
  274. */
  275. public function testMergeOptionsCustomScope(): void
  276. {
  277. $params = [
  278. 'page' => 10,
  279. 'limit' => 10,
  280. 'scope' => [
  281. 'page' => 2,
  282. 'limit' => 5,
  283. ],
  284. ];
  285. $settings = [
  286. 'page' => 1,
  287. 'limit' => 20,
  288. 'maxLimit' => 100,
  289. 'finder' => 'myCustomFind',
  290. ];
  291. $defaults = $this->Paginator->getDefaults('Post', $settings);
  292. $result = $this->Paginator->mergeOptions($params, $defaults);
  293. $expected = [
  294. 'page' => 10,
  295. 'limit' => 10,
  296. 'maxLimit' => 100,
  297. 'finder' => 'myCustomFind',
  298. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  299. 'sortableFields' => null,
  300. 'scope' => null,
  301. ];
  302. $this->assertEquals($expected, $result);
  303. $settings = [
  304. 'page' => 1,
  305. 'limit' => 20,
  306. 'maxLimit' => 100,
  307. 'finder' => 'myCustomFind',
  308. 'scope' => 'nonexistent',
  309. ];
  310. $defaults = $this->Paginator->getDefaults('Post', $settings);
  311. $result = $this->Paginator->mergeOptions($params, $defaults);
  312. $expected = [
  313. 'page' => 1,
  314. 'limit' => 20,
  315. 'maxLimit' => 100,
  316. 'finder' => 'myCustomFind',
  317. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  318. 'scope' => 'nonexistent',
  319. 'sortableFields' => null,
  320. ];
  321. $this->assertEquals($expected, $result);
  322. $settings = [
  323. 'page' => 1,
  324. 'limit' => 20,
  325. 'maxLimit' => 100,
  326. 'finder' => 'myCustomFind',
  327. 'scope' => 'scope',
  328. ];
  329. $defaults = $this->Paginator->getDefaults('Post', $settings);
  330. $result = $this->Paginator->mergeOptions($params, $defaults);
  331. $expected = [
  332. 'page' => 2,
  333. 'limit' => 5,
  334. 'maxLimit' => 100,
  335. 'finder' => 'myCustomFind',
  336. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  337. 'scope' => 'scope',
  338. 'sortableFields' => null,
  339. ];
  340. $this->assertEquals($expected, $result);
  341. }
  342. /**
  343. * test mergeOptions with customFind key
  344. */
  345. public function testMergeOptionsCustomFindKey(): void
  346. {
  347. $params = [
  348. 'page' => 10,
  349. 'limit' => 10,
  350. ];
  351. $settings = [
  352. 'page' => 1,
  353. 'limit' => 20,
  354. 'maxLimit' => 100,
  355. 'finder' => 'myCustomFind',
  356. ];
  357. $defaults = $this->Paginator->getDefaults('Post', $settings);
  358. $result = $this->Paginator->mergeOptions($params, $defaults);
  359. $expected = [
  360. 'page' => 10,
  361. 'limit' => 10,
  362. 'maxLimit' => 100,
  363. 'finder' => 'myCustomFind',
  364. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  365. 'sortableFields' => null,
  366. 'scope' => null,
  367. ];
  368. $this->assertEquals($expected, $result);
  369. }
  370. /**
  371. * test merging options from the querystring.
  372. */
  373. public function testMergeOptionsQueryString(): void
  374. {
  375. $params = [
  376. 'page' => 99,
  377. 'limit' => 75,
  378. ];
  379. $settings = [
  380. 'page' => 1,
  381. 'limit' => 20,
  382. 'maxLimit' => 100,
  383. ];
  384. $defaults = $this->Paginator->getDefaults('Post', $settings);
  385. $result = $this->Paginator->mergeOptions($params, $defaults);
  386. $expected = [
  387. 'page' => 99,
  388. 'limit' => 75,
  389. 'maxLimit' => 100,
  390. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  391. 'sortableFields' => null,
  392. 'finder' => 'all',
  393. 'scope' => null,
  394. ];
  395. $this->assertEquals($expected, $result);
  396. }
  397. /**
  398. * test that the default allowedParameters doesn't let people screw with things they should not be allowed to.
  399. */
  400. public function testMergeOptionsDefaultAllowedParameters(): void
  401. {
  402. $params = [
  403. 'page' => 10,
  404. 'limit' => 10,
  405. 'fields' => ['bad.stuff'],
  406. 'recursive' => 1000,
  407. 'conditions' => ['bad.stuff'],
  408. 'contain' => ['bad'],
  409. ];
  410. $settings = [
  411. 'page' => 1,
  412. 'limit' => 20,
  413. 'maxLimit' => 100,
  414. ];
  415. $defaults = $this->Paginator->getDefaults('Post', $settings);
  416. $result = $this->Paginator->mergeOptions($params, $defaults);
  417. $expected = [
  418. 'page' => 10,
  419. 'limit' => 10,
  420. 'maxLimit' => 100,
  421. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  422. 'sortableFields' => null,
  423. 'finder' => 'all',
  424. 'scope' => null,
  425. ];
  426. $this->assertEquals($expected, $result);
  427. }
  428. /**
  429. * test that modifying allowed parameters works.
  430. */
  431. public function testMergeOptionsExtraAllowedParameters(): void
  432. {
  433. $params = [
  434. 'page' => 10,
  435. 'limit' => 10,
  436. 'fields' => ['bad.stuff'],
  437. 'recursive' => 1000,
  438. 'conditions' => ['bad.stuff'],
  439. 'contain' => ['bad'],
  440. ];
  441. $settings = [
  442. 'page' => 1,
  443. 'limit' => 20,
  444. 'maxLimit' => 100,
  445. ];
  446. $this->Paginator->setConfig('allowedParameters', ['fields']);
  447. $defaults = $this->Paginator->getDefaults('Post', $settings);
  448. $result = $this->Paginator->mergeOptions($params, $defaults);
  449. $expected = [
  450. 'page' => 10,
  451. 'limit' => 10,
  452. 'maxLimit' => 100,
  453. 'fields' => ['bad.stuff'],
  454. 'allowedParameters' => ['limit', 'sort', 'page', 'direction', 'fields'],
  455. 'sortableFields' => null,
  456. 'finder' => 'all',
  457. 'scope' => null,
  458. ];
  459. $this->assertEquals($expected, $result);
  460. }
  461. /**
  462. * test mergeOptions with limit > maxLimit in code.
  463. */
  464. public function testMergeOptionsMaxLimit(): void
  465. {
  466. $settings = [
  467. 'limit' => 200,
  468. 'paramType' => 'named',
  469. ];
  470. $defaults = $this->Paginator->getDefaults('Post', $settings);
  471. $result = $this->Paginator->mergeOptions([], $defaults);
  472. $expected = [
  473. 'page' => 1,
  474. 'limit' => 100,
  475. 'maxLimit' => 100,
  476. 'paramType' => 'named',
  477. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  478. 'sortableFields' => null,
  479. 'finder' => 'all',
  480. 'scope' => null,
  481. ];
  482. $this->assertEquals($expected, $result);
  483. $settings = [
  484. 'maxLimit' => 10,
  485. 'paramType' => 'named',
  486. ];
  487. $defaults = $this->Paginator->getDefaults('Post', $settings);
  488. $result = $this->Paginator->mergeOptions([], $defaults);
  489. $expected = [
  490. 'page' => 1,
  491. 'limit' => 10,
  492. 'maxLimit' => 10,
  493. 'paramType' => 'named',
  494. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  495. 'sortableFields' => null,
  496. 'finder' => 'all',
  497. 'scope' => null,
  498. ];
  499. $this->assertEquals($expected, $result);
  500. }
  501. /**
  502. * test getDefaults with limit > maxLimit in code.
  503. */
  504. public function testGetDefaultMaxLimit(): void
  505. {
  506. $settings = [
  507. 'page' => 1,
  508. 'limit' => 2,
  509. 'maxLimit' => 10,
  510. 'order' => [
  511. 'Users.username' => 'asc',
  512. ],
  513. ];
  514. $defaults = $this->Paginator->getDefaults('Post', $settings);
  515. $result = $this->Paginator->mergeOptions([], $defaults);
  516. $expected = [
  517. 'page' => 1,
  518. 'limit' => 2,
  519. 'maxLimit' => 10,
  520. 'order' => [
  521. 'Users.username' => 'asc',
  522. ],
  523. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  524. 'sortableFields' => null,
  525. 'finder' => 'all',
  526. 'scope' => null,
  527. ];
  528. $this->assertEquals($expected, $result);
  529. $settings = [
  530. 'page' => 1,
  531. 'limit' => 100,
  532. 'maxLimit' => 10,
  533. 'order' => [
  534. 'Users.username' => 'asc',
  535. ],
  536. ];
  537. $defaults = $this->Paginator->getDefaults('Post', $settings);
  538. $result = $this->Paginator->mergeOptions([], $defaults);
  539. $expected = [
  540. 'page' => 1,
  541. 'limit' => 10,
  542. 'maxLimit' => 10,
  543. 'order' => [
  544. 'Users.username' => 'asc',
  545. ],
  546. 'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
  547. 'sortableFields' => null,
  548. 'finder' => 'all',
  549. 'scope' => null,
  550. ];
  551. $this->assertEquals($expected, $result);
  552. }
  553. /**
  554. * Integration test to ensure that validateSort is being used by paginate()
  555. */
  556. public function testValidateSortInvalid(): void
  557. {
  558. $table = $this->_getMockPosts(['selectQuery']);
  559. $query = $this->_getMockFindQuery();
  560. $table->expects($this->once())
  561. ->method('selectQuery')
  562. ->willReturn($query);
  563. $query->expects($this->once())->method('applyOptions')
  564. ->with([
  565. 'limit' => 20,
  566. 'page' => 1,
  567. 'order' => ['PaginatorPosts.id' => 'asc'],
  568. ]);
  569. $params = [
  570. 'page' => 1,
  571. 'sort' => 'id',
  572. 'direction' => 'herp',
  573. ];
  574. $result = $this->Paginator->paginate($table, $params);
  575. $pagingParams = $result->pagingParams();
  576. $this->assertEquals('id', $pagingParams['sort']);
  577. $this->assertEquals('asc', $pagingParams['direction']);
  578. }
  579. /**
  580. * test that invalid directions are ignored.
  581. */
  582. public function testValidateSortInvalidDirection(): void
  583. {
  584. $model = $this->getMockRepository();
  585. $model->expects($this->any())
  586. ->method('getAlias')
  587. ->willReturn('model');
  588. $model->expects($this->any())
  589. ->method('hasField')
  590. ->willReturn(true);
  591. $options = ['sort' => 'something', 'direction' => 'boogers'];
  592. $result = $this->Paginator->validateSort($model, $options);
  593. $this->assertEquals('asc', $result['order']['model.something']);
  594. }
  595. /**
  596. * Test that "sort" and "direction" in paging params is properly set based
  597. * on initial value of "order" in paging settings.
  598. */
  599. public function testValidaSortInitialSortAndDirection(): void
  600. {
  601. $table = $this->_getMockPosts(['selectQuery']);
  602. $query = $this->_getMockFindQuery();
  603. $table->expects($this->once())
  604. ->method('selectQuery')
  605. ->willReturn($query);
  606. $query->expects($this->once())->method('applyOptions')
  607. ->with([
  608. 'limit' => 20,
  609. 'page' => 1,
  610. 'order' => ['PaginatorPosts.id' => 'asc'],
  611. ]);
  612. $options = [
  613. 'order' => [
  614. 'id' => 'asc',
  615. ],
  616. 'sortableFields' => ['id'],
  617. ];
  618. $result = $this->Paginator->paginate($table, [], $options);
  619. $pagingParams = $result->pagingParams();
  620. $this->assertEquals('id', $pagingParams['sort']);
  621. $this->assertEquals('asc', $pagingParams['direction']);
  622. }
  623. /**
  624. * Test that "sort" and "direction" in paging params is properly set based
  625. * on initial value of "order" in paging settings.
  626. */
  627. public function testValidateSortAndDirectionAliased(): void
  628. {
  629. $table = $this->_getMockPosts(['selectQuery']);
  630. $query = $this->_getMockFindQuery();
  631. $table->expects($this->once())
  632. ->method('selectQuery')
  633. ->willReturn($query);
  634. $query->expects($this->once())->method('applyOptions')
  635. ->with([
  636. 'limit' => 20,
  637. 'page' => 1,
  638. 'order' => ['PaginatorPosts.title' => 'asc'],
  639. ]);
  640. $options = [
  641. 'order' => [
  642. 'Articles.title' => 'desc',
  643. ],
  644. ];
  645. $queryParams = [
  646. 'page' => 1,
  647. 'sort' => 'title',
  648. 'direction' => 'asc',
  649. ];
  650. $result = $this->Paginator->paginate($table, $queryParams, $options);
  651. $pagingParams = $result->pagingParams();
  652. $this->assertEquals('title', $pagingParams['sort']);
  653. $this->assertEquals('asc', $pagingParams['direction']);
  654. $this->assertEquals('Articles.title', $pagingParams['sortDefault']);
  655. $this->assertEquals('desc', $pagingParams['directionDefault']);
  656. }
  657. /**
  658. * testValidateSortRetainsOriginalSortValue
  659. *
  660. * @see https://github.com/cakephp/cakephp/issues/11740
  661. */
  662. public function testValidateSortRetainsOriginalSortValue(): void
  663. {
  664. $table = $this->_getMockPosts(['selectQuery']);
  665. $query = $this->_getMockFindQuery();
  666. $table->expects($this->once())
  667. ->method('selectQuery')
  668. ->willReturn($query);
  669. $query->expects($this->once())->method('applyOptions')
  670. ->with([
  671. 'limit' => 20,
  672. 'page' => 1,
  673. 'order' => ['PaginatorPosts.id' => 'asc'],
  674. ]);
  675. $params = [
  676. 'page' => 1,
  677. 'sort' => 'id',
  678. 'direction' => 'herp',
  679. ];
  680. $options = [
  681. 'sortableFields' => ['id'],
  682. ];
  683. $result = $this->Paginator->paginate($table, $params, $options);
  684. $pagingParams = $result->pagingParams();
  685. $this->assertEquals('id', $pagingParams['sort']);
  686. }
  687. /**
  688. * Test that a really large page number gets clamped to the max page size.
  689. */
  690. public function testOutOfRangePageNumberGetsClamped(): void
  691. {
  692. $params['page'] = 3000;
  693. $table = $this->getTableLocator()->get('PaginatorPosts');
  694. try {
  695. $this->Paginator->paginate($table, $params);
  696. $this->fail('No exception raised');
  697. } catch (PageOutOfBoundsException $exception) {
  698. $this->assertEquals(
  699. 'Page number `3000` could not be found.',
  700. $exception->getMessage()
  701. );
  702. $attributes = $exception->getAttributes();
  703. $this->assertSame(3000, $attributes['requestedPage']);
  704. $this->assertArrayHasKey('pagingParams', $attributes);
  705. }
  706. }
  707. /**
  708. * Test that a really REALLY large page number gets clamped to the max page size.
  709. */
  710. public function testOutOfVeryBigPageNumberGetsClamped(): void
  711. {
  712. $this->expectException(PageOutOfBoundsException::class);
  713. $params = [
  714. 'page' => '3000000000000000000',
  715. ];
  716. $table = $this->getTableLocator()->get('PaginatorPosts');
  717. $this->Paginator->paginate($table, $params);
  718. }
  719. /**
  720. * test that fields not in sortableFields won't be part of order conditions.
  721. */
  722. public function testValidateAllowedSortFailure(): void
  723. {
  724. $model = $this->mockAliasHasFieldModel();
  725. $options = [
  726. 'sort' => 'body',
  727. 'direction' => 'asc',
  728. 'sortableFields' => ['title', 'id'],
  729. ];
  730. $result = $this->Paginator->validateSort($model, $options);
  731. $this->assertEquals([], $result['order']);
  732. }
  733. /**
  734. * test that fields in the sortableFields are not validated
  735. */
  736. public function testValidateAllowedSortTrusted(): void
  737. {
  738. $model = $this->mockAliasHasFieldModel();
  739. $options = [
  740. 'sort' => 'body',
  741. 'direction' => 'asc',
  742. 'allowedsort' => ['body'],
  743. ];
  744. $result = $this->Paginator->validateSort($model, $options);
  745. $expected = ['model.body' => 'asc'];
  746. $this->assertEquals(
  747. $expected,
  748. $result['order'],
  749. 'Trusted fields in schema should be prefixed'
  750. );
  751. }
  752. /**
  753. * test that sortableFields as empty array does not allow any sorting
  754. */
  755. public function testValidateAllowedSortEmpty(): void
  756. {
  757. $model = $this->mockAliasHasFieldModel();
  758. $options = [
  759. 'order' => [
  760. 'body' => 'asc',
  761. 'foo.bar' => 'asc',
  762. ],
  763. 'sort' => 'body',
  764. 'direction' => 'asc',
  765. 'sortableFields' => [],
  766. ];
  767. $result = $this->Paginator->validateSort($model, $options);
  768. $this->assertSame([], $result['order'], 'No sort should be applied');
  769. }
  770. /**
  771. * test that fields in the sortableFields are not validated
  772. */
  773. public function testValidateAllowedSortNotInSchema(): void
  774. {
  775. $model = $this->getMockRepository();
  776. $model->expects($this->any())
  777. ->method('getAlias')
  778. ->willReturn('model');
  779. $model->expects($this->once())->method('hasField')
  780. ->willReturn(false);
  781. $options = [
  782. 'sort' => 'score',
  783. 'direction' => 'asc',
  784. 'sortableFields' => ['score'],
  785. ];
  786. $result = $this->Paginator->validateSort($model, $options);
  787. $expected = ['score' => 'asc'];
  788. $this->assertEquals(
  789. $expected,
  790. $result['order'],
  791. 'Trusted fields not in schema should not be altered'
  792. );
  793. }
  794. /**
  795. * test that multiple fields in the sortableFields are not validated and properly aliased.
  796. */
  797. public function testValidateAllowedSortMultiple(): void
  798. {
  799. $model = $this->mockAliasHasFieldModel();
  800. $options = [
  801. 'order' => [
  802. 'body' => 'asc',
  803. 'foo.bar' => 'asc',
  804. ],
  805. 'sortableFields' => ['body', 'foo.bar'],
  806. ];
  807. $result = $this->Paginator->validateSort($model, $options);
  808. $expected = [
  809. 'model.body' => 'asc',
  810. 'foo.bar' => 'asc',
  811. ];
  812. $this->assertEquals($expected, $result['order']);
  813. }
  814. /**
  815. * @return \Cake\Datasource\RepositoryInterface|\PHPUnit\Framework\MockObject\MockObject
  816. */
  817. protected function getMockRepository()
  818. {
  819. $model = $this->getMockBuilder(RepositoryInterface::class)
  820. ->getMock();
  821. return $model;
  822. }
  823. /**
  824. * @param string $modelAlias Model alias to use.
  825. * @return \Cake\Datasource\RepositoryInterface|\PHPUnit\Framework\MockObject\MockObject
  826. */
  827. protected function mockAliasHasFieldModel($modelAlias = 'model')
  828. {
  829. $model = $this->getMockRepository();
  830. $model->expects($this->any())
  831. ->method('getAlias')
  832. ->willReturn($modelAlias);
  833. $model->expects($this->any())
  834. ->method('hasField')
  835. ->willReturn(true);
  836. return $model;
  837. }
  838. /**
  839. * test that multiple sort works.
  840. */
  841. public function testValidateSortMultiple(): void
  842. {
  843. $model = $this->mockAliasHasFieldModel();
  844. $options = [
  845. 'order' => [
  846. 'author_id' => 'asc',
  847. 'title' => 'asc',
  848. ],
  849. ];
  850. $result = $this->Paginator->validateSort($model, $options);
  851. $expected = [
  852. 'model.author_id' => 'asc',
  853. 'model.title' => 'asc',
  854. ];
  855. $this->assertEquals($expected, $result['order']);
  856. }
  857. /**
  858. * test that multiple sort adds in query data.
  859. */
  860. public function testValidateSortMultipleWithQuery(): void
  861. {
  862. $model = $this->mockAliasHasFieldModel();
  863. $options = [
  864. 'sort' => 'created',
  865. 'direction' => 'desc',
  866. 'order' => [
  867. 'author_id' => 'asc',
  868. 'title' => 'asc',
  869. ],
  870. ];
  871. $result = $this->Paginator->validateSort($model, $options);
  872. $expected = [
  873. 'model.created' => 'desc',
  874. 'model.author_id' => 'asc',
  875. 'model.title' => 'asc',
  876. ];
  877. $this->assertEquals($expected, $result['order']);
  878. $options = [
  879. 'sort' => 'title',
  880. 'direction' => 'desc',
  881. 'order' => [
  882. 'author_id' => 'asc',
  883. 'title' => 'asc',
  884. ],
  885. ];
  886. $result = $this->Paginator->validateSort($model, $options);
  887. $expected = [
  888. 'model.title' => 'desc',
  889. 'model.author_id' => 'asc',
  890. ];
  891. $this->assertEquals($expected, $result['order']);
  892. }
  893. /**
  894. * Tests that sort query string and model prefixes default match on assoc merging.
  895. */
  896. public function testValidateSortMultipleWithQueryAndAliasedDefault(): void
  897. {
  898. $model = $this->mockAliasHasFieldModel();
  899. $options = [
  900. 'sort' => 'created',
  901. 'direction' => 'desc',
  902. 'order' => [
  903. 'model.created' => 'asc',
  904. ],
  905. ];
  906. $result = $this->Paginator->validateSort($model, $options);
  907. $expected = [
  908. 'sort' => 'created',
  909. 'order' => [
  910. 'model.created' => 'desc',
  911. ],
  912. ];
  913. $this->assertEquals($expected, $result);
  914. }
  915. /**
  916. * Tests that order strings can used by Paginator
  917. */
  918. public function testValidateSortWithString(): void
  919. {
  920. $model = $this->mockAliasHasFieldModel();
  921. $options = [
  922. 'order' => 'model.author_id DESC',
  923. ];
  924. $result = $this->Paginator->validateSort($model, $options);
  925. $expected = 'model.author_id DESC';
  926. $this->assertEquals($expected, $result['order']);
  927. }
  928. /**
  929. * Test that no sort doesn't trigger an error.
  930. */
  931. public function testValidateSortNoSort(): void
  932. {
  933. $model = $this->mockAliasHasFieldModel();
  934. $options = [
  935. 'direction' => 'asc',
  936. 'sortableFields' => ['title', 'id'],
  937. ];
  938. $result = $this->Paginator->validateSort($model, $options);
  939. $this->assertEquals([], $result['order']);
  940. }
  941. /**
  942. * Test sorting with incorrect aliases on valid fields.
  943. */
  944. public function testValidateSortInvalidAlias(): void
  945. {
  946. $model = $this->mockAliasHasFieldModel();
  947. $options = ['sort' => 'Derp.id'];
  948. $result = $this->Paginator->validateSort($model, $options);
  949. $this->assertEquals([], $result['order']);
  950. }
  951. /**
  952. * @return array
  953. */
  954. public static function checkLimitProvider(): array
  955. {
  956. return [
  957. 'out of bounds' => [
  958. ['limit' => 1000000, 'maxLimit' => 100],
  959. 100,
  960. ],
  961. 'limit is nan' => [
  962. ['limit' => 'sheep!', 'maxLimit' => 100],
  963. 1,
  964. ],
  965. 'negative limit' => [
  966. ['limit' => '-1', 'maxLimit' => 100],
  967. 1,
  968. ],
  969. 'unset limit' => [
  970. ['limit' => null, 'maxLimit' => 100],
  971. 1,
  972. ],
  973. 'limit = 0' => [
  974. ['limit' => 0, 'maxLimit' => 100],
  975. 1,
  976. ],
  977. 'limit = 0 v2' => [
  978. ['limit' => 0, 'maxLimit' => 0],
  979. 1,
  980. ],
  981. 'limit = null' => [
  982. ['limit' => null, 'maxLimit' => 0],
  983. 1,
  984. ],
  985. 'bad input, results in 1' => [
  986. ['limit' => null, 'maxLimit' => null],
  987. 1,
  988. ],
  989. 'bad input, results in 1 v2' => [
  990. ['limit' => false, 'maxLimit' => false],
  991. 1,
  992. ],
  993. ];
  994. }
  995. /**
  996. * test that maxLimit is respected
  997. */
  998. #[DataProvider('checkLimitProvider')]
  999. public function testCheckLimit(array $input, int $expected): void
  1000. {
  1001. $result = $this->Paginator->checkLimit($input);
  1002. $this->assertSame($expected, $result['limit']);
  1003. }
  1004. /**
  1005. * Integration test for checkLimit() being applied inside paginate()
  1006. */
  1007. public function testPaginateMaxLimit(): void
  1008. {
  1009. $table = $this->getTableLocator()->get('PaginatorPosts');
  1010. $settings = [
  1011. 'maxLimit' => 100,
  1012. ];
  1013. $params = [
  1014. 'limit' => '1000',
  1015. ];
  1016. $result = $this->Paginator->paginate($table, $params, $settings);
  1017. $pagingParams = $result->pagingParams();
  1018. $this->assertEquals(100, $pagingParams['limit']);
  1019. $this->assertEquals(100, $pagingParams['perPage']);
  1020. $params = [
  1021. 'limit' => '10',
  1022. ];
  1023. $result = $this->Paginator->paginate($table, $params, $settings);
  1024. $pagingParams = $result->pagingParams();
  1025. $this->assertEquals(10, $pagingParams['limit']);
  1026. $this->assertEquals(10, $pagingParams['perPage']);
  1027. }
  1028. /**
  1029. * test the `finder` is unused if paginate() is called with a query instance.
  1030. */
  1031. public function testPaginateQueryUnusedFinder(): void
  1032. {
  1033. $settings = [
  1034. 'finder' => 'published',
  1035. 'limit' => 2,
  1036. ];
  1037. $table = $this->_getMockPosts(['find']);
  1038. $query = $this->_getMockFindQuery();
  1039. $query->setRepository($table);
  1040. $table->expects($this->never())
  1041. ->method('find');
  1042. $query->expects($this->once())->method('applyOptions')
  1043. ->with([
  1044. 'limit' => 2,
  1045. 'page' => 1,
  1046. 'order' => [],
  1047. ]);
  1048. $this->Paginator->paginate($query, [], $settings)->pagingParams();
  1049. }
  1050. /**
  1051. * Tests that it is possible to pass an already made query object to
  1052. * paginate()
  1053. */
  1054. public function testPaginateQuery(): void
  1055. {
  1056. $params = ['page' => '-1'];
  1057. $settings = [
  1058. 'PaginatorPosts' => [
  1059. 'maxLimit' => 10,
  1060. 'order' => ['PaginatorPosts.id' => 'ASC'],
  1061. ],
  1062. ];
  1063. $table = $this->_getMockPosts(['find']);
  1064. $query = $this->_getMockFindQuery($table);
  1065. $table->expects($this->never())->method('find');
  1066. $query->expects($this->once())
  1067. ->method('applyOptions')
  1068. ->with([
  1069. 'limit' => 10,
  1070. 'order' => ['PaginatorPosts.id' => 'ASC'],
  1071. 'page' => 1,
  1072. ]);
  1073. $this->Paginator->paginate($query, $params, $settings);
  1074. }
  1075. /**
  1076. * test paginate() with bind()
  1077. */
  1078. public function testPaginateQueryWithBindValue(): void
  1079. {
  1080. $config = ConnectionManager::getConfig('test');
  1081. $this->skipIf(str_contains($config['driver'], 'Sqlserver'), 'Test temporarily broken in SQLServer');
  1082. $table = $this->getTableLocator()->get('PaginatorPosts');
  1083. $query = $table->find()
  1084. ->where(['PaginatorPosts.author_id BETWEEN :start AND :end'])
  1085. ->bind(':start', 1)
  1086. ->bind(':end', 2);
  1087. $results = $this->Paginator->paginate($query, []);
  1088. $result = $results->toArray();
  1089. $this->assertCount(2, $result);
  1090. $this->assertEquals('First Post', $result[0]->title);
  1091. $this->assertEquals('Third Post', $result[1]->title);
  1092. }
  1093. /**
  1094. * Tests that passing a query object with a limit clause set will
  1095. * overwrite it with the passed defaults.
  1096. */
  1097. public function testPaginateQueryWithLimit(): void
  1098. {
  1099. $params = ['page' => '-1'];
  1100. $settings = [
  1101. 'PaginatorPosts' => [
  1102. 'maxLimit' => 10,
  1103. 'limit' => 5,
  1104. 'order' => ['PaginatorPosts.id' => 'ASC'],
  1105. ],
  1106. ];
  1107. $table = $this->_getMockPosts(['find']);
  1108. $query = $this->_getMockFindQuery($table);
  1109. $query->limit(2);
  1110. $table->expects($this->never())->method('find');
  1111. $query->expects($this->once())
  1112. ->method('applyOptions')
  1113. ->with([
  1114. 'limit' => 5,
  1115. 'order' => ['PaginatorPosts.id' => 'ASC'],
  1116. 'page' => 1,
  1117. ]);
  1118. $this->Paginator->paginate($query, $params, $settings);
  1119. }
  1120. /**
  1121. * Helper method for making mocks.
  1122. *
  1123. * @param array $methods
  1124. * @return \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject
  1125. */
  1126. protected function _getMockPosts($methods = [])
  1127. {
  1128. return $this->getMockBuilder(PaginatorPostsTable::class)
  1129. ->onlyMethods($methods)
  1130. ->setConstructorArgs([[
  1131. 'connection' => ConnectionManager::get('test'),
  1132. 'alias' => 'PaginatorPosts',
  1133. 'schema' => [
  1134. 'id' => ['type' => 'integer'],
  1135. 'author_id' => ['type' => 'integer', 'null' => false],
  1136. 'title' => ['type' => 'string', 'null' => false],
  1137. 'body' => 'text',
  1138. 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'],
  1139. '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]],
  1140. ],
  1141. ]])
  1142. ->getMock();
  1143. }
  1144. /**
  1145. * Helper method for mocking queries.
  1146. *
  1147. * @param string|null $table
  1148. * @return \Cake\ORM\Query\SelectQuery|\PHPUnit\Framework\MockObject\MockObject
  1149. */
  1150. protected function _getMockFindQuery($table = null)
  1151. {
  1152. /** @var \Cake\ORM\Query\SelectQuery|\PHPUnit\Framework\MockObject\MockObject $query */
  1153. $query = $this->getMockBuilder(SelectQuery::class)
  1154. ->onlyMethods(['all', 'count', 'applyOptions'])
  1155. ->disableOriginalConstructor()
  1156. ->getMock();
  1157. $results = $this->getMockBuilder(ResultSet::class)
  1158. ->disableOriginalConstructor()
  1159. ->getMock();
  1160. $query->expects($this->any())
  1161. ->method('count')
  1162. ->willReturn(2);
  1163. $query->expects($this->any())
  1164. ->method('all')
  1165. ->willReturn($results);
  1166. if ($table) {
  1167. $query->setRepository($table);
  1168. }
  1169. return $query;
  1170. }
  1171. }