MDL-53363 tags: helper to search for tagged items; search course modules
[moodle.git] / tag / classes / index_builder.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Class core_tag_index_builder
19  *
20  * @package   core_tag
21  * @copyright 2016 Marina Glancy
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 /**
28  * Helper to build tag index
29  *
30  * This can be used by components to implement tag area callbacks. This is especially
31  * useful for in-course content when we need to check and cache user's access to
32  * multiple courses. Course access and accessible items are stored in session cache
33  * with 15 minutes expiry time.
34  *
35  * Example of usage:
36  *
37  * $builder = new core_tag_index_builder($component, $itemtype, $sql, $params, $from, $limit);
38  * while ($item = $builder->has_item_that_needs_access_check()) {
39  *     if (!$builder->can_access_course($item->courseid)) {
40  *         $builder->set_accessible($item, false);
41  *     } else {
42  *         $accessible = true; // Check access and set $accessible respectively.
43  *         $builder->set_accessible($item, $accessible);
44  *     }
45  * }
46  * $items = $builder->get_items();
47  *
48  * @package   core_tag
49  * @copyright 2016 Marina Glancy
50  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
51  */
52 class core_tag_index_builder {
54     /** @var string component specified in the constructor */
55     protected $component;
57     /** @var string itemtype specified in the constructor */
58     protected $itemtype;
60     /** @var string SQL statement */
61     protected $sql;
63     /** @var array parameters for SQL statement */
64     protected $params;
66     /** @var int index from which to return records */
67     protected $from;
69     /** @var int maximum number of records to return */
70     protected $limit;
72     /** @var array result of SQL query */
73     protected $items;
75     /** @var array list of item ids ( array_keys($this->items) ) */
76     protected $itemkeys;
78     /** @var string alias of the item id in the SQL result */
79     protected $idfield = 'id';
81     /** @var array cache of items accessibility (id => bool) */
82     protected $accessibleitems;
84     /** @var array cache of courses accessibility (courseid => bool) */
85     protected $courseaccess;
87     /** @var bool indicates that items cache was changed in this class and needs pushing to MUC */
88     protected $cachechangedaccessible = false;
90     /** @var bool indicates that course accessibiity cache was changed in this class and needs pushing to MUC */
91     protected $cachechangedcourse = false;
93     /** @var array cached courses (not pushed to MUC) */
94     protected $courses;
96     /**
97      * Constructor.
98      *
99      * Specify the SQL query for retrieving the tagged items, SQL query must:
100      * - return the item id as the first field and make sure that it is unique in the result
101      * - provide ORDER BY that exclude any possibility of random results, if $fromctx was specified when searching
102      *   for tagged items it is the best practice to make sure that items from this context are returned first.
103      *
104      * This query may also contain placeholders %COURSEFILTER% or %ITEMFILTER% that will be substituted with
105      * expressions excluding courses and/or filters that are already known as inaccessible.
106      *
107      * Example: "WHERE c.id %COURSEFILTER% AND cm.id %ITEMFILTER%"
108      *
109      * This query may contain fields to preload context if context is needed for formatting values.
110      *
111      * It is recommended to sort by course sortorder first, this way the items from the same course will be next to
112      * each other and the sequence of courses will the same in different tag areas.
113      *
114      * @param string $component component responsible for tagging
115      * @param string $itemtype type of item that is being tagged
116      * @param string $sql SQL query that would retrieve all relevant items without permission check
117      * @param array $params parameters for the query (must be named)
118      * @param int $from return a subset of records, starting at this point
119      * @param int $limit return a subset comprising this many records in total (this field is NOT optional)
120      */
121     public function __construct($component, $itemtype, $sql, $params, $from, $limit) {
122         $this->component = preg_replace('/[^A-Za-z0-9_]/i', '', $component);
123         $this->itemtype = preg_replace('/[^A-Za-z0-9_]/i', '', $itemtype);
124         $this->sql = $sql;
125         $this->params = $params;
126         $this->from = $from;
127         $this->limit = $limit;
128         $this->courses = array();
129     }
131     /**
132      * Substitute %COURSEFILTER% with an expression filtering out courses where current user does not have access
133      */
134     protected function prepare_sql_courses() {
135         global $DB;
136         if (!preg_match('/\\%COURSEFILTER\\%/', $this->sql)) {
137             return;
138         }
139         $this->init_course_access();
140         $unaccessiblecourses = array_filter($this->courseaccess, function($item) {
141             return !$item;
142         });
143         $idx = 0;
144         while (preg_match('/^([^\\0]*?)\\%COURSEFILTER\\%([^\\0]*)$/', $this->sql, $matches)) {
145             list($sql, $params) = $DB->get_in_or_equal(array_keys($unaccessiblecourses),
146                     SQL_PARAMS_NAMED, 'ca_'.($idx++).'_', false, 0);
147             $this->sql = $matches[1].' '.$sql.' '.$matches[2];
148             $this->params += $params;
149         }
150     }
152     /**
153      * Substitute %ITEMFILTER% with an expression filtering out items where current user does not have access
154      */
155     protected function prepare_sql_items() {
156         global $DB;
157         if (!preg_match('/\\%ITEMFILTER\\%/', $this->sql)) {
158             return;
159         }
160         $this->init_items_access();
161         $unaccessibleitems = array_filter($this->accessibleitems, function($item) {
162             return !$item;
163         });
164         $idx = 0;
165         while (preg_match('/^([^\\0]*?)\\%ITEMFILTER\\%([^\\0]*)$/', $this->sql, $matches)) {
166             list($sql, $params) = $DB->get_in_or_equal(array_keys($unaccessibleitems),
167                     SQL_PARAMS_NAMED, 'ia_'.($idx++).'_', false, 0);
168             $this->sql = $matches[1].' '.$sql.' '.$matches[2];
169             $this->params += $params;
170         }
171     }
173     /**
174      * Ensures that SQL query was executed and $this->items is filled
175      */
176     protected function retrieve_items() {
177         global $DB;
178         if ($this->items !== null) {
179             return;
180         }
181         $this->prepare_sql_courses();
182         $this->prepare_sql_items();
183         $this->items = $DB->get_records_sql($this->sql, $this->params);
184         $this->itemkeys = array_keys($this->items);
185         if ($this->items) {
186             // Find the name of the first key of the item - usually 'id' but can be something different.
187             // This must be a unique identifier of the item.
188             $firstitem = reset($this->items);
189             $firstitemarray = (array)$firstitem;
190             $this->idfield = key($firstitemarray);
191         }
192     }
194     /**
195      * Returns the filtered records from SQL query result.
196      *
197      * This function can only be executed after $builder->has_item_that_needs_access_check() returns null
198      *
199      *
200      * @return array
201      */
202     public function get_items() {
203         global $DB, $CFG;
204         if (is_siteadmin()) {
205             $this->sql = preg_replace('/\\%COURSEFILTER\\%/', '<>0', $this->sql);
206             $this->sql = preg_replace('/\\%ITEMFILTER\\%/', '<>0', $this->sql);
207             return $DB->get_records_sql($this->sql, $this->params, $this->from, $this->limit);
208         }
209         if ($CFG->debugdeveloper && $this->has_item_that_needs_access_check()) {
210             debugging('Caller must ensure that has_item_that_needs_access_check() does not return anything '
211                     . 'before calling get_items(). The item list may be incomplete', DEBUG_DEVELOPER);
212         }
213         $this->retrieve_items();
214         $this->save_caches();
215         $idx = 0;
216         $items = array();
217         foreach ($this->itemkeys as $id) {
218             if (!array_key_exists($id, $this->accessibleitems) || !$this->accessibleitems[$id]) {
219                 continue;
220             }
221             if ($idx >= $this->from) {
222                 $items[$id] = $this->items[$id];
223             }
224             $idx++;
225             if ($idx >= $this->from + $this->limit) {
226                 break;
227             }
228         }
229         return $items;
230     }
232     /**
233      * Returns the first row from the SQL result that we don't know whether it is accessible by user or not.
234      *
235      * This will return null when we have necessary number of accessible items to return in {@link get_items()}
236      *
237      * After analyzing you may decide to mark not only this record but all similar as accessible or not accessible.
238      * For example, if you already call get_fast_modinfo() to check this item's accessibility, why not mark all
239      * items in the same course as accessible or not accessible.
240      *
241      * Helpful methods: {@link set_accessible()} and {@link walk()}
242      *
243      * @return null|object
244      */
245     public function has_item_that_needs_access_check() {
246         if (is_siteadmin()) {
247             return null;
248         }
249         $this->retrieve_items();
250         $counter = 0; // Counter for accessible items.
251         foreach ($this->itemkeys as $id) {
252             if (!array_key_exists($id, $this->accessibleitems)) {
253                 return (object)(array)$this->items[$id];
254             }
255             $counter += $this->accessibleitems[$id] ? 1 : 0;
256             if ($counter >= $this->from + $this->limit) {
257                 // We found enough accessible items fot get_items() method, do not look any further.
258                 return null;
259             }
260         }
261         return null;
262     }
264     /**
265      * Walk through the array of items and call $callable for each of them
266      * @param callable $callable
267      */
268     public function walk($callable) {
269         $this->retrieve_items();
270         array_walk($this->items, $callable);
271     }
273     /**
274      * Marks record or group of records as accessible (or not accessible)
275      *
276      * @param int|std_Class $identifier either record id of the item that needs to be set accessible
277      * @param bool $accessible whether to mark as accessible or not accessible (default true)
278      */
279     public function set_accessible($identifier, $accessible = true) {
280         if (is_object($identifier)) {
281             $identifier = (int)($identifier->{$this->idfield});
282         }
283         $this->init_items_access();
284         if (is_int($identifier)) {
285             $accessible = (int)(bool)$accessible;
286             if (!array_key_exists($identifier, $this->accessibleitems) ||
287                     $this->accessibleitems[$identifier] != $accessible) {
288                 $this->accessibleitems[$identifier] = $accessible;
289                 $this->cachechangedaccessible;
290             }
291         } else {
292             throw new coding_exception('Argument $identifier must be either int or object');
293         }
294     }
296     /**
297      * Retrieves a course record (only fields id,visible,fullname,shortname,cacherev).
298      *
299      * This method is useful because it also caches results and preloads course context.
300      *
301      * @param int $courseid
302      */
303     public function get_course($courseid) {
304         global $DB;
305         if (!array_key_exists($courseid, $this->courses)) {
306             $ctxquery = context_helper::get_preload_record_columns_sql('ctx');
307             $sql = "SELECT c.id,c.visible,c.fullname,c.shortname,c.cacherev, $ctxquery
308             FROM {course} c JOIN {context} ctx ON ctx.contextlevel = ? AND ctx.instanceid=c.id
309             WHERE c.id = ?";
310             $params = array(CONTEXT_COURSE, $courseid);
311             $this->courses[$courseid] = $DB->get_record_sql($sql, $params);
312             context_helper::preload_from_record($this->courses[$courseid]);
313         }
314         return $this->courses[$courseid];
315     }
317     /**
318      * Ensures that we read the course access from the cache.
319      */
320     protected function init_course_access() {
321         if ($this->courseaccess === null) {
322             $this->courseaccess = cache::make('core', 'tagindexbuilder')->get('courseaccess') ?: [];
323         }
324     }
326     /**
327      * Ensures that we read the items access from the cache.
328      */
329     protected function init_items_access() {
330         if ($this->accessibleitems === null) {
331             $this->accessibleitems = cache::make('core', 'tagindexbuilder')->get($this->component.'__'.$this->itemtype) ?: [];
332         }
333     }
335     /**
336      * Checks if current user has access to the course
337      *
338      * This method calls global function {@link can_access_course} and caches results
339      *
340      * @param int $courseid
341      * @return bool
342      */
343     public function can_access_course($courseid) {
344         $this->init_course_access();
345         if (!array_key_exists($courseid, $this->courseaccess)) {
346             $this->courseaccess[$courseid] = can_access_course($this->get_course($courseid)) ? 1 : 0;
347             $this->cachechangedcourse = true;
348         }
349         return $this->courseaccess[$courseid];
350     }
352     /**
353      * Saves course/items caches if needed
354      */
355     protected function save_caches() {
356         if ($this->cachechangedcourse) {
357             cache::make('core', 'tagindexbuilder')->set('courseaccess', $this->courseaccess);
358             $this->cachechangedcourse = false;
359         }
360         if ($this->cachechangedaccessible) {
361             cache::make('core', 'tagindexbuilder')->set($this->component.'__'.$this->itemtype,
362                     $this->accessibleitems);
363             $this->cachechangedaccessible = false;
364         }
365     }
367     /**
368      * Resets all course/items session caches - useful in unittests when we change users and enrolments.
369      */
370     public static function reset_caches() {
371         cache_helper::purge_by_event('resettagindexbuilder');
372     }