1<?php
2
3namespace Drupal\Core\TypedData\Plugin\DataType;
4
5use Drupal\Core\TypedData\ComplexDataInterface;
6use Drupal\Core\TypedData\ListInterface;
7use Drupal\Core\TypedData\TypedData;
8use Drupal\Core\TypedData\TypedDataInterface;
9
10/**
11 * A generic list class.
12 *
13 * This class can serve as list for any type of items and is used by default.
14 * Data types may specify the default list class in their definition, see
15 * Drupal\Core\TypedData\Annotation\DataType.
16 * Note: The class cannot be called "List" as list is a reserved PHP keyword.
17 *
18 * @ingroup typed_data
19 *
20 * @DataType(
21 *   id = "list",
22 *   label = @Translation("List of items"),
23 *   definition_class = "\Drupal\Core\TypedData\ListDataDefinition"
24 * )
25 */
26class ItemList extends TypedData implements \IteratorAggregate, ListInterface {
27
28  /**
29   * Numerically indexed array of items.
30   *
31   * @var \Drupal\Core\TypedData\TypedDataInterface[]
32   */
33  protected $list = [];
34
35  /**
36   * {@inheritdoc}
37   */
38  public function getValue() {
39    $values = [];
40    foreach ($this->list as $delta => $item) {
41      $values[$delta] = $item->getValue();
42    }
43    return $values;
44  }
45
46  /**
47   * Overrides \Drupal\Core\TypedData\TypedData::setValue().
48   *
49   * @param array|null $values
50   *   An array of values of the field items, or NULL to unset the field.
51   * @param bool $notify
52   *   (optional) Whether to notify the parent object of the change. Defaults to
53   *   TRUE.
54   */
55  public function setValue($values, $notify = TRUE) {
56    if (!isset($values) || $values === []) {
57      $this->list = [];
58    }
59    else {
60      // Only arrays with numeric keys are supported.
61      if (!is_array($values)) {
62        throw new \InvalidArgumentException('Cannot set a list with a non-array value.');
63      }
64      // Assign incoming values. Keys are renumbered to ensure 0-based
65      // sequential deltas. If possible, reuse existing items rather than
66      // creating new ones.
67      foreach (array_values($values) as $delta => $value) {
68        if (!isset($this->list[$delta])) {
69          $this->list[$delta] = $this->createItem($delta, $value);
70        }
71        else {
72          $this->list[$delta]->setValue($value, FALSE);
73        }
74      }
75      // Truncate extraneous pre-existing values.
76      $this->list = array_slice($this->list, 0, count($values));
77    }
78    // Notify the parent of any changes.
79    if ($notify && isset($this->parent)) {
80      $this->parent->onChange($this->name);
81    }
82  }
83
84  /**
85   * {@inheritdoc}
86   */
87  public function getString() {
88    $strings = [];
89    foreach ($this->list as $item) {
90      $strings[] = $item->getString();
91    }
92    // Remove any empty strings resulting from empty items.
93    return implode(', ', array_filter($strings, 'mb_strlen'));
94  }
95
96  /**
97   * {@inheritdoc}
98   */
99  public function get($index) {
100    if (!is_numeric($index)) {
101      throw new \InvalidArgumentException('Unable to get a value with a non-numeric delta in a list.');
102    }
103    return isset($this->list[$index]) ? $this->list[$index] : NULL;
104  }
105
106  /**
107   * {@inheritdoc}
108   */
109  public function set($index, $value) {
110    if (!is_numeric($index)) {
111      throw new \InvalidArgumentException('Unable to set a value with a non-numeric delta in a list.');
112    }
113    // Ensure indexes stay sequential. We allow assigning an item at an existing
114    // index, or at the next index available.
115    if ($index < 0 || $index > count($this->list)) {
116      throw new \InvalidArgumentException('Unable to set a value to a non-subsequent delta in a list.');
117    }
118    // Support setting values via typed data objects.
119    if ($value instanceof TypedDataInterface) {
120      $value = $value->getValue();
121    }
122    // If needed, create the item at the next position.
123    $item = isset($this->list[$index]) ? $this->list[$index] : $this->appendItem();
124    $item->setValue($value);
125    return $this;
126  }
127
128  /**
129   * {@inheritdoc}
130   */
131  public function removeItem($index) {
132    if (isset($this->list) && array_key_exists($index, $this->list)) {
133      // Remove the item, and reassign deltas.
134      unset($this->list[$index]);
135      $this->rekey($index);
136    }
137    else {
138      throw new \InvalidArgumentException('Unable to remove item at non-existing index.');
139    }
140    return $this;
141  }
142
143  /**
144   * Renumbers the items in the list.
145   *
146   * @param int $from_index
147   *   Optionally, the index at which to start the renumbering, if it is known
148   *   that items before that can safely be skipped (for example, when removing
149   *   an item at a given index).
150   */
151  protected function rekey($from_index = 0) {
152    // Re-key the list to maintain consecutive indexes.
153    $this->list = array_values($this->list);
154    // Each item holds its own index as a "name", it needs to be updated
155    // according to the new list indexes.
156    for ($i = $from_index; $i < count($this->list); $i++) {
157      $this->list[$i]->setContext($i, $this);
158    }
159  }
160
161  /**
162   * {@inheritdoc}
163   */
164  public function first() {
165    return $this->get(0);
166  }
167
168  /**
169   * {@inheritdoc}
170   */
171  public function offsetExists($offset) {
172    // We do not want to throw exceptions here, so we do not use get().
173    return isset($this->list[$offset]);
174  }
175
176  /**
177   * {@inheritdoc}
178   */
179  public function offsetUnset($offset) {
180    $this->removeItem($offset);
181  }
182
183  /**
184   * {@inheritdoc}
185   */
186  public function offsetGet($offset) {
187    return $this->get($offset);
188  }
189
190  /**
191   * {@inheritdoc}
192   */
193  public function offsetSet($offset, $value) {
194    if (!isset($offset)) {
195      // The [] operator has been used.
196      $this->appendItem($value);
197    }
198    else {
199      $this->set($offset, $value);
200    }
201  }
202
203  /**
204   * {@inheritdoc}
205   */
206  public function appendItem($value = NULL) {
207    $offset = count($this->list);
208    $item = $this->createItem($offset, $value);
209    $this->list[$offset] = $item;
210    return $item;
211  }
212
213  /**
214   * Helper for creating a list item object.
215   *
216   * @return \Drupal\Core\TypedData\TypedDataInterface
217   */
218  protected function createItem($offset = 0, $value = NULL) {
219    return $this->getTypedDataManager()->getPropertyInstance($this, $offset, $value);
220  }
221
222  /**
223   * {@inheritdoc}
224   */
225  public function getItemDefinition() {
226    return $this->definition->getItemDefinition();
227  }
228
229  /**
230   * {@inheritdoc}
231   */
232  public function getIterator() {
233    return new \ArrayIterator($this->list);
234  }
235
236  /**
237   * {@inheritdoc}
238   */
239  public function count() {
240    return count($this->list);
241  }
242
243  /**
244   * {@inheritdoc}
245   */
246  public function isEmpty() {
247    foreach ($this->list as $item) {
248      if ($item instanceof ComplexDataInterface || $item instanceof ListInterface) {
249        if (!$item->isEmpty()) {
250          return FALSE;
251        }
252      }
253      // Other items are treated as empty if they have no value only.
254      elseif ($item->getValue() !== NULL) {
255        return FALSE;
256      }
257    }
258    return TRUE;
259  }
260
261  /**
262   * {@inheritdoc}
263   */
264  public function filter($callback) {
265    if (isset($this->list)) {
266      $removed = FALSE;
267      // Apply the filter, detecting if some items were actually removed.
268      $this->list = array_filter($this->list, function ($item) use ($callback, &$removed) {
269        if (call_user_func($callback, $item)) {
270          return TRUE;
271        }
272        else {
273          $removed = TRUE;
274        }
275      });
276      if ($removed) {
277        $this->rekey();
278      }
279    }
280    return $this;
281  }
282
283  /**
284   * {@inheritdoc}
285   */
286  public function onChange($delta) {
287    // Notify the parent of changes.
288    if (isset($this->parent)) {
289      $this->parent->onChange($this->name);
290    }
291  }
292
293  /**
294   * Magic method: Implements a deep clone.
295   */
296  public function __clone() {
297    foreach ($this->list as $delta => $item) {
298      $this->list[$delta] = clone $item;
299      $this->list[$delta]->setContext($delta, $this);
300    }
301  }
302
303}
304