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/>.
16
17/**
18 * Search area base class for blocks.
19 *
20 * Note: Only blocks within courses are supported.
21 *
22 * @package core_search
23 * @copyright 2017 The Open University
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
27namespace core_search;
28
29defined('MOODLE_INTERNAL') || die();
30
31/**
32 * Search area base class for blocks.
33 *
34 * Note: Only blocks within courses are supported.
35 *
36 * @package core_search
37 * @copyright 2017 The Open University
38 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39 */
40abstract class base_block extends base {
41    /** @var string Cache name used for block instances */
42    const CACHE_INSTANCES = 'base_block_instances';
43
44    /**
45     * The context levels the search area is working on.
46     *
47     * This can be overwriten by the search area if it works at multiple
48     * levels.
49     *
50     * @var array
51     */
52    protected static $levels = [CONTEXT_BLOCK];
53
54    /**
55     * Gets the block name only.
56     *
57     * @return string Block name e.g. 'html'
58     */
59    public function get_block_name() {
60        // Remove 'block_' text.
61        return substr($this->get_component_name(), 6);
62    }
63
64    /**
65     * Returns restrictions on which block_instances rows to return. By default, excludes rows
66     * that have empty configdata.
67     *
68     * If no restriction is required, you could return ['', []].
69     *
70     * @return array 2-element array of SQL restriction and params for it
71     */
72    protected function get_indexing_restrictions() {
73        global $DB;
74
75        // This includes completely empty configdata, and also three other values that are
76        // equivalent to empty:
77        // - A serialized completely empty object.
78        // - A serialized object with one field called '0' (string not int) set to boolean false
79        //   (this can happen after backup and restore, at least historically).
80        // - A serialized null.
81        $stupidobject = (object)[];
82        $zero = '0';
83        $stupidobject->{$zero} = false;
84        return [$DB->sql_compare_text('bi.configdata') . " != ? AND " .
85                $DB->sql_compare_text('bi.configdata') . " != ? AND " .
86                $DB->sql_compare_text('bi.configdata') . " != ? AND " .
87                $DB->sql_compare_text('bi.configdata') . " != ?",
88                ['', base64_encode(serialize((object)[])), base64_encode(serialize($stupidobject)),
89                base64_encode(serialize(null))]];
90    }
91
92    /**
93     * Gets recordset of all blocks of this type modified since given time within the given context.
94     *
95     * See base class for detailed requirements. This implementation includes the key fields
96     * from block_instances.
97     *
98     * This can be overridden to do something totally different if the block's data is stored in
99     * other tables.
100     *
101     * If there are certain instances of the block which should not be included in the search index
102     * then you can override get_indexing_restrictions; by default this excludes rows with empty
103     * configdata.
104     *
105     * @param int $modifiedfrom Return only records modified after this date
106     * @param \context|null $context Context to find blocks within
107     * @return false|\moodle_recordset|null
108     */
109    public function get_document_recordset($modifiedfrom = 0, \context $context = null) {
110        global $DB;
111
112        // Get context restrictions.
113        list ($contextjoin, $contextparams) = $this->get_context_restriction_sql($context, 'bi');
114
115        // Get custom restrictions for block type.
116        list ($restrictions, $restrictionparams) = $this->get_indexing_restrictions();
117        if ($restrictions) {
118            $restrictions = 'AND ' . $restrictions;
119        }
120
121        // Query for all entries in block_instances for this type of block, within the specified
122        // context. The query is based on the one from get_recordset_by_timestamp and applies the
123        // same restrictions.
124        return $DB->get_recordset_sql("
125                SELECT bi.id, bi.timemodified, bi.timecreated, bi.configdata,
126                       c.id AS courseid, x.id AS contextid
127                  FROM {block_instances} bi
128                       $contextjoin
129                  JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ?
130                  JOIN {context} parent ON parent.id = bi.parentcontextid
131             LEFT JOIN {course_modules} cm ON cm.id = parent.instanceid AND parent.contextlevel = ?
132                  JOIN {course} c ON c.id = cm.course
133                       OR (c.id = parent.instanceid AND parent.contextlevel = ?)
134                 WHERE bi.timemodified >= ?
135                       AND bi.blockname = ?
136                       AND (parent.contextlevel = ? AND (" . $DB->sql_like('bi.pagetypepattern', '?') . "
137                           OR bi.pagetypepattern IN ('site-index', 'course-*', '*')))
138                       $restrictions
139              ORDER BY bi.timemodified ASC",
140                array_merge($contextparams, [CONTEXT_BLOCK, CONTEXT_MODULE, CONTEXT_COURSE,
141                    $modifiedfrom, $this->get_block_name(), CONTEXT_COURSE, 'course-view-%'],
142                $restrictionparams));
143    }
144
145    public function get_doc_url(\core_search\document $doc) {
146        // Load block instance and find cmid if there is one.
147        $blockinstanceid = preg_replace('~^.*-~', '', $doc->get('id'));
148        $instance = $this->get_block_instance($blockinstanceid);
149        $courseid = $doc->get('courseid');
150        $anchor = 'inst' . $blockinstanceid;
151
152        // Check if the block is at course or module level.
153        if ($instance->cmid) {
154            // No module-level page types are supported at present so the search system won't return
155            // them. But let's put some example code here to indicate how it could work.
156            debugging('Unexpected module-level page type for block ' . $blockinstanceid . ': ' .
157                    $instance->pagetypepattern, DEBUG_DEVELOPER);
158            $modinfo = get_fast_modinfo($courseid);
159            $cm = $modinfo->get_cm($instance->cmid);
160            return new \moodle_url($cm->url, null, $anchor);
161        } else {
162            // The block is at course level. Let's check the page type, although in practice we
163            // currently only support the course main page.
164            if ($instance->pagetypepattern === '*' || $instance->pagetypepattern === 'course-*' ||
165                    preg_match('~^course-view-(.*)$~', $instance->pagetypepattern)) {
166                return new \moodle_url('/course/view.php', ['id' => $courseid], $anchor);
167            } else if ($instance->pagetypepattern === 'site-index') {
168                return new \moodle_url('/', ['redirect' => 0], $anchor);
169            } else {
170                debugging('Unexpected page type for block ' . $blockinstanceid . ': ' .
171                        $instance->pagetypepattern, DEBUG_DEVELOPER);
172                return new \moodle_url('/course/view.php', ['id' => $courseid], $anchor);
173            }
174        }
175    }
176
177    public function get_context_url(\core_search\document $doc) {
178        return $this->get_doc_url($doc);
179    }
180
181    /**
182     * Checks access for a document in this search area.
183     *
184     * If you override this function for a block, you should call this base class version first
185     * as it will check that the block is still visible to users in a supported location.
186     *
187     * @param int $id Document id
188     * @return int manager:ACCESS_xx constant
189     */
190    public function check_access($id) {
191        $instance = $this->get_block_instance($id, IGNORE_MISSING);
192        if (!$instance) {
193            // This generally won't happen because if the block has been deleted then we won't have
194            // included its context in the search area list, but just in case.
195            return manager::ACCESS_DELETED;
196        }
197
198        // Check block has not been moved to an unsupported area since it was indexed. (At the
199        // moment, only blocks within site and course context are supported, also only certain
200        // page types.)
201        if (!$instance->courseid ||
202                !self::is_supported_page_type_at_course_context($instance->pagetypepattern)) {
203            return manager::ACCESS_DELETED;
204        }
205
206        // Note we do not need to check if the block was hidden or if the user has access to the
207        // context, because those checks are included in the list of search contexts user can access
208        // that is calculated in manager.php every time they do a query.
209        return manager::ACCESS_GRANTED;
210    }
211
212    /**
213     * Checks if a page type is supported for blocks when at course (or also site) context. This
214     * function should be consistent with the SQL in get_recordset_by_timestamp.
215     *
216     * @param string $pagetype Page type
217     * @return bool True if supported
218     */
219    protected static function is_supported_page_type_at_course_context($pagetype) {
220        if (in_array($pagetype, ['site-index', 'course-*', '*'])) {
221            return true;
222        }
223        if (preg_match('~^course-view-~', $pagetype)) {
224            return true;
225        }
226        return false;
227    }
228
229    /**
230     * Gets a block instance with given id.
231     *
232     * Returns the fields id, pagetypepattern, subpagepattern from block_instances and also the
233     * cmid (if parent context is an activity module).
234     *
235     * @param int $id ID of block instance
236     * @param int $strictness MUST_EXIST or IGNORE_MISSING
237     * @return false|mixed Block instance data (may be false if strictness is IGNORE_MISSING)
238     */
239    protected function get_block_instance($id, $strictness = MUST_EXIST) {
240        global $DB;
241
242        $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_search',
243                self::CACHE_INSTANCES, [], ['simplekeys' => true]);
244        $id = (int)$id;
245        $instance = $cache->get($id);
246        if (!$instance) {
247            $instance = $DB->get_record_sql("
248                    SELECT bi.id, bi.pagetypepattern, bi.subpagepattern,
249                           c.id AS courseid, cm.id AS cmid
250                      FROM {block_instances} bi
251                      JOIN {context} parent ON parent.id = bi.parentcontextid
252                 LEFT JOIN {course} c ON c.id = parent.instanceid AND parent.contextlevel = ?
253                 LEFT JOIN {course_modules} cm ON cm.id = parent.instanceid AND parent.contextlevel = ?
254                     WHERE bi.id = ?",
255                    [CONTEXT_COURSE, CONTEXT_MODULE, $id], $strictness);
256            $cache->set($id, $instance);
257        }
258        return $instance;
259    }
260
261    /**
262     * Clears static cache. This function can be removed (with calls to it in the test script
263     * replaced with cache_helper::purge_all) if MDL-59427 is fixed.
264     */
265    public static function clear_static() {
266        \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_search',
267                self::CACHE_INSTANCES, [], ['simplekeys' => true])->purge();
268    }
269
270    /**
271     * Helper function that gets SQL useful for restricting a search query given a passed-in
272     * context.
273     *
274     * The SQL returned will be one or more JOIN statements, surrounded by whitespace, which act
275     * as restrictions on the query based on the rows in the block_instances table.
276     *
277     * We assume the block instances have already been restricted by blockname.
278     *
279     * Returns null if there can be no results for this block within this context.
280     *
281     * If named parameters are used, these will be named gcrs0, gcrs1, etc. The table aliases used
282     * in SQL also all begin with gcrs, to avoid conflicts.
283     *
284     * @param \context|null $context Context to restrict the query
285     * @param string $blocktable Alias of block_instances table
286     * @param int $paramtype Type of SQL parameters to use (default question mark)
287     * @return array Array with SQL and parameters
288     * @throws \coding_exception If called with invalid params
289     */
290    protected function get_context_restriction_sql(\context $context = null, $blocktable = 'bi',
291            $paramtype = SQL_PARAMS_QM) {
292        global $DB;
293
294        if (!$context) {
295            return ['', []];
296        }
297
298        switch ($paramtype) {
299            case SQL_PARAMS_QM:
300                $param1 = '?';
301                $param2 = '?';
302                $key1 = 0;
303                $key2 = 1;
304                break;
305            case SQL_PARAMS_NAMED:
306                $param1 = ':gcrs0';
307                $param2 = ':gcrs1';
308                $key1 = 'gcrs0';
309                $key2 = 'gcrs1';
310                break;
311            default:
312                throw new \coding_exception('Unexpected $paramtype: ' . $paramtype);
313        }
314
315        $params = [];
316        switch ($context->contextlevel) {
317            case CONTEXT_SYSTEM:
318                $sql = '';
319                break;
320
321            case CONTEXT_COURSECAT:
322            case CONTEXT_COURSE:
323            case CONTEXT_MODULE:
324            case CONTEXT_USER:
325                // Find all blocks whose parent is within the specified context.
326                $sql = " JOIN {context} gcrsx ON gcrsx.id = $blocktable.parentcontextid
327                              AND (gcrsx.id = $param1 OR " . $DB->sql_like('gcrsx.path', $param2) . ") ";
328                $params[$key1] = $context->id;
329                $params[$key2] = $context->path . '/%';
330                break;
331
332            case CONTEXT_BLOCK:
333                // Find only the specified block of this type. Since we are generating JOINs
334                // here, we do this by joining again to the block_instances table with the same ID.
335                $sql = " JOIN {block_instances} gcrsbi ON gcrsbi.id = $blocktable.id
336                              AND gcrsbi.id = $param1 ";
337                $params[$key1] = $context->instanceid;
338                break;
339
340            default:
341                throw new \coding_exception('Unexpected contextlevel: ' . $context->contextlevel);
342        }
343
344        return [$sql, $params];
345    }
346
347    /**
348     * This can be used in subclasses to change ordering within the get_contexts_to_reindex
349     * function.
350     *
351     * It returns 2 values:
352     * - Extra SQL joins (tables block_instances 'bi' and context 'x' already exist).
353     * - An ORDER BY value which must use aggregate functions, by default 'MAX(bi.timemodified) DESC'.
354     *
355     * Note the query already includes a GROUP BY on the context fields, so if your joins result
356     * in multiple rows, you can use aggregate functions in the ORDER BY. See forum for an example.
357     *
358     * @return string[] Array with 2 elements; extra joins for the query, and ORDER BY value
359     */
360    protected function get_contexts_to_reindex_extra_sql() {
361        return ['', 'MAX(bi.timemodified) DESC'];
362    }
363
364    /**
365     * Gets a list of all contexts to reindex when reindexing this search area.
366     *
367     * For blocks, the default is to return all contexts for blocks of that type, that are on a
368     * course page, in order of time added (most recent first).
369     *
370     * @return \Iterator Iterator of contexts to reindex
371     * @throws \moodle_exception If any DB error
372     */
373    public function get_contexts_to_reindex() {
374        global $DB;
375
376        list ($extrajoins, $dborder) = $this->get_contexts_to_reindex_extra_sql();
377        $contexts = [];
378        $selectcolumns = \context_helper::get_preload_record_columns_sql('x');
379        $groupbycolumns = '';
380        foreach (\context_helper::get_preload_record_columns('x') as $column => $thing) {
381            if ($groupbycolumns !== '') {
382                $groupbycolumns .= ',';
383            }
384            $groupbycolumns .= $column;
385        }
386        $rs = $DB->get_recordset_sql("
387                SELECT $selectcolumns
388                  FROM {block_instances} bi
389                  JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ?
390                  JOIN {context} parent ON parent.id = bi.parentcontextid
391                       $extrajoins
392                 WHERE bi.blockname = ? AND parent.contextlevel = ?
393              GROUP BY $groupbycolumns
394              ORDER BY $dborder", [CONTEXT_BLOCK, $this->get_block_name(), CONTEXT_COURSE]);
395        return new \core\dml\recordset_walk($rs, function($rec) {
396            $id = $rec->ctxid;
397            \context_helper::preload_from_record($rec);
398            return \context::instance_by_id($id);
399        });
400    }
401
402    /**
403     * Returns an icon instance for the document.
404     *
405     * @param \core_search\document $doc
406     * @return \core_search\document_icon
407     */
408    public function get_doc_icon(document $doc) : document_icon {
409        return new document_icon('e/anchor');
410    }
411
412    /**
413     * Returns a list of category names associated with the area.
414     *
415     * @return array
416     */
417    public function get_category_names() {
418        return [manager::SEARCH_AREA_CATEGORY_COURSE_CONTENT];
419    }
420}
421