1 <?php
2 /**
3 * @package Joomla.Legacy
4 * @subpackage Model
5 *
6 * @copyright Copyright (C) 2005 - 2017 Open Source Matters, Inc. All rights reserved.
7 * @license GNU General Public License version 2 or later; see LICENSE.txt
8 */
9
10 defined('JPATH_PLATFORM') or die;
11
12 use Joomla\Utilities\ArrayHelper;
13
14 /**
15 * Model class for handling lists of items.
16 *
17 * @since 1.6
18 */
19 class JModelList extends JModelLegacy
20 {
21 /**
22 * Internal memory based cache array of data.
23 *
24 * @var array
25 * @since 1.6
26 */
27 protected $cache = array();
28
29 /**
30 * Context string for the model type. This is used to handle uniqueness
31 * when dealing with the getStoreId() method and caching data structures.
32 *
33 * @var string
34 * @since 1.6
35 */
36 protected $context = null;
37
38 /**
39 * Valid filter fields or ordering.
40 *
41 * @var array
42 * @since 1.6
43 */
44 protected $filter_fields = array();
45
46 /**
47 * An internal cache for the last query used.
48 *
49 * @var JDatabaseQuery[]
50 * @since 1.6
51 */
52 protected $query = array();
53
54 /**
55 * Name of the filter form to load
56 *
57 * @var string
58 * @since 3.2
59 */
60 protected $filterFormName = null;
61
62 /**
63 * Associated HTML form
64 *
65 * @var string
66 * @since 3.2
67 */
68 protected $htmlFormName = 'adminForm';
69
70 /**
71 * A blacklist of filter variables to not merge into the model's state
72 *
73 * @var array
74 * @since 3.4.5
75 */
76 protected $filterBlacklist = array();
77
78 /**
79 * A blacklist of list variables to not merge into the model's state
80 *
81 * @var array
82 * @since 3.4.5
83 */
84 protected $listBlacklist = array('select');
85
86 /**
87 * Constructor.
88 *
89 * @param array $config An optional associative array of configuration settings.
90 *
91 * @see JModelLegacy
92 * @since 1.6
93 */
94 public function __construct($config = array())
95 {
96 parent::__construct($config);
97
98 // Add the ordering filtering fields whitelist.
99 if (isset($config['filter_fields']))
100 {
101 $this->filter_fields = $config['filter_fields'];
102 }
103
104 // Guess the context as Option.ModelName.
105 if (empty($this->context))
106 {
107 $this->context = strtolower($this->option . '.' . $this->getName());
108 }
109 }
110
111 /**
112 * Method to cache the last query constructed.
113 *
114 * This method ensures that the query is constructed only once for a given state of the model.
115 *
116 * @return JDatabaseQuery A JDatabaseQuery object
117 *
118 * @since 1.6
119 */
120 protected function _getListQuery()
121 {
122 // Capture the last store id used.
123 static $lastStoreId;
124
125 // Compute the current store id.
126 $currentStoreId = $this->getStoreId();
127
128 // If the last store id is different from the current, refresh the query.
129 if ($lastStoreId != $currentStoreId || empty($this->query))
130 {
131 $lastStoreId = $currentStoreId;
132 $this->query = $this->getListQuery();
133 }
134
135 return $this->query;
136 }
137
138 /**
139 * Function to get the active filters
140 *
141 * @return array Associative array in the format: array('filter_published' => 0)
142 *
143 * @since 3.2
144 */
145 public function getActiveFilters()
146 {
147 $activeFilters = array();
148
149 if (!empty($this->filter_fields))
150 {
151 foreach ($this->filter_fields as $filter)
152 {
153 $filterName = 'filter.' . $filter;
154
155 if (property_exists($this->state, $filterName) && (!empty($this->state->{$filterName}) || is_numeric($this->state->{$filterName})))
156 {
157 $activeFilters[$filter] = $this->state->get($filterName);
158 }
159 }
160 }
161
162 return $activeFilters;
163 }
164
165 /**
166 * Method to get an array of data items.
167 *
168 * @return mixed An array of data items on success, false on failure.
169 *
170 * @since 1.6
171 */
172 public function getItems()
173 {
174 // Get a storage key.
175 $store = $this->getStoreId();
176
177 // Try to load the data from internal storage.
178 if (isset($this->cache[$store]))
179 {
180 return $this->cache[$store];
181 }
182
183 try
184 {
185 // Load the list items and add the items to the internal cache.
186 $this->cache[$store] = $this->_getList($this->_getListQuery(), $this->getStart(), $this->getState('list.limit'));
187 }
188 catch (RuntimeException $e)
189 {
190 $this->setError($e->getMessage());
191
192 return false;
193 }
194
195 return $this->cache[$store];
196 }
197
198 /**
199 * Method to get a JDatabaseQuery object for retrieving the data set from a database.
200 *
201 * @return JDatabaseQuery A JDatabaseQuery object to retrieve the data set.
202 *
203 * @since 1.6
204 */
205 protected function getListQuery()
206 {
207 return $this->getDbo()->getQuery(true);
208 }
209
210 /**
211 * Method to get a JPagination object for the data set.
212 *
213 * @return JPagination A JPagination object for the data set.
214 *
215 * @since 1.6
216 */
217 public function getPagination()
218 {
219 // Get a storage key.
220 $store = $this->getStoreId('getPagination');
221
222 // Try to load the data from internal storage.
223 if (isset($this->cache[$store]))
224 {
225 return $this->cache[$store];
226 }
227
228 $limit = (int) $this->getState('list.limit') - (int) $this->getState('list.links');
229
230 // Create the pagination object and add the object to the internal cache.
231 $this->cache[$store] = new JPagination($this->getTotal(), $this->getStart(), $limit);
232
233 return $this->cache[$store];
234 }
235
236 /**
237 * Method to get a store id based on the model configuration state.
238 *
239 * This is necessary because the model is used by the component and
240 * different modules that might need different sets of data or different
241 * ordering requirements.
242 *
243 * @param string $id An identifier string to generate the store id.
244 *
245 * @return string A store id.
246 *
247 * @since 1.6
248 */
249 protected function getStoreId($id = '')
250 {
251 // Add the list state to the store id.
252 $id .= ':' . $this->getState('list.start');
253 $id .= ':' . $this->getState('list.limit');
254 $id .= ':' . $this->getState('list.ordering');
255 $id .= ':' . $this->getState('list.direction');
256
257 return md5($this->context . ':' . $id);
258 }
259
260 /**
261 * Method to get the total number of items for the data set.
262 *
263 * @return integer The total number of items available in the data set.
264 *
265 * @since 1.6
266 */
267 public function getTotal()
268 {
269 // Get a storage key.
270 $store = $this->getStoreId('getTotal');
271
272 // Try to load the data from internal storage.
273 if (isset($this->cache[$store]))
274 {
275 return $this->cache[$store];
276 }
277
278 try
279 {
280 // Load the total and add the total to the internal cache.
281 $this->cache[$store] = (int) $this->_getListCount($this->_getListQuery());
282 }
283 catch (RuntimeException $e)
284 {
285 $this->setError($e->getMessage());
286
287 return false;
288 }
289
290 return $this->cache[$store];
291 }
292
293 /**
294 * Method to get the starting number of items for the data set.
295 *
296 * @return integer The starting number of items available in the data set.
297 *
298 * @since 1.6
299 */
300 public function getStart()
301 {
302 $store = $this->getStoreId('getstart');
303
304 // Try to load the data from internal storage.
305 if (isset($this->cache[$store]))
306 {
307 return $this->cache[$store];
308 }
309
310 $start = $this->getState('list.start');
311
312 if ($start > 0)
313 {
314 $limit = $this->getState('list.limit');
315 $total = $this->getTotal();
316
317 if ($start > $total - $limit)
318 {
319 $start = max(0, (int) (ceil($total / $limit) - 1) * $limit);
320 }
321 }
322
323 // Add the total to the internal cache.
324 $this->cache[$store] = $start;
325
326 return $this->cache[$store];
327 }
328
329 /**
330 * Get the filter form
331 *
332 * @param array $data data
333 * @param boolean $loadData load current data
334 *
335 * @return JForm|boolean The JForm object or false on error
336 *
337 * @since 3.2
338 */
339 public function getFilterForm($data = array(), $loadData = true)
340 {
341 $form = null;
342
343 // Try to locate the filter form automatically. Example: ContentModelArticles => "filter_articles"
344 if (empty($this->filterFormName))
345 {
346 $classNameParts = explode('Model', get_called_class());
347
348 if (count($classNameParts) == 2)
349 {
350 $this->filterFormName = 'filter_' . strtolower($classNameParts[1]);
351 }
352 }
353
354 if (!empty($this->filterFormName))
355 {
356 // Get the form.
357 $form = $this->loadForm($this->context . '.filter', $this->filterFormName, array('control' => '', 'load_data' => $loadData));
358 }
359
360 return $form;
361 }
362
363 /**
364 * Method to get a form object.
365 *
366 * @param string $name The name of the form.
367 * @param string $source The form source. Can be XML string if file flag is set to false.
368 * @param array $options Optional array of options for the form creation.
369 * @param boolean $clear Optional argument to force load a new form.
370 * @param string|boolean $xpath An optional xpath to search for the fields.
371 *
372 * @return JForm|boolean JForm object on success, False on error.
373 *
374 * @see JForm
375 * @since 3.2
376 */
377 protected function loadForm($name, $source = null, $options = array(), $clear = false, $xpath = false)
378 {
379 // Handle the optional arguments.
380 $options['control'] = ArrayHelper::getValue((array) $options, 'control', false);
381
382 // Create a signature hash.
383 $hash = md5($source . serialize($options));
384
385 // Check if we can use a previously loaded form.
386 if (isset($this->_forms[$hash]) && !$clear)
387 {
388 return $this->_forms[$hash];
389 }
390
391 // Get the form.
392 JForm::addFormPath(JPATH_COMPONENT . '/models/forms');
393 JForm::addFieldPath(JPATH_COMPONENT . '/models/fields');
394
395 try
396 {
397 $form = JForm::getInstance($name, $source, $options, false, $xpath);
398
399 if (isset($options['load_data']) && $options['load_data'])
400 {
401 // Get the data for the form.
402 $data = $this->loadFormData();
403 }
404 else
405 {
406 $data = array();
407 }
408
409 // Allow for additional modification of the form, and events to be triggered.
410 // We pass the data because plugins may require it.
411 $this->preprocessForm($form, $data);
412
413 // Load the data into the form after the plugins have operated.
414 $form->bind($data);
415 }
416 catch (Exception $e)
417 {
418 $this->setError($e->getMessage());
419
420 return false;
421 }
422
423 // Store the form for later.
424 $this->_forms[$hash] = $form;
425
426 return $form;
427 }
428
429 /**
430 * Method to get the data that should be injected in the form.
431 *
432 * @return mixed The data for the form.
433 *
434 * @since 3.2
435 */
436 protected function loadFormData()
437 {
438 // Check the session for previously entered form data.
439 $data = JFactory::getApplication()->getUserState($this->context, new stdClass);
440
441 // Pre-fill the list options
442 if (!property_exists($data, 'list'))
443 {
444 $data->list = array(
445 'direction' => $this->getState('list.direction'),
446 'limit' => $this->getState('list.limit'),
447 'ordering' => $this->getState('list.ordering'),
448 'start' => $this->getState('list.start'),
449 );
450 }
451
452 return $data;
453 }
454
455 /**
456 * Method to auto-populate the model state.
457 *
458 * This method should only be called once per instantiation and is designed
459 * to be called on the first call to the getState() method unless the model
460 * configuration flag to ignore the request is set.
461 *
462 * Note. Calling getState in this method will result in recursion.
463 *
464 * @param string $ordering An optional ordering field.
465 * @param string $direction An optional direction (asc|desc).
466 *
467 * @return void
468 *
469 * @since 1.6
470 */
471 protected function populateState($ordering = null, $direction = null)
472 {
473 // If the context is set, assume that stateful lists are used.
474 if ($this->context)
475 {
476 $app = JFactory::getApplication();
477 $inputFilter = JFilterInput::getInstance();
478
479 // Receive & set filters
480 if ($filters = $app->getUserStateFromRequest($this->context . '.filter', 'filter', array(), 'array'))
481 {
482 foreach ($filters as $name => $value)
483 {
484 // Exclude if blacklisted
485 if (!in_array($name, $this->filterBlacklist))
486 {
487 $this->setState('filter.' . $name, $value);
488 }
489 }
490 }
491
492 $limit = 0;
493
494 // Receive & set list options
495 if ($list = $app->getUserStateFromRequest($this->context . '.list', 'list', array(), 'array'))
496 {
497 foreach ($list as $name => $value)
498 {
499 // Exclude if blacklisted
500 if (!in_array($name, $this->listBlacklist))
501 {
502 // Extra validations
503 switch ($name)
504 {
505 case 'fullordering':
506 $orderingParts = explode(' ', $value);
507
508 if (count($orderingParts) >= 2)
509 {
510 // Latest part will be considered the direction
511 $fullDirection = end($orderingParts);
512
513 if (in_array(strtoupper($fullDirection), array('ASC', 'DESC', '')))
514 {
515 $this->setState('list.direction', $fullDirection);
516 }
517 else
518 {
519 $this->setState('list.direction', $direction);
520
521 // Fallback to the default value
522 $value = $ordering . ' ' . $direction;
523 }
524
525 unset($orderingParts[count($orderingParts) - 1]);
526
527 // The rest will be the ordering
528 $fullOrdering = implode(' ', $orderingParts);
529
530 if (in_array($fullOrdering, $this->filter_fields))
531 {
532 $this->setState('list.ordering', $fullOrdering);
533 }
534 else
535 {
536 $this->setState('list.ordering', $ordering);
537
538 // Fallback to the default value
539 $value = $ordering . ' ' . $direction;
540 }
541
542 }
543 else
544 {
545 $this->setState('list.ordering', $ordering);
546 $this->setState('list.direction', $direction);
547
548 // Fallback to the default value
549 $value = $ordering . ' ' . $direction;
550 }
551 break;
552
553 case 'ordering':
554 if (!in_array($value, $this->filter_fields))
555 {
556 $value = $ordering;
557 }
558 break;
559
560 case 'direction':
561 if (!in_array(strtoupper($value), array('ASC', 'DESC', '')))
562 {
563 $value = $direction;
564 }
565 break;
566
567 case 'limit':
568 $value = $inputFilter->clean($value, 'int');
569 $limit = $value;
570 break;
571
572 case 'select':
573 $explodedValue = explode(',', $value);
574
575 foreach ($explodedValue as &$field)
576 {
577 $field = $inputFilter->clean($field, 'cmd');
578 }
579
580 $value = implode(',', $explodedValue);
581 break;
582 }
583
584 $this->setState('list.' . $name, $value);
585 }
586 }
587 }
588 else
589 // Keep B/C for components previous to jform forms for filters
590 {
591 // Pre-fill the limits
592 $limit = $app->getUserStateFromRequest('global.list.limit', 'limit', $app->get('list_limit'), 'uint');
593 $this->setState('list.limit', $limit);
594
595 // Check if the ordering field is in the whitelist, otherwise use the incoming value.
596 $value = $app->getUserStateFromRequest($this->context . '.ordercol', 'filter_order', $ordering);
597
598 if (!in_array($value, $this->filter_fields))
599 {
600 $value = $ordering;
601 $app->setUserState($this->context . '.ordercol', $value);
602 }
603
604 $this->setState('list.ordering', $value);
605
606 // Check if the ordering direction is valid, otherwise use the incoming value.
607 $value = $app->getUserStateFromRequest($this->context . '.orderdirn', 'filter_order_Dir', $direction);
608
609 if (!in_array(strtoupper($value), array('ASC', 'DESC', '')))
610 {
611 $value = $direction;
612 $app->setUserState($this->context . '.orderdirn', $value);
613 }
614
615 $this->setState('list.direction', $value);
616 }
617
618 // Support old ordering field
619 $oldOrdering = $app->input->get('filter_order');
620
621 if (!empty($oldOrdering) && in_array($oldOrdering, $this->filter_fields))
622 {
623 $this->setState('list.ordering', $oldOrdering);
624 }
625
626 // Support old direction field
627 $oldDirection = $app->input->get('filter_order_Dir');
628
629 if (!empty($oldDirection) && in_array(strtoupper($oldDirection), array('ASC', 'DESC', '')))
630 {
631 $this->setState('list.direction', $oldDirection);
632 }
633
634 $value = $app->getUserStateFromRequest($this->context . '.limitstart', 'limitstart', 0, 'int');
635 $limitstart = ($limit != 0 ? (floor($value / $limit) * $limit) : 0);
636 $this->setState('list.start', $limitstart);
637 }
638 else
639 {
640 $this->setState('list.start', 0);
641 $this->setState('list.limit', 0);
642 }
643 }
644
645 /**
646 * Method to allow derived classes to preprocess the form.
647 *
648 * @param JForm $form A JForm object.
649 * @param mixed $data The data expected for the form.
650 * @param string $group The name of the plugin group to import (defaults to "content").
651 *
652 * @return void
653 *
654 * @since 3.2
655 * @throws Exception if there is an error in the form event.
656 */
657 protected function preprocessForm(JForm $form, $data, $group = 'content')
658 {
659 // Import the appropriate plugin group.
660 JPluginHelper::importPlugin($group);
661
662 // Get the dispatcher.
663 $dispatcher = JEventDispatcher::getInstance();
664
665 // Trigger the form preparation event.
666 $results = $dispatcher->trigger('onContentPrepareForm', array($form, $data));
667
668 // Check for errors encountered while preparing the form.
669 if (count($results) && in_array(false, $results, true))
670 {
671 // Get the last error.
672 $error = $dispatcher->getError();
673
674 if (!($error instanceof Exception))
675 {
676 throw new Exception($error);
677 }
678 }
679 }
680
681 /**
682 * Gets the value of a user state variable and sets it in the session
683 *
684 * This is the same as the method in JApplication except that this also can optionally
685 * force you back to the first page when a filter has changed
686 *
687 * @param string $key The key of the user state variable.
688 * @param string $request The name of the variable passed in a request.
689 * @param string $default The default value for the variable if not found. Optional.
690 * @param string $type Filter for the variable, for valid values see {@link JFilterInput::clean()}. Optional.
691 * @param boolean $resetPage If true, the limitstart in request is set to zero
692 *
693 * @return mixed The request user state.
694 *
695 * @since 1.6
696 */
697 public function getUserStateFromRequest($key, $request, $default = null, $type = 'none', $resetPage = true)
698 {
699 $app = JFactory::getApplication();
700 $input = $app->input;
701 $old_state = $app->getUserState($key);
702 $cur_state = (!is_null($old_state)) ? $old_state : $default;
703 $new_state = $input->get($request, null, $type);
704
705 // BC for Search Tools which uses different naming
706 if ($new_state === null && strpos($request, 'filter_') === 0)
707 {
708 $name = substr($request, 7);
709 $filters = $app->input->get('filter', array(), 'array');
710
711 if (isset($filters[$name]))
712 {
713 $new_state = $filters[$name];
714 }
715 }
716
717 if ($cur_state != $new_state && $new_state !== null && $resetPage)
718 {
719 $input->set('limitstart', 0);
720 }
721
722 // Save the new value only if it is set in this request.
723 if ($new_state !== null)
724 {
725 $app->setUserState($key, $new_state);
726 }
727 else
728 {
729 $new_state = $cur_state;
730 }
731
732 return $new_state;
733 }
734
735 /**
736 * Parse and transform the search string into a string fit for regex-ing arbitrary strings against
737 *
738 * @param string $search The search string
739 * @param string $regexDelimiter The regex delimiter to use for the quoting
740 *
741 * @return string Search string escaped for regex
742 *
743 * @since 3.4
744 */
745 protected function refineSearchStringToRegex($search, $regexDelimiter = '/')
746 {
747 $searchArr = explode('|', trim($search, ' |'));
748
749 foreach ($searchArr as $key => $searchString)
750 {
751 if (strlen(trim($searchString)) == 0)
752 {
753 unset($searchArr[$key]);
754 continue;
755 }
756
757 $searchArr[$key] = str_replace(' ', '.*', preg_quote(trim($searchString), $regexDelimiter));
758 }
759
760 return implode('|', $searchArr);
761 }
762 }
763