MDL-50851 core_tag: introduce tag collections
[moodle.git] / lib / classes / task / tag_cron_task.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  * A scheduled task.
19  *
20  * @package    core
21  * @copyright  2013 onwards Martin Dougiamas  http://dougiamas.com
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 namespace core\task;
26 use core_tag_collection, core_tag_tag, core_tag_area, stdClass;
28 /**
29  * Simple task to run the tag cron.
30  */
31 class tag_cron_task extends scheduled_task {
33     /**
34      * Get a descriptive name for this task (shown to admins).
35      *
36      * @return string
37      */
38     public function get_name() {
39         return get_string('tasktagcron', 'admin');
40     }
42     /**
43      * Do the job.
44      * Throw exceptions on errors (the job will be retried).
45      */
46     public function execute() {
47         global $CFG;
49         if (!empty($CFG->usetags)) {
50             $this->compute_correlations();
51             $this->cleanup();
52         }
53     }
55     /**
56      * Calculates and stores the correlated tags of all tags.
57      *
58      * The correlations are stored in the 'tag_correlation' table.
59      *
60      * Two tags are correlated if they appear together a lot. Ex.: Users tagged with "computers"
61      * will probably also be tagged with "algorithms".
62      *
63      * The rationale for the 'tag_correlation' table is performance. It works as a cache
64      * for a potentially heavy load query done at the 'tag_instance' table. So, the
65      * 'tag_correlation' table stores redundant information derived from the 'tag_instance' table.
66      *
67      * @param int $mincorrelation Only tags with more than $mincorrelation correlations will be identified.
68      */
69     public function compute_correlations($mincorrelation = 2) {
70         global $DB;
72         // This mighty one line query fetches a row from the database for every
73         // individual tag correlation. We then need to process the rows collecting
74         // the correlations for each tag id.
75         // The fields used by this query are as follows:
76         //   tagid         : This is the tag id, there should be at least $mincorrelation
77         //                   rows for each tag id.
78         //   correlation   : This is the tag id that correlates to the above tagid field.
79         //   correlationid : This is the id of the row in the tag_correlation table that
80         //                   relates to the tagid field and will be NULL if there are no
81         //                   existing correlations.
82         $sql = 'SELECT pairs.tagid, pairs.correlation, pairs.ocurrences, co.id AS correlationid
83                   FROM (
84                            SELECT ta.tagid, tb.tagid AS correlation, COUNT(*) AS ocurrences
85                              FROM {tag_instance} ta
86                              JOIN {tag} tga ON ta.tagid = tga.id
87                              JOIN {tag_instance} tb ON (ta.itemtype = tb.itemtype AND ta.component = tb.component
88                                 AND ta.itemid = tb.itemid AND ta.tagid <> tb.tagid)
89                              JOIN {tag} tgb ON tb.tagid = tgb.id AND tgb.tagcollid = tga.tagcollid
90                          GROUP BY ta.tagid, tb.tagid
91                            HAVING COUNT(*) > :mincorrelation
92                        ) pairs
93              LEFT JOIN {tag_correlation} co ON co.tagid = pairs.tagid
94               ORDER BY pairs.tagid ASC, pairs.ocurrences DESC, pairs.correlation ASC';
95         $rs = $DB->get_recordset_sql($sql, array('mincorrelation' => $mincorrelation));
97         // Set up an empty tag correlation object.
98         $tagcorrelation = new stdClass;
99         $tagcorrelation->id = null;
100         $tagcorrelation->tagid = null;
101         $tagcorrelation->correlatedtags = array();
103         // We store each correlation id in this array so we can remove any correlations
104         // that no longer exist.
105         $correlations = array();
107         // Iterate each row of the result set and build them into tag correlations.
108         // We add all of a tag's correlations to $tagcorrelation->correlatedtags[]
109         // then save the $tagcorrelation object.
110         foreach ($rs as $row) {
111             if ($row->tagid != $tagcorrelation->tagid) {
112                 // The tag id has changed so we have all of the correlations for this tag.
113                 $tagcorrelationid = $this->process_computed_correlation($tagcorrelation);
114                 if ($tagcorrelationid) {
115                     $correlations[] = $tagcorrelationid;
116                 }
117                 // Now we reset the tag correlation object so we can reuse it and set it
118                 // up for the current record.
119                 $tagcorrelation = new stdClass;
120                 $tagcorrelation->id = $row->correlationid;
121                 $tagcorrelation->tagid = $row->tagid;
122                 $tagcorrelation->correlatedtags = array();
123             }
124             // Save the correlation on the tag correlation object.
125             $tagcorrelation->correlatedtags[] = $row->correlation;
126         }
127         // Update the current correlation after the last record.
128         $tagcorrelationid = $this->process_computed_correlation($tagcorrelation);
129         if ($tagcorrelationid) {
130             $correlations[] = $tagcorrelationid;
131         }
133         // Close the recordset.
134         $rs->close();
136         // Remove any correlations that weren't just identified.
137         if (empty($correlations)) {
138             // There are no tag correlations.
139             $DB->delete_records('tag_correlation');
140         } else {
141             list($sql, $params) = $DB->get_in_or_equal($correlations,
142                     SQL_PARAMS_NAMED, 'param0000', false);
143             $DB->delete_records_select('tag_correlation', 'id '.$sql, $params);
144         }
145     }
147     /**
148      * Clean up the tag tables, making sure all tagged object still exists.
149      *
150      * This method is called from cron.
151      *
152      * This should normally not be necessary, but in case related tags are not deleted
153      * when the tagged record is removed, this should be done once in a while, perhaps
154      * on an occasional cron run.  On a site with lots of tags, this could become an
155      * expensive function to call.
156      */
157     public function cleanup() {
158         global $DB;
160         // Get ids to delete from instances where the tag has been deleted. This should never happen apparently.
161         $sql = "SELECT ti.id
162                   FROM {tag_instance} ti
163              LEFT JOIN {tag} t ON t.id = ti.tagid
164                  WHERE t.id IS null";
165         $tagids = $DB->get_records_sql($sql);
166         $tagarray = array();
167         foreach ($tagids as $tagid) {
168             $tagarray[] = $tagid->id;
169         }
171         // Next get ids from instances that have an owner that has been deleted.
172         $sql = "SELECT ti.id
173                   FROM {tag_instance} ti, {user} u
174                  WHERE ti.itemid = u.id
175                    AND ti.itemtype = 'user'
176                    AND ti.component = 'core'
177                    AND u.deleted = 1";
178         $tagids = $DB->get_records_sql($sql);
179         foreach ($tagids as $tagid) {
180             $tagarray[] = $tagid->id;
181         }
183         // Get the other itemtypes.
184         $sql = "SELECT DISTINCT component, itemtype
185                   FROM {tag_instance}
186                  WHERE itemtype <> 'user' or component <> 'core'";
187         $tagareas = $DB->get_records_sql($sql);
188         foreach ($tagareas as $tagarea) {
189             $sql = 'SELECT ti.id
190                       FROM {tag_instance} ti
191                  LEFT JOIN {' . $tagarea->itemtype . '} it ON it.id = ti.itemid
192                      WHERE it.id IS null
193                      AND ti.itemtype = ? AND ti.component = ?';
194             $tagids = $DB->get_records_sql($sql, array($tagarea->itemtype, $tagarea->component));
195             foreach ($tagids as $tagid) {
196                 $tagarray[] = $tagid->id;
197             }
198         }
200         // Get instances for each of the ids to be deleted.
201         if (count($tagarray) > 0) {
202             list($sqlin, $params) = $DB->get_in_or_equal($tagarray);
203             $sql = "SELECT ti.*, COALESCE(t.name, 'deleted') AS name, COALESCE(t.rawname, 'deleted') AS rawname
204                       FROM {tag_instance} ti
205                  LEFT JOIN {tag} t ON t.id = ti.tagid
206                      WHERE ti.id $sqlin";
207             $instances = $DB->get_records_sql($sql, $params);
208             $this->bulk_delete_instances($instances);
209         }
211         core_tag_collection::cleanup_unused_tags();
212     }
214     /**
215      * This function processes a tag correlation and makes changes in the database as required.
216      *
217      * The tag correlation object needs have both a tagid property and a correlatedtags property that is an array.
218      *
219      * @param   stdClass $tagcorrelation
220      * @return  int/bool The id of the tag correlation that was just processed or false.
221      */
222     public function process_computed_correlation(stdClass $tagcorrelation) {
223         global $DB;
225         // You must provide a tagid and correlatedtags must be set and be an array.
226         if (empty($tagcorrelation->tagid) || !isset($tagcorrelation->correlatedtags) ||
227                 !is_array($tagcorrelation->correlatedtags)) {
228             return false;
229         }
231         $tagcorrelation->correlatedtags = join(',', $tagcorrelation->correlatedtags);
232         if (!empty($tagcorrelation->id)) {
233             // The tag correlation already exists so update it.
234             $DB->update_record('tag_correlation', $tagcorrelation);
235         } else {
236             // This is a new correlation to insert.
237             $tagcorrelation->id = $DB->insert_record('tag_correlation', $tagcorrelation);
238         }
239         return $tagcorrelation->id;
240     }
242     /**
243      * This function will delete numerous tag instances efficiently.
244      * This removes tag instances only. It doesn't check to see if it is the last use of a tag.
245      *
246      * @param array $instances An array of tag instance objects with the addition of the tagname and tagrawname
247      *        (used for recording a delete event).
248      */
249     public function bulk_delete_instances($instances) {
250         global $DB;
252         $instanceids = array();
253         foreach ($instances as $instance) {
254             $instanceids[] = $instance->id;
255         }
257         // This is a multi db compatible method of creating the correct sql when using the 'IN' value.
258         // $insql is the sql statement, $params are the id numbers.
259         list($insql, $params) = $DB->get_in_or_equal($instanceids);
260         $sql = 'id ' . $insql;
261         $DB->delete_records_select('tag_instance', $sql, $params);
263         // Now go through and record each tag individually with the event system.
264         foreach ($instances as $instance) {
265             // Trigger tag removed event (i.e. The tag instance has been removed).
266             \core\event\tag_removed::create_from_tag_instance($instance, $instance->name,
267                     $instance->rawname, true)->trigger();
268         }
269     }