a48033abddec36d1ae38861820058068be07ec57
[moodle.git] / mod / glossary / classes / entry_query_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  * Entry query builder.
19  *
20  * @package    mod_glossary
21  * @copyright  2015 Frédéric Massart - FMCorz.net
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 /**
28  * Entry query builder class.
29  *
30  * The purpose of this class is to avoid duplicating SQL statements to fetch entries
31  * which are very similar with each other. This builder is not meant to be smart, it
32  * will not out rule any previously set condition, or join, etc...
33  *
34  * You should be using this builder just like you would be creating your SQL query. Only
35  * some methods are shorthands to avoid logic duplication and common mistakes.
36  *
37  * @package    mod_glossary
38  * @copyright  2015 Frédéric Massart - FMCorz.net
39  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40  */
41 class mod_glossary_entry_query_builder {
43     /** Alias for table glossary_alias. */
44     const ALIAS_ALIAS = 'ga';
45     /** Alias for table glossary_categories. */
46     const ALIAS_CATEGORIES = 'gc';
47     /** Alias for table glossary_entries_categories. */
48     const ALIAS_ENTRIES_CATEGORIES = 'gec';
49     /** Alias for table glossary_entries. */
50     const ALIAS_ENTRIES = 'ge';
51     /** Alias for table user. */
52     const ALIAS_USER = 'u';
54     /** Include none of the entries to approve. */
55     const NON_APPROVED_NONE = 'na_none';
56     /** Including all the entries. */
57     const NON_APPROVED_ALL = 'na_all';
58     /** Including only the entries to be approved. */
59     const NON_APPROVED_ONLY = 'na_only';
60     /** Including my entries to be approved. */
61     const NON_APPROVED_SELF = 'na_self';
63     /** @var array Raw SQL statements representing the fields to select. */
64     protected $fields = array();
65     /** @var array Raw SQL statements representing the JOINs to make. */
66     protected $joins = array();
67     /** @var string Raw SQL statement representing the FROM clause. */
68     protected $from;
69     /** @var object The glossary we are fetching from. */
70     protected $glossary;
71     /** @var int The number of records to fetch from. */
72     protected $limitfrom = 0;
73     /** @var int The number of records to fetch. */
74     protected $limitnum = 0;
75     /** @var array List of SQL parameters. */
76     protected $params = array();
77     /** @var array Raw SQL statements representing the ORDER clause. */
78     protected $order = array();
79     /** @var array Raw SQL statements representing the WHERE clause. */
80     protected $where = array();
82     /**
83      * Constructor.
84      *
85      * @param object $glossary The glossary.
86      */
87     public function __construct($glossary = null) {
88         $this->from = sprintf('FROM {glossary_entries} %s', self::ALIAS_ENTRIES);
89         if (!empty($glossary)) {
90             $this->glossary = $glossary;
91             $this->where[] = sprintf('(%s.glossaryid = :gid OR %s.sourceglossaryid = :gid2)', self::ALIAS_ENTRIES, self::ALIAS_ENTRIES);
92             $this->params['gid'] = $glossary->id;
93             $this->params['gid2'] = $glossary->id;
94         }
95     }
97     /**
98      * Add a field to select.
99      *
100      * @param string $field The field, or *.
101      * @param string $table The table name, without the prefix 'glossary_'.
102      * @param string $alias An alias for the field.
103      */
104     public function add_field($field, $table, $alias = null) {
105         $field = self::resolve_field($field, $table);
106         if (!empty($alias)) {
107             $field .= ' AS ' . $alias;
108         }
109         $this->fields[] = $field;
110     }
112     /**
113      * Adds the user fields.
114      *
115      * @return void
116      */
117     public function add_user_fields() {
118         $this->fields[] = user_picture::fields('u', null, 'userdataid', 'userdata');
119     }
121     /**
122      * Internal method to build the query.
123      *
124      * @param  boolean $count Query to count?
125      * @return string The SQL statement.
126      */
127     protected function build_query($count = false) {
128         $sql = 'SELECT ';
130         if ($count) {
131             $sql .= 'COUNT(\'x\') ';
132         } else {
133             $sql .= implode(', ', $this->fields) . ' ';
134         }
136         $sql .= $this->from . ' ';
137         $sql .= implode(' ', $this->joins) . ' ';
139         if (!empty($this->where)) {
140             $sql .= 'WHERE (' . implode(') AND (', $this->where) . ') ';
141         }
143         if (!$count && !empty($this->order)) {
144             $sql .= 'ORDER BY ' . implode(', ', $this->order);
145         }
147         return $sql;
148     }
150     /**
151      * Count the records.
152      *
153      * @return int
154      */
155     public function count_records() {
156         global $DB;
157         return $DB->count_records_sql($this->build_query(true), $this->params);
158     }
160     /**
161      * Distinct a field.
162      *
163      * @param string $field The field.
164      * @param string $table The table name, without the prefix 'glossary_'.
165      */
166     public function distinct($field, $table) {
167         $field = self::resolve_field($field, $table);
168         array_unshift($this->fields, 'DISTINCT(' . $field . ')');
169     }
171     /**
172      * Filter a field using a letter.
173      *
174      * @param  string $letter     The letter.
175      * @param  string $finalfield The SQL statement representing the field.
176      */
177     protected function filter_by_letter($letter, $finalfield) {
178         global $DB;
180         $letter = core_text::strtoupper($letter);
181         $len = core_text::strlen($letter);
182         $sql = $DB->sql_substr(sprintf('upper(%s)', $finalfield), 1, $len);
184         $this->where[] = "$sql = :letter";
185         $this->params['letter'] = $letter;
186     }
188     /**
189      * Filter a field by special characters.
190      *
191      * @param  string $finalfield The SQL statement representing the field.
192      */
193     protected function filter_by_non_letter($finalfield) {
194         global $DB;
196         $alphabet = explode(',', get_string('alphabet', 'langconfig'));
197         list($nia, $aparams) = $DB->get_in_or_equal($alphabet, SQL_PARAMS_NAMED, 'nonletter', false);
199         $sql = $DB->sql_substr(sprintf('upper(%s)', $finalfield), 1, 1);
201         $this->where[] = "$sql $nia";
202         $this->params = array_merge($this->params, $aparams);
203     }
205     /**
206      * Filter the author by letter.
207      *
208      * @param  string  $letter         The letter.
209      * @param  boolean $firstnamefirst Whether or not the firstname is first in the author's name.
210      */
211     public function filter_by_author_letter($letter, $firstnamefirst = false) {
212         $field = self::get_fullname_field($firstnamefirst);
213         $this->filter_by_letter($letter, $field);
214     }
216     /**
217      * Filter the author by special characters.
218      *
219      * @param  boolean $firstnamefirst Whether or not the firstname is first in the author's name.
220      */
221     public function filter_by_author_non_letter($firstnamefirst = false) {
222         $field = self::get_fullname_field($firstnamefirst);
223         $this->filter_by_non_letter($field);
224     }
226     /**
227      * Filter the concept by letter.
228      *
229      * @param  string  $letter         The letter.
230      */
231     public function filter_by_concept_letter($letter) {
232         $this->filter_by_letter($letter, self::resolve_field('concept', 'entries'));
233     }
235     /**
236      * Filter the concept by special characters.
237      *
238      * @return void
239      */
240     public function filter_by_concept_non_letter() {
241         $this->filter_by_non_letter(self::resolve_field('concept', 'entries'));
242     }
244     /**
245      * Filter non approved entries.
246      *
247      * @param  string $constant One of the NON_APPROVED_* constants.
248      * @param  int    $userid   The user ID when relevant, otherwise current user.
249      */
250     public function filter_by_non_approved($constant, $userid = null) {
251         global $USER;
252         if (!$userid) {
253             $userid = $USER->id;
254         }
256         if ($constant === self::NON_APPROVED_ALL) {
257             // Nothing to do.
259         } else if ($constant === self::NON_APPROVED_SELF) {
260             $this->where[] = sprintf('%s != 0 OR %s = :toapproveuserid',
261                 self::resolve_field('approved', 'entries'), self::resolve_field('userid', 'entries'));
262             $this->params['toapproveuserid'] = $USER->id;
264         } else if ($constant === self::NON_APPROVED_NONE) {
265             $this->where[] = sprintf('%s != 0', self::resolve_field('approved', 'entries'));
267         } else if ($constant === self::NON_APPROVED_ONLY) {
268             $this->where[] = sprintf('%s = 0', self::resolve_field('approved', 'entries'));
270         } else {
271             throw new coding_exception('Invalid constant');
272         }
273     }
275     /**
276      * Filter by concept or alias.
277      *
278      * This requires the alias table to be joined in the query. See {@link self::join_alias()}.
279      *
280      * @param  string $term What the concept or aliases should be.
281      */
282     public function filter_by_term($term) {
283         $this->where[] = sprintf("(%s = :filterterma OR %s = :filtertermb)",
284             self::resolve_field('concept', 'entries'),
285             self::resolve_field('alias', 'alias'));
286         $this->params['filterterma'] = $term;
287         $this->params['filtertermb'] = $term;
288     }
290     /**
291      * Filter by search terms.
292      *
293      * Note that this does not handle invalid or too short terms. This requires the alias
294      * table to be joined in the query. See {@link self::join_alias()}.
295      *
296      * @param  array   $terms      Array of terms.
297      * @param  boolean $fullsearch Whether or not full search should be enabled.
298      */
299     public function filter_by_search_terms(array $terms, $fullsearch = true) {
300         global $DB;
301         static $i = 0;
303         if ($DB->sql_regex_supported()) {
304             $regexp = $DB->sql_regex(true);
305             $notregexp = $DB->sql_regex(false);
306         }
308         $params = array();
309         $conceptfield = self::resolve_field('concept', 'entries');
310         $aliasfield = self::resolve_field('alias', 'alias');
311         $definitionfield = self::resolve_field('definition', 'entries');
312         $conditions = array();
314         foreach ($terms as $searchterm) {
315             $i++;
317             $not = false; // Initially we aren't going to perform NOT LIKE searches, only MSSQL and Oracle
318                           // will use it to simulate the "-" operator with LIKE clause.
320             if (empty($fullsearch)) {
321                 // With fullsearch disabled, look only within concepts and aliases.
322                 $concat = $DB->sql_concat($conceptfield, "' '", "COALESCE($aliasfield, :emptychar{$i})");
323             } else {
324                 // With fullsearch enabled, look also within definitions.
325                 $concat = $DB->sql_concat($conceptfield, "' '", $definitionfield, "' '", "COALESCE($aliasfield, :emptychar{$i})");
326             }
327             $params['emptychar' . $i] = '';
329             // Under Oracle and MSSQL, trim the + and - operators and perform simpler LIKE (or NOT LIKE) queries.
330             if (!$DB->sql_regex_supported()) {
331                 if (substr($searchterm, 0, 1) === '-') {
332                     $not = true;
333                 }
334                 $searchterm = trim($searchterm, '+-');
335             }
337             if (substr($searchterm, 0, 1) === '+') {
338                 $searchterm = trim($searchterm, '+-');
339                 $conditions[] = "$concat $regexp :searchterm{$i}";
340                 $params['searchterm' . $i] = '(^|[^a-zA-Z0-9])' . preg_quote($searchterm, '|') . '([^a-zA-Z0-9]|$)';
342             } else if (substr($searchterm, 0, 1) === "-") {
343                 $searchterm = trim($searchterm, '+-');
344                 $conditions[] = "$concat $notregexp :searchterm{$i}";
345                 $params['searchterm' . $i] = '(^|[^a-zA-Z0-9])' . preg_quote($searchterm, '|') . '([^a-zA-Z0-9]|$)';
347             } else {
348                 $conditions[] = $DB->sql_like($concat, ":searchterm{$i}", false, true, $not);
349                 $params['searchterm' . $i] = '%' . $DB->sql_like_escape($searchterm) . '%';
350             }
351         }
353         // When there are no conditions we add a negative one to ensure that we don't return anything.
354         if (empty($conditions)) {
355             $conditions[] = '1 = 2';
356         }
358         $this->where[] = implode(' AND ', $conditions);
359         $this->params = array_merge($this->params, $params);
360     }
362     /**
363      * Convenience method to get get the SQL statement for the full name.
364      *
365      * @param  boolean $firstnamefirst Whether or not the firstname is first in the author's name.
366      * @return string The SQL statement.
367      */
368     public static function get_fullname_field($firstnamefirst = false) {
369         global $DB;
370         if ($firstnamefirst) {
371             return $DB->sql_fullname(self::resolve_field('firstname', 'user'), self::resolve_field('lastname', 'user'));
372         }
373         return $DB->sql_fullname(self::resolve_field('lastname', 'user'), self::resolve_field('firstname', 'user'));
374     }
376     /**
377      * Get the records.
378      *
379      * @return array
380      */
381     public function get_records() {
382         global $DB;
383         return $DB->get_records_sql($this->build_query(), $this->params, $this->limitfrom, $this->limitnum);
384     }
386     /**
387      * Get the recordset.
388      *
389      * @return moodle_recordset
390      */
391     public function get_recordset() {
392         global $DB;
393         return $DB->get_recordset_sql($this->build_query(), $this->params, $this->limitfrom, $this->limitnum);
394     }
396     /**
397      * Retrieve a user object from a record.
398      *
399      * This comes handy when {@link self::add_user_fields} was used.
400      *
401      * @param  stdClass $record The record.
402      * @return stdClass A user object.
403      */
404     public static function get_user_from_record($record) {
405         return user_picture::unalias($record, null, 'userdataid', 'userdata');
406     }
408     /**
409      * Join the alias table.
410      *
411      * Note that this may cause the same entry to be returned more than once. You might want
412      * to add a distinct on the entry id. See {@link self::distinct()}.
413      *
414      * @return void
415      */
416     public function join_alias() {
417         $this->joins[] = sprintf('LEFT JOIN {glossary_alias} %s ON %s = %s',
418             self::ALIAS_ALIAS, self::resolve_field('id', 'entries'), self::resolve_field('entryid', 'alias'));
419     }
421     /**
422      * Join on the category tables.
423      *
424      * Depending on the category passed the joins will be different. This is due to the display
425      * logic that assumes that when displaying all categories the non categorised entries should
426      * not be returned, etc...
427      *
428      * @param  int $categoryid The category ID, or GLOSSARY_SHOW_* constant.
429      */
430     public function join_category($categoryid) {
432         if ($categoryid === GLOSSARY_SHOW_ALL_CATEGORIES) {
433             $this->joins[] = sprintf('JOIN {glossary_entries_categories} %s ON %s = %s',
434                 self::ALIAS_ENTRIES_CATEGORIES, self::resolve_field('id', 'entries'),
435                 self::resolve_field('entryid', 'entries_categories'));
437             $this->joins[] = sprintf('JOIN {glossary_categories} %s ON %s = %s',
438                 self::ALIAS_CATEGORIES, self::resolve_field('id', 'categories'),
439                 self::resolve_field('categoryid', 'entries_categories'));
441         } else if ($categoryid === GLOSSARY_SHOW_NOT_CATEGORISED) {
442             $this->joins[] = sprintf('LEFT JOIN {glossary_entries_categories} %s ON %s = %s',
443                 self::ALIAS_ENTRIES_CATEGORIES, self::resolve_field('id', 'entries'),
444                 self::resolve_field('entryid', 'entries_categories'));
446         } else {
447             $this->joins[] = sprintf('JOIN {glossary_entries_categories} %s ON %s = %s AND %s = :joincategoryid',
448                 self::ALIAS_ENTRIES_CATEGORIES, self::resolve_field('id', 'entries'),
449                 self::resolve_field('entryid', 'entries_categories'),
450                 self::resolve_field('categoryid', 'entries_categories'));
451             $this->params['joincategoryid'] = $categoryid;
453         }
454     }
456     /**
457      * Join the user table.
458      *
459      * @param  boolean $strict When strict uses a JOIN rather than a LEFT JOIN.
460      */
461     public function join_user($strict = false) {
462         $join = $strict ? 'JOIN' : 'LEFT JOIN';
463         $this->joins[] = sprintf("$join {user} %s ON %s = %s",
464             self::ALIAS_USER, self::resolve_field('id', 'user'), self::resolve_field('userid', 'entries'));
465     }
467     /**
468      * Limit the number of records to fetch.
469      * @param  int $from Fetch from.
470      * @param  int $num  Number to fetch.
471      */
472     public function limit($from, $num) {
473         $this->limitfrom = $from;
474         $this->limitnum = $num;
475     }
477     /**
478      * Normalise a direction.
479      *
480      * This ensures that the value is either ASC or DESC.
481      *
482      * @param  string $direction The desired direction.
483      * @return string ASC or DESC.
484      */
485     protected function normalize_direction($direction) {
486         $direction = core_text::strtoupper($direction);
487         if ($direction == 'DESC') {
488             return 'DESC';
489         }
490         return 'ASC';
491     }
493     /**
494      * Order by a field.
495      *
496      * @param string $field The field, or *.
497      * @param string $table The table name, without the prefix 'glossary_'.
498      * @param string $direction ASC, or DESC.
499      */
500     public function order_by($field, $table, $direction = '') {
501         $direction = self::normalize_direction($direction);
502         $this->order[] = self::resolve_field($field, $table) . ' ' . $direction;
503     }
505     /**
506      * Order by author name.
507      *
508      * @param  boolean $firstnamefirst Whether or not the firstname is first in the author's name.
509      * @param  string  $direction      [description]
510      * @param  string $direction ASC, or DESC.
511      */
512     public function order_by_author($firstnamefirst = false, $direction = '') {
513         $field = self::get_fullname_field($firstnamefirst);
514         $direction = self::normalize_direction($direction);
515         $this->order[] = $field . ' ' . $direction;
516     }
518     /**
519      * Convenience method to transform a field into SQL statement.
520      *
521      * @param string $field The field, or *.
522      * @param string $table The table name, without the prefix 'glossary_'.
523      * @return string SQL statement.
524      */
525     protected static function resolve_field($field, $table) {
526         $prefix = constant(__CLASS__ . '::ALIAS_' . core_text::strtoupper($table));
527         return sprintf('%s.%s', $prefix, $field);
528     }
530     /**
531      * Simple where conditions.
532      *
533      * @param string $field The field, or *.
534      * @param string $table The table name, without the prefix 'glossary_'.
535      * @param mixed $value The value to be equal to.
536      */
537     public function where($field, $table, $value) {
538         static $i = 0;
539         $sql = self::resolve_field($field, $table) . ' ';
541         if ($value === null) {
542             $sql .= 'IS NULL';
544         } else {
545             $param = 'where' . $i++;
546             $sql .= " = :$param";
547             $this->params[$param] = $value;
548         }
550         $this->where[] = $sql;
551     }