1<?php
2// Copyright (C) 2010-2017 Combodo SARL
3//
4//   This file is part of iTop.
5//
6//   iTop is free software; you can redistribute it and/or modify
7//   it under the terms of the GNU Affero General Public License as published by
8//   the Free Software Foundation, either version 3 of the License, or
9//   (at your option) any later version.
10//
11//   iTop is distributed in the hope that it will be useful,
12//   but WITHOUT ANY WARRANTY; without even the implied warranty of
13//   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14//   GNU Affero General Public License for more details.
15//
16//   You should have received a copy of the GNU Affero General Public License
17//   along with iTop. If not, see <http://www.gnu.org/licenses/>
18
19require_once('dbobjectiterator.php');
20
21
22/**
23 * The value for an attribute representing a set of links between the host object and "remote" objects
24 *
25 * @package     iTopORM
26 * @copyright   Copyright (C) 2010-2017 Combodo SARL
27 * @license     http://opensource.org/licenses/AGPL-3.0
28 */
29
30class ormLinkSet implements iDBObjectSetIterator, Iterator, SeekableIterator
31{
32	protected $sHostClass; // subclass of DBObject
33	protected $sAttCode; // xxxxxx_list
34	protected $sClass; // class of the links
35
36	/**
37	 * @var DBObjectSet
38	 */
39	protected $oOriginalSet;
40
41	/**
42	 * @var DBObject[] array of iObjectId => DBObject
43	 */
44	protected $aOriginalObjects = null;
45
46	/**
47	 * @var bool
48	 */
49	protected $bHasDelta = false;
50
51	/**
52	 * Object from the original set, minus the removed objects
53	 * @var DBObject[] array of iObjectId => DBObject
54	 */
55	protected $aPreserved = array();
56
57	/**
58	 * @var DBObject[] New items
59	 */
60	protected $aAdded = array();
61
62	/**
63	 * @var DBObject[] Modified items (could also be found in aPreserved)
64	 */
65	protected $aModified = array();
66
67	/**
68	 * @var int[] Removed items
69	 */
70	protected $aRemoved = array();
71
72	/**
73	 * @var int Position in the collection
74	 */
75	protected $iCursor = 0;
76
77	/**
78	 * __toString magical function overload.
79	 */
80	public function __toString()
81	{
82		return '';
83	}
84
85	/**
86	 * ormLinkSet constructor.
87	 * @param $sHostClass
88	 * @param $sAttCode
89	 * @param DBObjectSet|null $oOriginalSet
90	 * @throws Exception
91	 */
92	public function __construct($sHostClass, $sAttCode, DBObjectSet $oOriginalSet = null)
93	{
94		$this->sHostClass = $sHostClass;
95		$this->sAttCode = $sAttCode;
96		$this->oOriginalSet = $oOriginalSet ? clone $oOriginalSet : null;
97
98		$oAttDef = MetaModel::GetAttributeDef($sHostClass, $sAttCode);
99		if (!$oAttDef instanceof AttributeLinkedSet)
100		{
101			throw new Exception("ormLinkSet: $sAttCode is not a link set");
102		}
103		$this->sClass = $oAttDef->GetLinkedClass();
104		if ($oOriginalSet && ($oOriginalSet->GetClass() != $this->sClass))
105		{
106			throw new Exception("ormLinkSet: wrong class for the original set, found {$oOriginalSet->GetClass()} while expecting {$oAttDef->GetLinkedClass()}");
107		}
108	}
109
110	public function GetFilter()
111	{
112		return clone $this->oOriginalSet->GetFilter();
113	}
114
115	/**
116	 * Specify the subset of attributes to load (for each class of objects) before performing the SQL query for retrieving the rows from the DB
117	 *
118	 * @param hash $aAttToLoad Format: alias => array of attribute_codes
119	 *
120	 * @return void
121	 */
122	public function OptimizeColumnLoad($aAttToLoad)
123	{
124		$this->oOriginalSet->OptimizeColumnLoad($aAttToLoad);
125	}
126
127	/**
128	 * @param DBObject $oLink
129	 */
130	public function AddItem(DBObject $oLink)
131	{
132		assert($oLink instanceof $this->sClass);
133		// No impact on the iteration algorithm
134        $iObjectId = $oLink->GetKey();
135		$this->aAdded[$iObjectId] = $oLink;
136		$this->bHasDelta = true;
137	}
138
139    /**
140     * @param DBObject $oObject
141     * @param string $sClassAlias
142     * @deprecated Since iTop 2.4, use ormLinkset->AddItem() instead.
143     */
144	public function AddObject(DBObject $oObject, $sClassAlias = '')
145    {
146        $this->AddItem($oObject);
147    }
148
149	/**
150	 * @param $iObjectId
151	 */
152	public function RemoveItem($iObjectId)
153	{
154		if (array_key_exists($iObjectId, $this->aPreserved))
155		{
156			unset($this->aPreserved[$iObjectId]);
157			$this->aRemoved[$iObjectId] = $iObjectId;
158			$this->bHasDelta = true;
159		}
160		else
161        {
162            if (array_key_exists($iObjectId, $this->aAdded))
163            {
164                unset($this->aAdded[$iObjectId]);
165            }
166        }
167	}
168
169	/**
170	 * @param DBObject $oLink
171	 */
172	public function ModifyItem(DBObject $oLink)
173	{
174		assert($oLink instanceof $this->sClass);
175
176		$iObjectId = $oLink->GetKey();
177        if (array_key_exists($iObjectId, $this->aPreserved))
178        {
179            unset($this->aPreserved[$iObjectId]);
180            $this->aModified[$iObjectId] = $oLink;
181            $this->bHasDelta = true;
182        }
183	}
184
185	protected function LoadOriginalIds()
186	{
187		if ($this->aOriginalObjects === null)
188		{
189			if ($this->oOriginalSet)
190			{
191				$this->aOriginalObjects = $this->GetArrayOfIndex();
192				$this->aPreserved = $this->aOriginalObjects; // Copy (not effective until aPreserved gets modified)
193                foreach ($this->aRemoved as $iObjectId)
194                {
195                    if (array_key_exists($iObjectId, $this->aPreserved))
196                    {
197                        unset($this->aPreserved[$iObjectId]);
198                    }
199                }
200                foreach ($this->aModified as $iObjectId => $oLink)
201                {
202                    if (array_key_exists($iObjectId, $this->aPreserved))
203                    {
204                        unset($this->aPreserved[$iObjectId]);
205                    }
206                }
207			}
208			else
209			{
210
211				// Nothing to load
212				$this->aOriginalObjects = array();
213				$this->aPreserved = array();
214			}
215		}
216	}
217
218	/**
219	 * Note: After calling this method, the set cursor will be at the end of the set. You might want to rewind it.
220	 * @return array
221	 */
222	protected function GetArrayOfIndex()
223	{
224		$aRet = array();
225		$this->oOriginalSet->Rewind();
226		$iRow = 0;
227		while ($oObject = $this->oOriginalSet->Fetch())
228		{
229			$aRet[$oObject->GetKey()] = $iRow++;
230		}
231		return $aRet;
232	}
233
234    /**
235     * @param bool $bWithId
236     * @return array
237     * @deprecated Since iTop 2.4, use foreach($this as $oItem){} instead
238     */
239    public function ToArray($bWithId = true)
240    {
241        $aRet = array();
242        foreach($this as $oItem)
243        {
244            if ($bWithId)
245            {
246                $aRet[$oItem->GetKey()] = $oItem;
247            }
248            else
249            {
250                $aRet[] = $oItem;
251            }
252        }
253        return $aRet;
254    }
255
256    /**
257     * @param string $sAttCode
258     * @param bool $bWithId
259     * @return array
260     */
261    public function GetColumnAsArray($sAttCode, $bWithId = true)
262    {
263        $aRet = array();
264        foreach($this as $oItem)
265        {
266            if ($bWithId)
267            {
268                $aRet[$oItem->GetKey()] = $oItem->Get($sAttCode);
269            }
270            else
271            {
272                $aRet[] = $oItem->Get($sAttCode);
273            }
274        }
275        return $aRet;
276    }
277
278    /**
279	 * The class of the objects of the collection (at least a common ancestor)
280	 *
281	 * @return string
282	 */
283	public function GetClass()
284	{
285		return $this->sClass;
286	}
287
288	/**
289	 * The total number of objects in the collection
290	 *
291	 * @return int
292	 */
293	public function Count()
294	{
295		$this->LoadOriginalIds();
296		$iRet = count($this->aPreserved) + count($this->aAdded) + count($this->aModified);
297		return $iRet;
298	}
299
300	/**
301	 * Position the cursor to the given 0-based position
302	 *
303	 * @param $iPosition
304	 * @throws Exception
305	 * @internal param int $iRow
306	 */
307	public function Seek($iPosition)
308	{
309		$this->LoadOriginalIds();
310
311		$iCount = $this->Count();
312		if ($iPosition >= $iCount)
313		{
314			throw new Exception("Invalid position $iPosition: the link set is made of $iCount items.");
315		}
316		$this->rewind();
317		for($iPos = 0 ; $iPos < $iPosition ; $iPos++)
318		{
319			$this->next();
320		}
321	}
322
323	/**
324	 * Fetch the object at the current position in the collection and move the cursor to the next position.
325	 *
326	 * @return DBObject|null The fetched object or null when at the end
327	 */
328	public function Fetch()
329	{
330		$this->LoadOriginalIds();
331
332		$ret = $this->current();
333		if ($ret === false)
334		{
335			$ret = null;
336		}
337		$this->next();
338		return $ret;
339	}
340
341	/**
342	 * Return the current element
343	 * @link http://php.net/manual/en/iterator.current.php
344	 * @return mixed Can return any type.
345	 */
346	public function current()
347	{
348		$this->LoadOriginalIds();
349
350		$iPreservedCount = count($this->aPreserved);
351		if ($this->iCursor < $iPreservedCount)
352		{
353			$iRet = current($this->aPreserved);
354			$this->oOriginalSet->Seek($iRet);
355			$oRet = $this->oOriginalSet->Fetch();
356		}
357		else
358		{
359		    $iModifiedCount = count($this->aModified);
360		    if($this->iCursor < $iPreservedCount + $iModifiedCount)
361            {
362                $oRet = current($this->aModified);
363            }
364            else
365            {
366                $oRet = current($this->aAdded);
367            }
368		}
369		return $oRet;
370	}
371
372	/**
373	 * Move forward to next element
374	 * @link http://php.net/manual/en/iterator.next.php
375	 * @return void Any returned value is ignored.
376	 */
377	public function next()
378	{
379		$this->LoadOriginalIds();
380
381		$iPreservedCount = count($this->aPreserved);
382		if ($this->iCursor < $iPreservedCount)
383		{
384			next($this->aPreserved);
385		}
386		else
387		{
388		    $iModifiedCount = count($this->aModified);
389		    if($this->iCursor < $iPreservedCount + $iModifiedCount)
390            {
391                next($this->aModified);
392            }
393            else
394            {
395                next($this->aAdded);
396            }
397		}
398		// Increment AFTER moving the internal cursors because when starting aModified / aAdded, we must leave it intact
399		$this->iCursor++;
400	}
401
402	/**
403	 * Return the key of the current element
404	 * @link http://php.net/manual/en/iterator.key.php
405	 * @return mixed scalar on success, or null on failure.
406	 */
407	public function key()
408	{
409		return $this->iCursor;
410	}
411
412	/**
413	 * Checks if current position is valid
414	 * @link http://php.net/manual/en/iterator.valid.php
415	 * @return boolean The return value will be casted to boolean and then evaluated.
416	 * Returns true on success or false on failure.
417	 */
418	public function valid()
419	{
420		$this->LoadOriginalIds();
421
422		$iCount = $this->Count();
423		$bRet = ($this->iCursor < $iCount);
424		return $bRet;
425	}
426
427	/**
428	 * Rewind the Iterator to the first element
429	 * @link http://php.net/manual/en/iterator.rewind.php
430	 * @return void Any returned value is ignored.
431	 */
432	public function rewind()
433	{
434	    $this->LoadOriginalIds();
435
436	    $this->iCursor = 0;
437		reset($this->aPreserved);
438        reset($this->aAdded);
439        reset($this->aModified);
440	}
441
442	public function HasDelta()
443	{
444		return $this->bHasDelta;
445	}
446
447	/**
448	 * This method has been designed specifically for AttributeLinkedSet:Equals and as such it assumes that the passed argument is a clone of this.
449	 * @param ormLinkSet $oFellow
450	 * @return bool|null
451	 * @throws Exception
452	 */
453	public function Equals(ormLinkSet $oFellow)
454	{
455		$bRet = null;
456		if ($this === $oFellow)
457		{
458			$bRet = true;
459		}
460		else
461		{
462			if ( ($this->oOriginalSet !== $oFellow->oOriginalSet)
463			&& ($this->oOriginalSet->GetFilter()->ToOQL() != $oFellow->oOriginalSet->GetFilter()->ToOQL()) )
464			{
465				throw new Exception('ormLinkSet::Equals assumes that compared link sets have the same original scope');
466			}
467			if ($this->HasDelta())
468			{
469				throw new Exception('ormLinkSet::Equals assumes that left link set had no delta');
470			}
471			$bRet = !$oFellow->HasDelta();
472		}
473		return $bRet;
474	}
475
476	public function UpdateFromCompleteList(iDBObjectSetIterator $oFellow)
477	{
478		if ($oFellow === $this)
479		{
480			throw new Exception('ormLinkSet::UpdateFromCompleteList assumes that the passed link set is at least a clone of the current one');
481		}
482		$bUpdateFromDelta = false;
483		if ($oFellow instanceof ormLinkSet)
484		{
485			if ( ($this->oOriginalSet === $oFellow->oOriginalSet)
486				|| ($this->oOriginalSet->GetFilter()->ToOQL() == $oFellow->oOriginalSet->GetFilter()->ToOQL()) )
487			{
488				$bUpdateFromDelta = true;
489			}
490		}
491
492		if ($bUpdateFromDelta)
493		{
494			// Same original set -> simply update the delta
495			$this->iCursor = 0;
496			$this->aAdded = $oFellow->aAdded;
497			$this->aRemoved = $oFellow->aRemoved;
498			$this->aModified = $oFellow->aModified;
499			$this->aPreserved = $oFellow->aPreserved;
500			$this->bHasDelta = $oFellow->bHasDelta;
501		}
502		else
503		{
504			// For backward compatibility reasons, let's rebuild a delta...
505
506			// Reset the delta
507			$this->iCursor = 0;
508			$this->aAdded = array();
509			$this->aRemoved = array();
510			$this->aModified = array();
511			$this->aPreserved = ($this->aOriginalObjects === null) ? array() : $this->aOriginalObjects;
512			$this->bHasDelta = false;
513
514			/** @var AttributeLinkedSet $oAttDef */
515			$oAttDef = MetaModel::GetAttributeDef($this->sHostClass, $this->sAttCode);
516			$sExtKeyToMe = $oAttDef->GetExtKeyToMe();
517			$sAdditionalKey = null;
518			if ($oAttDef->IsIndirect() && !$oAttDef->DuplicatesAllowed())
519			{
520				$sAdditionalKey = $oAttDef->GetExtKeyToRemote();
521			}
522			// Compare both collections by iterating the whole sets, order them, a build a fingerprint based on meaningful data (what make the difference)
523			$oComparator = new DBObjectSetComparator($this, $oFellow, array($sExtKeyToMe), $sAdditionalKey);
524			$aChanges = $oComparator->GetDifferences();
525			foreach ($aChanges['added'] as $oLink)
526			{
527				$this->AddItem($oLink);
528			}
529
530			foreach ($aChanges['modified'] as $oLink)
531			{
532				$this->ModifyItem($oLink);
533			}
534
535			foreach ($aChanges['removed'] as $oLink)
536			{
537				$this->RemoveItem($oLink->GetKey());
538			}
539		}
540	}
541
542	/**
543	 * Get the list of all modified (added, modified and removed) links
544	 *
545	 * @return array of link objects
546	 * @throws \Exception
547	 */
548	public function ListModifiedLinks()
549	{
550		$aAdded = $this->aAdded;
551		$aModified = $this->aModified;
552		$aRemoved = array();
553		if (count($this->aRemoved) > 0)
554		{
555			$oSearch = new DBObjectSearch($this->sClass);
556			$oSearch->AddCondition('id', $this->aRemoved, 'IN');
557			$oSet = new DBObjectSet($oSearch);
558			$aRemoved = $oSet->ToArray();
559		}
560		return array_merge($aAdded, $aModified, $aRemoved);
561	}
562
563	/**
564	 * @param DBObject $oHostObject
565	 */
566	public function DBWrite(DBObject $oHostObject)
567	{
568		/** @var AttributeLinkedSet $oAttDef */
569		$oAttDef = MetaModel::GetAttributeDef(get_class($oHostObject), $this->sAttCode);
570		$sExtKeyToMe = $oAttDef->GetExtKeyToMe();
571		$sExtKeyToRemote = $oAttDef->IsIndirect() ? $oAttDef->GetExtKeyToRemote() : 'n/a';
572
573		$aCheckLinks = array();
574		$aCheckRemote = array();
575		foreach ($this->aAdded as $oLink)
576		{
577			if ($oLink->IsNew())
578			{
579				if ($oAttDef->IsIndirect() && !$oAttDef->DuplicatesAllowed())
580				{
581					//todo: faire un test qui passe dans cette branche !
582					$aCheckRemote[] = $oLink->Get($sExtKeyToRemote);
583				}
584			}
585			else
586			{
587				//todo: faire un test qui passe dans cette branche !
588				$aCheckLinks[] = $oLink->GetKey();
589			}
590		}
591		foreach ($this->aRemoved as $iLinkId)
592		{
593			$aCheckLinks[] = $iLinkId;
594		}
595		foreach ($this->aModified as $iLinkId => $oLink)
596		{
597			$aCheckLinks[] = $oLink->GetKey();
598		}
599
600		// Critical section : serialize any write access to these links
601		//
602		$oMtx = new iTopMutex('Write-'.$this->sClass);
603		$oMtx->Lock();
604
605		// Check for the existing links
606		//
607		/** @var DBObject[] $aExistingLinks */
608		$aExistingLinks = array();
609		/** @var Int[] $aExistingRemote */
610		$aExistingRemote = array();
611		if (count($aCheckLinks) > 0)
612		{
613			$oSearch = new DBObjectSearch($this->sClass);
614			$oSearch->AddCondition('id', $aCheckLinks, 'IN');
615			$oSet = new DBObjectSet($oSearch);
616			$aExistingLinks = $oSet->ToArray();
617		}
618
619		// Check for the existing remote objects
620		//
621		if (count($aCheckRemote) > 0)
622		{
623			$oSearch = new DBObjectSearch($this->sClass);
624			$oSearch->AddCondition($sExtKeyToMe, $oHostObject->GetKey(), '=');
625			$oSearch->AddCondition($sExtKeyToRemote, $aCheckRemote, 'IN');
626			$oSet = new DBObjectSet($oSearch);
627			$aExistingRemote = $oSet->GetColumnAsArray($sExtKeyToRemote, true);
628		}
629
630		// Write the links according to the existing links
631		//
632		foreach ($this->aAdded as $oLink)
633		{
634			// Make sure that the objects in the set point to "this"
635			$oLink->Set($sExtKeyToMe, $oHostObject->GetKey());
636
637			if ($oLink->IsNew())
638			{
639				if (count($aCheckRemote) > 0)
640				{
641				    $bIsDuplicate = false;
642				    foreach($aExistingRemote as $sLinkKey => $sExtKey)
643                    {
644                        if ($sExtKey == $oLink->Get($sExtKeyToRemote))
645                        {
646                            // Do not create a duplicate
647                            // + In the case of a remove action followed by an add action
648                            // of an existing link,
649                            // the final state to consider is add action,
650                            // so suppress the entry in the removed list.
651                            if (array_key_exists($sLinkKey, $this->aRemoved))
652                            {
653                                unset($this->aRemoved[$sLinkKey]);
654                            }
655                            $bIsDuplicate = true;
656                            break;
657                        }
658                    }
659                    if ($bIsDuplicate)
660                    {
661                        continue;
662                    }
663				}
664
665			}
666			else
667			{
668				if (!array_key_exists($oLink->GetKey(), $aExistingLinks))
669				{
670					$oLink->DBClone();
671				}
672			}
673			$oLink->DBWrite();
674		}
675		foreach ($this->aRemoved as $iLinkId)
676		{
677			if (array_key_exists($iLinkId, $aExistingLinks))
678			{
679				$oLink = $aExistingLinks[$iLinkId];
680				if ($oAttDef->IsIndirect())
681				{
682					$oLink->DBDelete();
683				}
684				else
685				{
686					$oExtKeyToRemote = MetaModel::GetAttributeDef($this->sClass, $sExtKeyToMe);
687					if ($oExtKeyToRemote->IsNullAllowed())
688					{
689						if ($oLink->Get($sExtKeyToMe) == $oHostObject->GetKey())
690						{
691							// Detach the link object from this
692							$oLink->Set($sExtKeyToMe, 0);
693							$oLink->DBUpdate();
694						}
695					}
696					else
697					{
698						$oLink->DBDelete();
699					}
700				}
701			}
702		}
703		// Note: process modifications at the end: if a link to remove has also been listed as modified, then it will be gracefully ignored
704		foreach ($this->aModified as $iLinkId => $oLink)
705		{
706			if (array_key_exists($oLink->GetKey(), $aExistingLinks))
707			{
708				$oLink->DBUpdate();
709			}
710			else
711			{
712				$oLink->DBClone();
713			}
714		}
715
716		// End of the critical section
717		//
718		$oMtx->Unlock();
719	}
720
721	public function ToDBObjectSet($bShowObsolete = true)
722	{
723		$oAttDef = MetaModel::GetAttributeDef($this->sHostClass, $this->sAttCode);
724		$oLinkSearch = $this->GetFilter();
725		if ($oAttDef->IsIndirect())
726		{
727			$sExtKeyToRemote = $oAttDef->GetExtKeyToRemote();
728			$oLinkingAttDef = MetaModel::GetAttributeDef($this->sClass, $sExtKeyToRemote);
729			$sTargetClass = $oLinkingAttDef->GetTargetClass();
730			if (!$bShowObsolete && MetaModel::IsObsoletable($sTargetClass))
731			{
732				$oNotObsolete = new BinaryExpression(
733					new FieldExpression('obsolescence_flag', $sTargetClass),
734					'=',
735					new ScalarExpression(0)
736				);
737				$oNotObsoleteRemote = new DBObjectSearch($sTargetClass);
738				$oNotObsoleteRemote->AddConditionExpression($oNotObsolete);
739				$oLinkSearch->AddCondition_PointingTo($oNotObsoleteRemote, $sExtKeyToRemote);
740			}
741		}
742		$oLinkSet = new DBObjectSet($oLinkSearch);
743		$oLinkSet->SetShowObsoleteData($bShowObsolete);
744		if ($this->HasDelta())
745		{
746			$oLinkSet->AddObjectArray($this->aAdded);
747		}
748		return $oLinkSet;
749	}
750}