BelongsToManyTest.php 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187
  1. <?php
  2. /**
  3. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  4. * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  5. *
  6. * Licensed under The MIT License
  7. * For full copyright and license information, please see the LICENSE.txt
  8. * Redistributions of files must retain the above copyright notice.
  9. *
  10. * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
  11. * @link http://cakephp.org CakePHP(tm) Project
  12. * @since 3.0.0
  13. * @license http://www.opensource.org/licenses/mit-license.php MIT License
  14. */
  15. namespace Cake\Test\TestCase\ORM\Association;
  16. use Cake\Database\Expression\QueryExpression;
  17. use Cake\Datasource\ConnectionManager;
  18. use Cake\ORM\Association\BelongsToMany;
  19. use Cake\ORM\Entity;
  20. use Cake\ORM\TableRegistry;
  21. use Cake\TestSuite\TestCase;
  22. /**
  23. * Tests BelongsToMany class
  24. */
  25. class BelongsToManyTest extends TestCase
  26. {
  27. /**
  28. * Fixtures
  29. *
  30. * @var array
  31. */
  32. public $fixtures = ['core.articles', 'core.special_tags', 'core.articles_tags', 'core.tags'];
  33. /**
  34. * Set up
  35. *
  36. * @return void
  37. */
  38. public function setUp()
  39. {
  40. parent::setUp();
  41. $this->tag = $this->getMockBuilder('Cake\ORM\Table')
  42. ->setMethods(['find', 'delete'])
  43. ->setConstructorArgs([['alias' => 'Tags', 'table' => 'tags']])
  44. ->getMock();
  45. $this->tag->schema([
  46. 'id' => ['type' => 'integer'],
  47. 'name' => ['type' => 'string'],
  48. '_constraints' => [
  49. 'primary' => ['type' => 'primary', 'columns' => ['id']]
  50. ]
  51. ]);
  52. $this->article = $this->getMockBuilder('Cake\ORM\Table')
  53. ->setMethods(['find', 'delete'])
  54. ->setConstructorArgs([['alias' => 'Articles', 'table' => 'articles']])
  55. ->getMock();
  56. $this->article->schema([
  57. 'id' => ['type' => 'integer'],
  58. 'name' => ['type' => 'string'],
  59. '_constraints' => [
  60. 'primary' => ['type' => 'primary', 'columns' => ['id']]
  61. ]
  62. ]);
  63. }
  64. /**
  65. * Tear down
  66. *
  67. * @return void
  68. */
  69. public function tearDown()
  70. {
  71. parent::tearDown();
  72. TableRegistry::clear();
  73. }
  74. /**
  75. * Tests that the association reports it can be joined
  76. *
  77. * @return void
  78. */
  79. public function testCanBeJoined()
  80. {
  81. $assoc = new BelongsToMany('Test');
  82. $this->assertFalse($assoc->canBeJoined());
  83. }
  84. /**
  85. * Tests sort() method
  86. *
  87. * @return void
  88. */
  89. public function testSort()
  90. {
  91. $assoc = new BelongsToMany('Test');
  92. $this->assertNull($assoc->sort());
  93. $assoc->sort(['id' => 'ASC']);
  94. $this->assertEquals(['id' => 'ASC'], $assoc->sort());
  95. }
  96. /**
  97. * Tests requiresKeys() method
  98. *
  99. * @return void
  100. */
  101. public function testRequiresKeys()
  102. {
  103. $assoc = new BelongsToMany('Test');
  104. $this->assertTrue($assoc->requiresKeys());
  105. $assoc->strategy(BelongsToMany::STRATEGY_SUBQUERY);
  106. $this->assertFalse($assoc->requiresKeys());
  107. $assoc->strategy(BelongsToMany::STRATEGY_SELECT);
  108. $this->assertTrue($assoc->requiresKeys());
  109. }
  110. /**
  111. * Tests that BelongsToMany can't use the join strategy
  112. *
  113. * @expectedException \InvalidArgumentException
  114. * @expectedExceptionMessage Invalid strategy "join" was provided
  115. * @return void
  116. */
  117. public function testStrategyFailure()
  118. {
  119. $assoc = new BelongsToMany('Test');
  120. $assoc->strategy(BelongsToMany::STRATEGY_JOIN);
  121. }
  122. /**
  123. * Tests the junction method
  124. *
  125. * @return void
  126. */
  127. public function testJunction()
  128. {
  129. $assoc = new BelongsToMany('Test', [
  130. 'sourceTable' => $this->article,
  131. 'targetTable' => $this->tag
  132. ]);
  133. $junction = $assoc->junction();
  134. $this->assertInstanceOf('Cake\ORM\Table', $junction);
  135. $this->assertEquals('ArticlesTags', $junction->alias());
  136. $this->assertEquals('articles_tags', $junction->table());
  137. $this->assertSame($this->article, $junction->association('Articles')->target());
  138. $this->assertSame($this->tag, $junction->association('Tags')->target());
  139. $belongsTo = '\Cake\ORM\Association\BelongsTo';
  140. $this->assertInstanceOf($belongsTo, $junction->association('Articles'));
  141. $this->assertInstanceOf($belongsTo, $junction->association('Tags'));
  142. $this->assertSame($junction, $this->tag->association('ArticlesTags')->target());
  143. $this->assertSame($this->article, $this->tag->association('Articles')->target());
  144. $hasMany = '\Cake\ORM\Association\HasMany';
  145. $belongsToMany = '\Cake\ORM\Association\BelongsToMany';
  146. $this->assertInstanceOf($belongsToMany, $this->tag->association('Articles'));
  147. $this->assertInstanceOf($hasMany, $this->tag->association('ArticlesTags'));
  148. $this->assertSame($junction, $assoc->junction());
  149. $junction2 = TableRegistry::get('Foos');
  150. $assoc->junction($junction2);
  151. $this->assertSame($junction2, $assoc->junction());
  152. $assoc->junction('ArticlesTags');
  153. $this->assertSame($junction, $assoc->junction());
  154. }
  155. /**
  156. * Tests the junction passes the source connection name on.
  157. *
  158. * @return void
  159. */
  160. public function testJunctionConnection()
  161. {
  162. $mock = $this->getMockBuilder('Cake\Database\Connection')
  163. ->setMethods(['driver'])
  164. ->setConstructorArgs(['name' => 'other_source'])
  165. ->getMock();
  166. ConnectionManager::config('other_source', $mock);
  167. $this->article->connection(ConnectionManager::get('other_source'));
  168. $assoc = new BelongsToMany('Test', [
  169. 'sourceTable' => $this->article,
  170. 'targetTable' => $this->tag
  171. ]);
  172. $junction = $assoc->junction();
  173. $this->assertSame($mock, $junction->connection());
  174. ConnectionManager::drop('other_source');
  175. }
  176. /**
  177. * Tests the junction method custom keys
  178. *
  179. * @return void
  180. */
  181. public function testJunctionCustomKeys()
  182. {
  183. $this->article->belongsToMany('Tags', [
  184. 'joinTable' => 'articles_tags',
  185. 'foreignKey' => 'article',
  186. 'targetForeignKey' => 'tag'
  187. ]);
  188. $this->tag->belongsToMany('Articles', [
  189. 'joinTable' => 'articles_tags',
  190. 'foreignKey' => 'tag',
  191. 'targetForeignKey' => 'article'
  192. ]);
  193. $junction = $this->article->association('Tags')->junction();
  194. $this->assertEquals('article', $junction->association('Articles')->foreignKey());
  195. $this->assertEquals('article', $this->article->association('ArticlesTags')->foreignKey());
  196. $junction = $this->tag->association('Articles')->junction();
  197. $this->assertEquals('tag', $junction->association('Tags')->foreignKey());
  198. $this->assertEquals('tag', $this->tag->association('ArticlesTags')->foreignKey());
  199. }
  200. /**
  201. * Tests it is possible to set the table name for the join table
  202. *
  203. * @return void
  204. */
  205. public function testJunctionWithDefaultTableName()
  206. {
  207. $assoc = new BelongsToMany('Test', [
  208. 'sourceTable' => $this->article,
  209. 'targetTable' => $this->tag,
  210. 'joinTable' => 'tags_articles'
  211. ]);
  212. $junction = $assoc->junction();
  213. $this->assertEquals('TagsArticles', $junction->alias());
  214. $this->assertEquals('tags_articles', $junction->table());
  215. }
  216. /**
  217. * Tests saveStrategy
  218. *
  219. * @return void
  220. */
  221. public function testSaveStrategy()
  222. {
  223. $assoc = new BelongsToMany('Test');
  224. $this->assertEquals(BelongsToMany::SAVE_REPLACE, $assoc->saveStrategy());
  225. $assoc->saveStrategy(BelongsToMany::SAVE_APPEND);
  226. $this->assertEquals(BelongsToMany::SAVE_APPEND, $assoc->saveStrategy());
  227. $assoc->saveStrategy(BelongsToMany::SAVE_REPLACE);
  228. $this->assertEquals(BelongsToMany::SAVE_REPLACE, $assoc->saveStrategy());
  229. }
  230. /**
  231. * Tests that it is possible to pass the saveAssociated strategy in the constructor
  232. *
  233. * @return void
  234. */
  235. public function testSaveStrategyInOptions()
  236. {
  237. $assoc = new BelongsToMany('Test', ['saveStrategy' => BelongsToMany::SAVE_APPEND]);
  238. $this->assertEquals(BelongsToMany::SAVE_APPEND, $assoc->saveStrategy());
  239. }
  240. /**
  241. * Tests that passing an invalid strategy will throw an exception
  242. *
  243. * @expectedException \InvalidArgumentException
  244. * @expectedExceptionMessage Invalid save strategy "depsert"
  245. * @return void
  246. */
  247. public function testSaveStrategyInvalid()
  248. {
  249. $assoc = new BelongsToMany('Test', ['saveStrategy' => 'depsert']);
  250. }
  251. /**
  252. * Test cascading deletes.
  253. *
  254. * @return void
  255. */
  256. public function testCascadeDelete()
  257. {
  258. $articleTag = $this->getMockBuilder('Cake\ORM\Table')
  259. ->setMethods(['deleteAll'])
  260. ->getMock();
  261. $config = [
  262. 'sourceTable' => $this->article,
  263. 'targetTable' => $this->tag,
  264. 'sort' => ['id' => 'ASC'],
  265. ];
  266. $association = new BelongsToMany('Tags', $config);
  267. $association->junction($articleTag);
  268. $this->article
  269. ->association($articleTag->alias())
  270. ->conditions(['click_count' => 3]);
  271. $articleTag->expects($this->once())
  272. ->method('deleteAll')
  273. ->with([
  274. 'click_count' => 3,
  275. 'article_id' => 1
  276. ]);
  277. $entity = new Entity(['id' => 1, 'name' => 'PHP']);
  278. $association->cascadeDelete($entity);
  279. }
  280. /**
  281. * Test cascading deletes with dependent=false
  282. *
  283. * @return void
  284. */
  285. public function testCascadeDeleteDependent()
  286. {
  287. $articleTag = $this->getMockBuilder('Cake\ORM\Table')
  288. ->setMethods(['delete', 'deleteAll'])
  289. ->getMock();
  290. $config = [
  291. 'sourceTable' => $this->article,
  292. 'targetTable' => $this->tag,
  293. 'dependent' => false,
  294. 'sort' => ['id' => 'ASC'],
  295. ];
  296. $association = new BelongsToMany('Tags', $config);
  297. $association->junction($articleTag);
  298. $this->article
  299. ->association($articleTag->alias())
  300. ->conditions(['click_count' => 3]);
  301. $articleTag->expects($this->never())
  302. ->method('deleteAll');
  303. $articleTag->expects($this->never())
  304. ->method('delete');
  305. $entity = new Entity(['id' => 1, 'name' => 'PHP']);
  306. $association->cascadeDelete($entity);
  307. }
  308. /**
  309. * Test cascading deletes with callbacks.
  310. *
  311. * @return void
  312. */
  313. public function testCascadeDeleteWithCallbacks()
  314. {
  315. $articleTag = TableRegistry::get('ArticlesTags');
  316. $config = [
  317. 'sourceTable' => $this->article,
  318. 'targetTable' => $this->tag,
  319. 'cascadeCallbacks' => true,
  320. ];
  321. $association = new BelongsToMany('Tag', $config);
  322. $association->junction($articleTag);
  323. $this->article->association($articleTag->alias());
  324. $counter = $this->getMockBuilder('StdClass')
  325. ->setMethods(['__invoke'])
  326. ->getMock();
  327. $counter->expects($this->exactly(2))->method('__invoke');
  328. $articleTag->eventManager()->on('Model.beforeDelete', $counter);
  329. $this->assertEquals(2, $articleTag->find()->where(['article_id' => 1])->count());
  330. $entity = new Entity(['id' => 1, 'name' => 'PHP']);
  331. $association->cascadeDelete($entity);
  332. $this->assertEquals(0, $articleTag->find()->where(['article_id' => 1])->count());
  333. }
  334. /**
  335. * Test linking entities having a non persisted source entity
  336. *
  337. * @expectedException \InvalidArgumentException
  338. * @expectedExceptionMessage Source entity needs to be persisted before proceeding
  339. * @return void
  340. */
  341. public function testLinkWithNotPersistedSource()
  342. {
  343. $config = [
  344. 'sourceTable' => $this->article,
  345. 'targetTable' => $this->tag,
  346. 'joinTable' => 'tags_articles'
  347. ];
  348. $assoc = new BelongsToMany('Test', $config);
  349. $entity = new Entity(['id' => 1]);
  350. $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])];
  351. $assoc->link($entity, $tags);
  352. }
  353. /**
  354. * Test liking entities having a non persited target entity
  355. *
  356. * @expectedException \InvalidArgumentException
  357. * @expectedExceptionMessage Cannot link not persisted entities
  358. * @return void
  359. */
  360. public function testLinkWithNotPersistedTarget()
  361. {
  362. $config = [
  363. 'sourceTable' => $this->article,
  364. 'targetTable' => $this->tag,
  365. 'joinTable' => 'tags_articles'
  366. ];
  367. $assoc = new BelongsToMany('Test', $config);
  368. $entity = new Entity(['id' => 1], ['markNew' => false]);
  369. $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])];
  370. $assoc->link($entity, $tags);
  371. }
  372. /**
  373. * Tests that liking entities will validate data and pass on to _saveLinks
  374. *
  375. * @return void
  376. */
  377. public function testLinkSuccess()
  378. {
  379. $connection = ConnectionManager::get('test');
  380. $joint = $this->getMockBuilder('\Cake\ORM\Table')
  381. ->setMethods(['save'])
  382. ->setConstructorArgs([['alias' => 'ArticlesTags', 'connection' => $connection]])
  383. ->getMock();
  384. $config = [
  385. 'sourceTable' => $this->article,
  386. 'targetTable' => $this->tag,
  387. 'through' => $joint,
  388. 'joinTable' => 'tags_articles'
  389. ];
  390. $assoc = new BelongsToMany('Test', $config);
  391. $opts = ['markNew' => false];
  392. $entity = new Entity(['id' => 1], $opts);
  393. $tags = [new Entity(['id' => 2], $opts), new Entity(['id' => 3], $opts)];
  394. $saveOptions = ['foo' => 'bar'];
  395. $joint->expects($this->at(0))
  396. ->method('save')
  397. ->will($this->returnCallback(function ($e, $opts) use ($entity) {
  398. $expected = ['article_id' => 1, 'tag_id' => 2];
  399. $this->assertEquals($expected, $e->toArray());
  400. $this->assertEquals(['foo' => 'bar'], $opts);
  401. $this->assertTrue($e->isNew());
  402. return $entity;
  403. }));
  404. $joint->expects($this->at(1))
  405. ->method('save')
  406. ->will($this->returnCallback(function ($e, $opts) use ($entity) {
  407. $expected = ['article_id' => 1, 'tag_id' => 3];
  408. $this->assertEquals($expected, $e->toArray());
  409. $this->assertEquals(['foo' => 'bar'], $opts);
  410. $this->assertTrue($e->isNew());
  411. return $entity;
  412. }));
  413. $this->assertTrue($assoc->link($entity, $tags, $saveOptions));
  414. $this->assertSame($entity->test, $tags);
  415. }
  416. /**
  417. * Test liking entities having a non persited source entity
  418. *
  419. * @expectedException \InvalidArgumentException
  420. * @expectedExceptionMessage Source entity needs to be persisted before proceeding
  421. * @return void
  422. */
  423. public function testUnlinkWithNotPersistedSource()
  424. {
  425. $config = [
  426. 'sourceTable' => $this->article,
  427. 'targetTable' => $this->tag,
  428. 'joinTable' => 'tags_articles'
  429. ];
  430. $assoc = new BelongsToMany('Test', $config);
  431. $entity = new Entity(['id' => 1]);
  432. $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])];
  433. $assoc->unlink($entity, $tags);
  434. }
  435. /**
  436. * Test liking entities having a non persited target entity
  437. *
  438. * @expectedException \InvalidArgumentException
  439. * @expectedExceptionMessage Cannot link not persisted entities
  440. * @return void
  441. */
  442. public function testUnlinkWithNotPersistedTarget()
  443. {
  444. $config = [
  445. 'sourceTable' => $this->article,
  446. 'targetTable' => $this->tag,
  447. 'joinTable' => 'tags_articles'
  448. ];
  449. $assoc = new BelongsToMany('Test', $config);
  450. $entity = new Entity(['id' => 1], ['markNew' => false]);
  451. $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])];
  452. $assoc->unlink($entity, $tags);
  453. }
  454. /**
  455. * Tests that unlinking calls the right methods
  456. *
  457. * @return void
  458. */
  459. public function testUnlinkSuccess()
  460. {
  461. $joint = TableRegistry::get('SpecialTags');
  462. $articles = TableRegistry::get('Articles');
  463. $tags = TableRegistry::get('Tags');
  464. $assoc = $articles->belongsToMany('Tags', [
  465. 'sourceTable' => $articles,
  466. 'targetTable' => $tags,
  467. 'through' => $joint,
  468. 'joinTable' => 'special_tags',
  469. ]);
  470. $entity = $articles->get(2, ['contain' => 'Tags']);
  471. $initial = $entity->tags;
  472. $this->assertCount(1, $initial);
  473. $this->assertTrue($assoc->unlink($entity, $entity->tags));
  474. $this->assertEmpty($entity->get('tags'), 'Property should be empty');
  475. $new = $articles->get(2, ['contain' => 'Tags']);
  476. $this->assertCount(0, $new->tags, 'DB should be clean');
  477. $this->assertSame(3, $tags->find()->count(), 'Tags should still exist');
  478. }
  479. /**
  480. * Tests that unlinking with last parameter set to false
  481. * will not remove entities from the association property
  482. *
  483. * @return void
  484. */
  485. public function testUnlinkWithoutPropertyClean()
  486. {
  487. $joint = TableRegistry::get('SpecialTags');
  488. $articles = TableRegistry::get('Articles');
  489. $tags = TableRegistry::get('Tags');
  490. $assoc = $articles->belongsToMany('Tags', [
  491. 'sourceTable' => $articles,
  492. 'targetTable' => $tags,
  493. 'through' => $joint,
  494. 'joinTable' => 'special_tags',
  495. 'conditions' => ['SpecialTags.highlighted' => true]
  496. ]);
  497. $entity = $articles->get(2, ['contain' => 'Tags']);
  498. $initial = $entity->tags;
  499. $this->assertCount(1, $initial);
  500. $this->assertTrue($assoc->unlink($entity, $initial, ['cleanProperty' => false]));
  501. $this->assertNotEmpty($entity->get('tags'), 'Property should not be empty');
  502. $this->assertEquals($initial, $entity->get('tags'), 'Property should be untouched');
  503. $new = $articles->get(2, ['contain' => 'Tags']);
  504. $this->assertCount(0, $new->tags, 'DB should be clean');
  505. }
  506. /**
  507. * Tests that replaceLink requires the sourceEntity to have primaryKey values
  508. * for the source entity
  509. *
  510. * @expectedException \InvalidArgumentException
  511. * @expectedExceptionMessage Could not find primary key value for source entity
  512. * @return void
  513. */
  514. public function testReplaceWithMissingPrimaryKey()
  515. {
  516. $config = [
  517. 'sourceTable' => $this->article,
  518. 'targetTable' => $this->tag,
  519. 'joinTable' => 'tags_articles'
  520. ];
  521. $assoc = new BelongsToMany('Test', $config);
  522. $entity = new Entity(['foo' => 1], ['markNew' => false]);
  523. $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])];
  524. $assoc->replaceLinks($entity, $tags);
  525. }
  526. /**
  527. * Test that replaceLinks() can saveAssociated an empty set, removing all rows.
  528. *
  529. * @return void
  530. */
  531. public function testReplaceLinksUpdateToEmptySet()
  532. {
  533. $joint = TableRegistry::get('ArticlesTags');
  534. $articles = TableRegistry::get('Articles');
  535. $tags = TableRegistry::get('Tags');
  536. $assoc = $articles->belongsToMany('Tags', [
  537. 'sourceTable' => $articles,
  538. 'targetTable' => $tags,
  539. 'through' => $joint,
  540. 'joinTable' => 'articles_tags',
  541. ]);
  542. $entity = $articles->get(1, ['contain' => 'Tags']);
  543. $this->assertCount(2, $entity->tags);
  544. $assoc->replaceLinks($entity, []);
  545. $this->assertSame([], $entity->tags, 'Property should be empty');
  546. $this->assertFalse($entity->dirty('tags'), 'Property should be cleaned');
  547. $new = $articles->get(1, ['contain' => 'Tags']);
  548. $this->assertSame([], $entity->tags, 'Should not be data in db');
  549. }
  550. /**
  551. * Tests that replaceLinks will delete entities not present in the passed,
  552. * array, maintain those are already persisted and were passed and also
  553. * insert the rest.
  554. *
  555. * @return void
  556. */
  557. public function testReplaceLinkSuccess()
  558. {
  559. $joint = TableRegistry::get('ArticlesTags');
  560. $articles = TableRegistry::get('Articles');
  561. $tags = TableRegistry::get('Tags');
  562. $assoc = $articles->belongsToMany('Tags', [
  563. 'sourceTable' => $articles,
  564. 'targetTable' => $tags,
  565. 'through' => $joint,
  566. 'joinTable' => 'articles_tags',
  567. ]);
  568. $entity = $articles->get(1, ['contain' => 'Tags']);
  569. // 1=existing, 2=removed, 3=new link, & new tag
  570. $tagData = [
  571. new Entity(['id' => 1], ['markNew' => false]),
  572. new Entity(['id' => 3]),
  573. new Entity(['name' => 'net new']),
  574. ];
  575. $result = $assoc->replaceLinks($entity, $tagData, ['associated' => false]);
  576. $this->assertTrue($result);
  577. $this->assertSame($tagData, $entity->tags, 'Tags should match replaced objects');
  578. $this->assertFalse($entity->dirty('tags'), 'Should be clean');
  579. $fresh = $articles->get(1, ['contain' => 'Tags']);
  580. $this->assertCount(3, $fresh->tags, 'Records should be in db');
  581. $this->assertNotEmpty($tags->get(2), 'Unlinked tag should still exist');
  582. }
  583. /**
  584. * Tests that replaceLinks() will contain() the target table when
  585. * there are conditions present on the association.
  586. *
  587. * In this case the replacement will fail because the association conditions
  588. * hide the fixture data.
  589. *
  590. * @return void
  591. */
  592. public function testReplaceLinkWithConditions()
  593. {
  594. $joint = TableRegistry::get('SpecialTags');
  595. $articles = TableRegistry::get('Articles');
  596. $tags = TableRegistry::get('Tags');
  597. $assoc = $articles->belongsToMany('Tags', [
  598. 'sourceTable' => $articles,
  599. 'targetTable' => $tags,
  600. 'through' => $joint,
  601. 'joinTable' => 'special_tags',
  602. 'conditions' => ['SpecialTags.highlighted' => true]
  603. ]);
  604. $entity = $articles->get(1, ['contain' => 'Tags']);
  605. $result = $assoc->replaceLinks($entity, [], ['associated' => false]);
  606. $this->assertTrue($result);
  607. $this->assertSame([], $entity->tags, 'Tags should match replaced objects');
  608. $this->assertFalse($entity->dirty('tags'), 'Should be clean');
  609. $fresh = $articles->get(1, ['contain' => 'Tags']);
  610. $this->assertCount(0, $fresh->tags, 'Association should be empty');
  611. $jointCount = $joint->find()->where(['article_id' => 1])->count();
  612. $this->assertSame(1, $jointCount, 'Non matching joint record should remain.');
  613. }
  614. /**
  615. * Tests replaceLinks with failing domain rules and new link targets.
  616. *
  617. * @return void
  618. */
  619. public function testReplaceLinkFailingDomainRules()
  620. {
  621. $articles = TableRegistry::get('Articles');
  622. $tags = TableRegistry::get('Tags');
  623. $tags->eventManager()->on('Model.buildRules', function ($event, $rules) {
  624. $rules->add(function () {
  625. return false;
  626. }, 'rule', ['errorField' => 'name', 'message' => 'Bad data']);
  627. });
  628. $assoc = $articles->belongsToMany('Tags', [
  629. 'sourceTable' => $articles,
  630. 'targetTable' => $tags,
  631. 'through' => TableRegistry::get('ArticlesTags'),
  632. 'joinTable' => 'articles_tags',
  633. ]);
  634. $entity = $articles->get(1, ['contain' => 'Tags']);
  635. $originalCount = count($entity->tags);
  636. $tags = [
  637. new Entity(['name' => 'tag99', 'description' => 'Best tag'])
  638. ];
  639. $result = $assoc->replaceLinks($entity, $tags);
  640. $this->assertFalse($result, 'replace should have failed.');
  641. $this->assertNotEmpty($tags[0]->errors(), 'Bad entity should have errors.');
  642. $entity = $articles->get(1, ['contain' => 'Tags']);
  643. $this->assertCount($originalCount, $entity->tags, 'Should not have changed.');
  644. $this->assertEquals('tag1', $entity->tags[0]->name);
  645. }
  646. /**
  647. * Provider for empty values
  648. *
  649. * @return array
  650. */
  651. public function emptyProvider()
  652. {
  653. return [
  654. [''],
  655. [false],
  656. [null],
  657. [[]]
  658. ];
  659. }
  660. /**
  661. * Test that saveAssociated() fails on non-empty, non-iterable value
  662. *
  663. * @expectedException InvalidArgumentException
  664. * @expectedExceptionMessage Could not save tags, it cannot be traversed
  665. * @return void
  666. */
  667. public function testSaveAssociatedNotEmptyNotIterable()
  668. {
  669. $articles = TableRegistry::get('Articles');
  670. $assoc = $articles->belongsToMany('Tags', [
  671. 'saveStrategy' => BelongsToMany::SAVE_APPEND,
  672. 'joinTable' => 'articles_tags',
  673. ]);
  674. $entity = new Entity([
  675. 'id' => 1,
  676. 'tags' => 'oh noes',
  677. ], ['markNew' => true]);
  678. $assoc->saveAssociated($entity);
  679. }
  680. /**
  681. * Test that saving an empty set on create works.
  682. *
  683. * @dataProvider emptyProvider
  684. * @return void
  685. */
  686. public function testSaveAssociatedEmptySetSuccess($value)
  687. {
  688. $table = $this->getMockBuilder('Cake\ORM\Table')
  689. ->setMethods(['table'])
  690. ->getMock();
  691. $table->schema([]);
  692. $assoc = $this->getMockBuilder('\Cake\ORM\Association\BelongsToMany')
  693. ->setMethods(['_saveTarget', 'replaceLinks'])
  694. ->setConstructorArgs(['tags', ['sourceTable' => $table]])
  695. ->getMock();
  696. $entity = new Entity([
  697. 'id' => 1,
  698. 'tags' => $value,
  699. ], ['markNew' => true]);
  700. $assoc->saveStrategy(BelongsToMany::SAVE_REPLACE);
  701. $assoc->expects($this->never())
  702. ->method('replaceLinks');
  703. $assoc->expects($this->never())
  704. ->method('_saveTarget');
  705. $this->assertSame($entity, $assoc->saveAssociated($entity));
  706. }
  707. /**
  708. * Test that saving an empty set on update works.
  709. *
  710. * @dataProvider emptyProvider
  711. * @return void
  712. */
  713. public function testSaveAssociatedEmptySetUpdateSuccess($value)
  714. {
  715. $table = $this->getMockBuilder('Cake\ORM\Table')
  716. ->setMethods(['table'])
  717. ->getMock();
  718. $table->schema([]);
  719. $assoc = $this->getMockBuilder('\Cake\ORM\Association\BelongsToMany')
  720. ->setMethods(['_saveTarget', 'replaceLinks'])
  721. ->setConstructorArgs(['tags', ['sourceTable' => $table]])
  722. ->getMock();
  723. $entity = new Entity([
  724. 'id' => 1,
  725. 'tags' => $value,
  726. ], ['markNew' => false]);
  727. $assoc->saveStrategy(BelongsToMany::SAVE_REPLACE);
  728. $assoc->expects($this->once())
  729. ->method('replaceLinks')
  730. ->with($entity, [])
  731. ->will($this->returnValue(true));
  732. $assoc->expects($this->never())
  733. ->method('_saveTarget');
  734. $this->assertSame($entity, $assoc->saveAssociated($entity));
  735. }
  736. /**
  737. * Tests saving with replace strategy returning true
  738. *
  739. * @return void
  740. */
  741. public function testSaveAssociatedWithReplace()
  742. {
  743. $table = $this->getMockBuilder('Cake\ORM\Table')
  744. ->setMethods(['table'])
  745. ->getMock();
  746. $table->schema([]);
  747. $assoc = $this->getMockBuilder('\Cake\ORM\Association\BelongsToMany')
  748. ->setMethods(['replaceLinks'])
  749. ->setConstructorArgs(['tags', ['sourceTable' => $table]])
  750. ->getMock();
  751. $entity = new Entity([
  752. 'id' => 1,
  753. 'tags' => [
  754. new Entity(['name' => 'foo'])
  755. ]
  756. ]);
  757. $options = ['foo' => 'bar'];
  758. $assoc->saveStrategy(BelongsToMany::SAVE_REPLACE);
  759. $assoc->expects($this->once())->method('replaceLinks')
  760. ->with($entity, $entity->tags, $options)
  761. ->will($this->returnValue(true));
  762. $this->assertSame($entity, $assoc->saveAssociated($entity, $options));
  763. }
  764. /**
  765. * Tests saving with replace strategy returning true
  766. *
  767. * @return void
  768. */
  769. public function testSaveAssociatedWithReplaceReturnFalse()
  770. {
  771. $table = $this->getMockBuilder('Cake\ORM\Table')
  772. ->setMethods(['table'])
  773. ->getMock();
  774. $table->schema([]);
  775. $assoc = $this->getMockBuilder('\Cake\ORM\Association\BelongsToMany')
  776. ->setMethods(['replaceLinks'])
  777. ->setConstructorArgs(['tags', ['sourceTable' => $table]])
  778. ->getMock();
  779. $entity = new Entity([
  780. 'id' => 1,
  781. 'tags' => [
  782. new Entity(['name' => 'foo'])
  783. ]
  784. ]);
  785. $options = ['foo' => 'bar'];
  786. $assoc->saveStrategy(BelongsToMany::SAVE_REPLACE);
  787. $assoc->expects($this->once())->method('replaceLinks')
  788. ->with($entity, $entity->tags, $options)
  789. ->will($this->returnValue(false));
  790. $this->assertFalse($assoc->saveAssociated($entity, $options));
  791. }
  792. /**
  793. * Test that saveAssociated() ignores non entity values.
  794. *
  795. * @return void
  796. */
  797. public function testSaveAssociatedOnlyEntitiesAppend()
  798. {
  799. $connection = ConnectionManager::get('test');
  800. $mock = $this->getMockBuilder('Cake\ORM\Table')
  801. ->setMethods(['saveAssociated', 'schema'])
  802. ->setConstructorArgs([['table' => 'tags', 'connection' => $connection]])
  803. ->getMock();
  804. $mock->primaryKey('id');
  805. $config = [
  806. 'sourceTable' => $this->article,
  807. 'targetTable' => $mock,
  808. 'saveStrategy' => BelongsToMany::SAVE_APPEND,
  809. ];
  810. $entity = new Entity([
  811. 'id' => 1,
  812. 'title' => 'First Post',
  813. 'tags' => [
  814. ['tag' => 'nope'],
  815. new Entity(['tag' => 'cakephp']),
  816. ]
  817. ]);
  818. $mock->expects($this->never())
  819. ->method('saveAssociated');
  820. $association = new BelongsToMany('Tags', $config);
  821. $association->saveAssociated($entity);
  822. }
  823. /**
  824. * Tests that targetForeignKey() returns the correct configured value
  825. *
  826. * @return void
  827. */
  828. public function testTargetForeignKey()
  829. {
  830. $assoc = new BelongsToMany('Test', [
  831. 'sourceTable' => $this->article,
  832. 'targetTable' => $this->tag
  833. ]);
  834. $this->assertEquals('tag_id', $assoc->targetForeignKey());
  835. $assoc->targetForeignKey('another_key');
  836. $this->assertEquals('another_key', $assoc->targetForeignKey());
  837. $assoc = new BelongsToMany('Test', [
  838. 'sourceTable' => $this->article,
  839. 'targetTable' => $this->tag,
  840. 'targetForeignKey' => 'foo'
  841. ]);
  842. $this->assertEquals('foo', $assoc->targetForeignKey());
  843. }
  844. /**
  845. * Tests that custom foreignKeys are properly trasmitted to involved associations
  846. * when they are customized
  847. *
  848. * @return void
  849. */
  850. public function testJunctionWithCustomForeignKeys()
  851. {
  852. $assoc = new BelongsToMany('Test', [
  853. 'sourceTable' => $this->article,
  854. 'targetTable' => $this->tag,
  855. 'foreignKey' => 'Art',
  856. 'targetForeignKey' => 'Tag'
  857. ]);
  858. $junction = $assoc->junction();
  859. $this->assertEquals('Art', $junction->association('Articles')->foreignKey());
  860. $this->assertEquals('Tag', $junction->association('Tags')->foreignKey());
  861. $inverseRelation = $this->tag->association('Articles');
  862. $this->assertEquals('Tag', $inverseRelation->foreignKey());
  863. $this->assertEquals('Art', $inverseRelation->targetForeignKey());
  864. }
  865. /**
  866. * Tests that property is being set using the constructor options.
  867. *
  868. * @return void
  869. */
  870. public function testPropertyOption()
  871. {
  872. $config = ['propertyName' => 'thing_placeholder'];
  873. $association = new BelongsToMany('Thing', $config);
  874. $this->assertEquals('thing_placeholder', $association->property());
  875. }
  876. /**
  877. * Test that plugin names are omitted from property()
  878. *
  879. * @return void
  880. */
  881. public function testPropertyNoPlugin()
  882. {
  883. $mock = $this->getMockBuilder('Cake\ORM\Table')
  884. ->disableOriginalConstructor()
  885. ->getMock();
  886. $config = [
  887. 'sourceTable' => $this->article,
  888. 'targetTable' => $mock,
  889. ];
  890. $association = new BelongsToMany('Contacts.Tags', $config);
  891. $this->assertEquals('tags', $association->property());
  892. }
  893. /**
  894. * Test that the generated associations are correct.
  895. *
  896. * @return void
  897. */
  898. public function testGeneratedAssociations()
  899. {
  900. $articles = TableRegistry::get('Articles');
  901. $tags = TableRegistry::get('Tags');
  902. $conditions = ['SpecialTags.highlighted' => true];
  903. $assoc = $articles->belongsToMany('Tags', [
  904. 'sourceTable' => $articles,
  905. 'targetTable' => $tags,
  906. 'foreignKey' => 'foreign_key',
  907. 'targetForeignKey' => 'target_foreign_key',
  908. 'through' => 'SpecialTags',
  909. 'conditions' => $conditions,
  910. ]);
  911. // Generate associations
  912. $assoc->junction();
  913. $tagAssoc = $articles->association('Tags');
  914. $this->assertNotEmpty($tagAssoc, 'btm should exist');
  915. $this->assertEquals($conditions, $tagAssoc->conditions());
  916. $this->assertEquals('target_foreign_key', $tagAssoc->targetForeignKey());
  917. $this->assertEquals('foreign_key', $tagAssoc->foreignKey());
  918. $jointAssoc = $articles->association('SpecialTags');
  919. $this->assertNotEmpty($jointAssoc, 'has many to junction should exist');
  920. $this->assertInstanceOf('Cake\ORM\Association\HasMany', $jointAssoc);
  921. $this->assertEquals('foreign_key', $jointAssoc->foreignKey());
  922. $articleAssoc = $tags->association('Articles');
  923. $this->assertNotEmpty($articleAssoc, 'reverse btm should exist');
  924. $this->assertInstanceOf('Cake\ORM\Association\BelongsToMany', $articleAssoc);
  925. $this->assertEquals($conditions, $articleAssoc->conditions());
  926. $this->assertEquals('foreign_key', $articleAssoc->targetForeignKey(), 'keys should swap');
  927. $this->assertEquals('target_foreign_key', $articleAssoc->foreignKey(), 'keys should swap');
  928. $jointAssoc = $tags->association('SpecialTags');
  929. $this->assertNotEmpty($jointAssoc, 'has many to junction should exist');
  930. $this->assertInstanceOf('Cake\ORM\Association\HasMany', $jointAssoc);
  931. $this->assertEquals('target_foreign_key', $jointAssoc->foreignKey());
  932. }
  933. /**
  934. * Tests that fetching belongsToMany association will not force
  935. * all fields being returned, but intead will honor the select() clause
  936. *
  937. * @see https://github.com/cakephp/cakephp/issues/7916
  938. * @return void
  939. */
  940. public function testEagerLoadingBelongsToManyLimitedFields()
  941. {
  942. $table = TableRegistry::get('Articles');
  943. $table->belongsToMany('Tags');
  944. $result = $table
  945. ->find()
  946. ->contain(['Tags' => function ($q) {
  947. return $q->select(['id']);
  948. }])
  949. ->first();
  950. $this->assertNotEmpty($result->tags[0]->id);
  951. $this->assertEmpty($result->tags[0]->name);
  952. }
  953. /**
  954. * Tests that fetching belongsToMany association will retain autoFields(true) if it was used.
  955. *
  956. * @see https://github.com/cakephp/cakephp/issues/8052
  957. * @return void
  958. */
  959. public function testEagerLoadingBelongsToManyLimitedFieldsWithAutoFields()
  960. {
  961. $table = TableRegistry::get('Articles');
  962. $table->belongsToMany('Tags');
  963. $result = $table
  964. ->find()
  965. ->contain(['Tags' => function ($q) {
  966. return $q->select(['two' => $q->newExpr('1 + 1')])->autoFields(true);
  967. }])
  968. ->first();
  969. $this->assertNotEmpty($result->tags[0]->two, 'Should have computed field');
  970. $this->assertNotEmpty($result->tags[0]->name, 'Should have standard field');
  971. }
  972. /**
  973. * Test that association proxy find() applies joins when conditions are involved.
  974. *
  975. * @return void
  976. */
  977. public function testAssociationProxyFindWithConditions()
  978. {
  979. $table = TableRegistry::get('Articles');
  980. $table->belongsToMany('Tags', [
  981. 'foreignKey' => 'article_id',
  982. 'associationForeignKey' => 'tag_id',
  983. 'conditions' => ['SpecialTags.highlighted' => true],
  984. 'through' => 'SpecialTags'
  985. ]);
  986. $query = $table->Tags->find();
  987. $result = $query->toArray();
  988. $this->assertCount(1, $result);
  989. $this->assertEquals(1, $result[0]->id);
  990. }
  991. /**
  992. * Test that association proxy find() applies complex conditions
  993. *
  994. * @return void
  995. */
  996. public function testAssociationProxyFindWithComplexConditions()
  997. {
  998. $table = TableRegistry::get('Articles');
  999. $table->belongsToMany('Tags', [
  1000. 'foreignKey' => 'article_id',
  1001. 'associationForeignKey' => 'tag_id',
  1002. 'conditions' => [
  1003. 'OR' => [
  1004. 'SpecialTags.highlighted' => true,
  1005. ]
  1006. ],
  1007. 'through' => 'SpecialTags'
  1008. ]);
  1009. $query = $table->Tags->find();
  1010. $result = $query->toArray();
  1011. $this->assertCount(1, $result);
  1012. $this->assertEquals(1, $result[0]->id);
  1013. }
  1014. /**
  1015. * Test that matching() works on belongsToMany associations.
  1016. *
  1017. * @return void
  1018. */
  1019. public function testBelongsToManyAssociationWithArrayConditions()
  1020. {
  1021. $table = TableRegistry::get('Articles');
  1022. $table->belongsToMany('Tags', [
  1023. 'foreignKey' => 'article_id',
  1024. 'associationForeignKey' => 'tag_id',
  1025. 'conditions' => ['SpecialTags.highlighted' => true],
  1026. 'through' => 'SpecialTags'
  1027. ]);
  1028. $query = $table->find()->matching('Tags', function ($q) {
  1029. return $q->where(['Tags.name' => 'tag1']);
  1030. });
  1031. $results = $query->toArray();
  1032. $this->assertCount(1, $results);
  1033. $this->assertNotEmpty($results[0]->_matchingData);
  1034. }
  1035. /**
  1036. * Test that matching() works on belongsToMany associations.
  1037. *
  1038. * @return void
  1039. */
  1040. public function testBelongsToManyAssociationWithExpressionConditions()
  1041. {
  1042. $table = TableRegistry::get('Articles');
  1043. $table->belongsToMany('Tags', [
  1044. 'foreignKey' => 'article_id',
  1045. 'associationForeignKey' => 'tag_id',
  1046. 'conditions' => [new QueryExpression("name LIKE 'tag%'")],
  1047. 'through' => 'SpecialTags'
  1048. ]);
  1049. $query = $table->find()->matching('Tags', function ($q) {
  1050. return $q->where(['Tags.name' => 'tag1']);
  1051. });
  1052. $results = $query->toArray();
  1053. $this->assertCount(1, $results);
  1054. $this->assertNotEmpty($results[0]->_matchingData);
  1055. }
  1056. /**
  1057. * Test that association proxy find() with matching resolves joins correctly
  1058. *
  1059. * @return void
  1060. */
  1061. public function testAssociationProxyFindWithConditionsMatching()
  1062. {
  1063. $table = TableRegistry::get('Articles');
  1064. $table->belongsToMany('Tags', [
  1065. 'foreignKey' => 'article_id',
  1066. 'associationForeignKey' => 'tag_id',
  1067. 'conditions' => ['SpecialTags.highlighted' => true],
  1068. 'through' => 'SpecialTags'
  1069. ]);
  1070. $query = $table->Tags->find()->matching('Articles', function ($query) {
  1071. return $query->where(['Articles.id' => 1]);
  1072. });
  1073. // The inner join on special_tags excludes the results.
  1074. $this->assertEquals(0, $query->count());
  1075. }
  1076. }