Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * A class for efficiently finds questions at random from the question bank.
 *
 * @package   core_question
 * @copyright 2015 The Open University
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace core_question\local\bank;

/**
 * This class efficiently finds questions at random from the question bank.
 *
 * You can ask for questions at random one at a time. Each time you ask, you
 * pass a category id, and whether to pick from that category and all subcategories
 * or just that category.
 *
 * The number of teams each question has been used is tracked, and we will always
 * return a question from among those elegible that has been used the fewest times.
 * So, if there are questions that have not been used yet in the category asked for,
 * one of those will be returned. However, within one instantiation of this class,
 * we will never return a given question more than once, and we will never return
 * questions passed into the constructor as $usedquestions.
 *
 * @copyright 2015 The Open University
 * @author    2021 Safat Shahin <safatshahin@catalyst-au.net>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class random_question_loader {
    /** @var \qubaid_condition which usages to consider previous attempts from. */
    protected $qubaids;

    /** @var array qtypes that cannot be used by random questions. */
    protected $excludedqtypes;

    /** @var array categoryid & include subcategories => num previous uses => questionid => 1. */
    protected $availablequestionscache = [];

    /**
     * @var array questionid => num recent uses. Questions that have been used,
     * but that is not yet recorded in the DB.
     */
    protected $recentlyusedquestions;

    /**
     * Constructor.
     *
     * @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question.
     * @param array $usedquestions questionid => number of times used count. If we should allow for
     *      further existing uses of a question in addition to the ones in $qubaids.
     */
    public function __construct(\qubaid_condition $qubaids, array $usedquestions = []) {
        $this->qubaids = $qubaids;
        $this->recentlyusedquestions = $usedquestions;

        foreach (\question_bank::get_all_qtypes() as $qtype) {
            if (!$qtype->is_usable_by_random()) {
                $this->excludedqtypes[] = $qtype->name();
            }
        }
    }

    /**
> * Pick a random question based on filter conditions * Pick a question at random from the given category, from among those with the fewest uses. > * * If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected. > * @param array $filters filter array * > * @return int|null * It is up the the caller to verify that the cateogry exists. An unknown category > */ * behaves like an empty one. > public function get_next_filtered_question_id(array $filters): ?int { * > $this->ensure_filtered_questions_loaded($filters); * @param int $categoryid the id of a category in the question bank. > * @param bool $includesubcategories wether to pick a question from exactly > $key = $this->get_filtered_questions_key($filters); * that category, or that category and subcategories. > if (empty($this->availablequestionscache[$key])) { * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any) > return null; * in order to be eligible for being picked. > } * @return int|null the id of the question picked, or null if there aren't any. > */ > reset($this->availablequestionscache[$key]); public function get_next_question_id($categoryid, $includesubcategories, $tagids = []): ?int { > $lowestcount = key($this->availablequestionscache[$key]); $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); > reset($this->availablequestionscache[$key][$lowestcount]); > $questionid = key($this->availablequestionscache[$key][$lowestcount]); $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); > $this->use_question($questionid); if (empty($this->availablequestionscache[$categorykey])) { > return $questionid; return null; > } } > > reset($this->availablequestionscache[$categorykey]); > /**
$lowestcount = key($this->availablequestionscache[$categorykey]);
> * @deprecated since Moodle 4.3 reset($this->availablequestionscache[$categorykey][$lowestcount]); > * @todo Final deprecation on Moodle 4.7 MDL-78091
$questionid = key($this->availablequestionscache[$categorykey][$lowestcount]);
> debugging( $this->use_question($questionid); > 'Function get_next_question_id() is deprecated, please use get_next_filtered_question_id() instead.', return $questionid; > DEBUG_DEVELOPER } > ); >
/**
> * Key for filtered questions. * Get the key into {@see $availablequestionscache} for this combination of options. > * This function replace get_category_key * > * * @param int $categoryid the id of a category in the question bank. > * @param array $filters filter array * @param bool $includesubcategories wether to pick a question from exactly > * @return String * that category, or that category and subcategories. > */ * @param array $tagids an array of tag ids. > protected function get_filtered_questions_key(array $filters): String { * @return string the cache key. > return sha1(json_encode($filters)); */ > } protected function get_category_key($categoryid, $includesubcategories, $tagids = []): string { > if ($includesubcategories) { > /**
$key = $categoryid . '|1';
> * } else { > * @deprecated since Moodle 4.3 $key = $categoryid . '|0'; > * @todo Final deprecation on Moodle 4.7 MDL-78091
}
> debugging( > 'Function get_category_key() is deprecated, please get_fitlered_questions_key instead.', if (!empty($tagids)) { > DEBUG_DEVELOPER $key .= '|' . implode('|', $tagids); > );
} return $key; } /**
> * Populate {@see $availablequestionscache} according to filter conditions. * Populate {@see $availablequestionscache} for this combination of options. > * * > * @param array $filters filter array * @param int $categoryid The id of a category in the question bank. > * @return void * @param bool $includesubcategories Whether to pick a question from exactly > */ * that category, or that category and subcategories. > protected function ensure_filtered_questions_loaded(array $filters) { * @param array $tagids An array of tag ids. If an array is provided, then > global $DB; * only the questions that are tagged with ALL the provided tagids will be loaded. > */ > $key = $this->get_filtered_questions_key($filters); protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []): void { > if (isset($this->availablequestionscache[$key])) { global $DB; > // Data is already in the cache, nothing to do. > return; $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); > } > if (isset($this->availablequestionscache[$categorykey])) { > [$extraconditions, $extraparams] = $DB->get_in_or_equal($this->excludedqtypes, // Data is already in the cache, nothing to do. > SQL_PARAMS_NAMED, 'excludedqtype', false); return; > } > $previoussql = "SELECT COUNT(1) > FROM " . $this->qubaids->from_question_attempts('qa') . " // Load the available questions from the question bank. > WHERE qa.questionid = q.id AND " . $this->qubaids->where(); if ($includesubcategories) { > $previousparams = $this->qubaids->from_where_params(); $categoryids = question_categorylist($categoryid); > } else { > // Latest version. $categoryids = [$categoryid]; > $latestversionsql = "SELECT MAX(v.version) } > FROM {question_versions} v > JOIN {question_bank_entries} be ON be.id = v.questionbankentryid list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes, > WHERE be.id = qbe.id"; SQL_PARAMS_NAMED, 'excludedqtype', false); > > $sql = "SELECT q.id, ($previoussql) AS previous_attempts $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts( > FROM {question} q $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids); > JOIN {question_versions} qv ON qv.questionid = q.id if (!$questionidsandcounts) { > JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid // No questions in this category. > WHERE "; $this->availablequestionscache[$categorykey] = []; > return; > $where = [ } > 'q.parent = :noparent', > 'qv.status = :ready', // Put all the questions with each value of $prevusecount in separate arrays. > "qv.version = ($latestversionsql)", $idsbyusecount = []; > ]; foreach ($questionidsandcounts as $questionid => $prevusecount) { > $params = array_merge( if (isset($this->recentlyusedquestions[$questionid])) { > $previousparams, // Recently used questions are never returned. > ['noparent' => 0, 'ready' => question_version_status::QUESTION_STATUS_READY]); continue; > } > // Get current enabled condition classes. $idsbyusecount[$prevusecount][] = $questionid; > $conditionclasses = \core_question\local\bank\filter_condition_manager::get_condition_classes(); } > // Build filter conditions. > foreach ($conditionclasses as $conditionclass) { // Now put that data into our cache. For each count, we need to shuffle > $filter = $conditionclass::get_filter_from_list($filters); // questionids, and make those the keys of an array. > if (is_null($filter)) { $this->availablequestionscache[$categorykey] = []; > continue; foreach ($idsbyusecount as $prevusecount => $questionids) { > } shuffle($questionids); > [$filterwhere, $filterparams] = $conditionclass::build_query_from_filter($filter); $this->availablequestionscache[$categorykey][$prevusecount] = array_combine( > if (!empty($filterwhere)) { $questionids, array_fill(0, count($questionids), 1)); > $where[] = '(' . $filterwhere . ')'; } > } ksort($this->availablequestionscache[$categorykey]); > if (!empty($filterparams)) { } > $params = array_merge($params, $filterparams); > } /** > } * Update the internal data structures to indicate that a given question has > * been used one more time. > // Extra conditions. * > if ($extraconditions) { * @param int $questionid the question that is being used. > $where[] = 'q.qtype ' . $extraconditions; */ > $params = array_merge($params, $extraparams); protected function use_question($questionid): void { > } if (isset($this->recentlyusedquestions[$questionid])) { > $this->recentlyusedquestions[$questionid] += 1; > // Build query. } else { > $sql .= implode(' AND ', $where); $this->recentlyusedquestions[$questionid] = 1; > $sql .= "ORDER BY previous_attempts"; } > > $questionidsandcounts = $DB->get_records_sql_menu($sql, $params); foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) { > foreach ($questionsforcategory as $numuses => $questionids) { > if (!$questionidsandcounts) { if (!isset($questionids[$questionid])) { > // No questions in this category. continue; > $this->availablequestionscache[$key] = []; } > return; unset($this->availablequestionscache[$categorykey][$numuses][$questionid]); > } if (empty($this->availablequestionscache[$categorykey][$numuses])) { > unset($this->availablequestionscache[$categorykey][$numuses]); > // Put all the questions with each value of $prevusecount in separate arrays. } > $idsbyusecount = []; } > foreach ($questionidsandcounts as $questionid => $prevusecount) { } > if (isset($this->recentlyusedquestions[$questionid])) { } > // Recently used questions are never returned. > continue; /** > } * Get the list of available question ids for the given criteria. > $idsbyusecount[$prevusecount][] = $questionid; * > } * @param int $categoryid The id of a category in the question bank. > * @param bool $includesubcategories Whether to pick a question from exactly > // Now put that data into our cache. For each count, we need to shuffle * that category, or that category and subcategories. > // questionids, and make those the keys of an array. * @param array $tagids An array of tag ids. If an array is provided, then > $this->availablequestionscache[$key] = []; * only the questions that are tagged with ALL the provided tagids will be loaded. > foreach ($idsbyusecount as $prevusecount => $questionids) { * @return int[] The list of question ids > shuffle($questionids); */ > $this->availablequestionscache[$key][$prevusecount] = array_combine( protected function get_question_ids($categoryid, $includesubcategories, $tagids = []): array { > $questionids, array_fill(0, count($questionids), 1)); $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); > } $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); > ksort($this->availablequestionscache[$key]); $cachedvalues = $this->availablequestionscache[$categorykey]; > } $questionids = []; > > /**
foreach ($cachedvalues as $usecount => $ids) {
> * @deprecated since Moodle 4.3 $questionids = array_merge($questionids, array_keys($ids)); > * @todo Final deprecation on Moodle 4.7 MDL-78091
}
> debugging( > 'Function ensure_questions_for_category_loaded() is deprecated, please use the function ' . return $questionids; > 'ensure_filtered_questions_loaded.', } > DEBUG_DEVELOPER > ); /** >
* Check whether a given question is available in a given category. If so, mark it used.
> * Get filtered questions. * If an optional list of tag ids are provided, then the question must be tagged with > * * ALL of the provided tags to be considered as available. > * @param array $filters filter array * > * @return array list of filtered questions * @param int $categoryid the id of a category in the question bank. > */ * @param bool $includesubcategories wether to pick a question from exactly > protected function get_filtered_question_ids(array $filters): array { * that category, or that category and subcategories. > $this->ensure_filtered_questions_loaded($filters); * @param int $questionid the question that is being used. > $key = $this->get_filtered_questions_key($filters); * @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available. > * @return bool whether the question is available in the requested category. > $cachedvalues = $this->availablequestionscache[$key]; */ > $questionids = []; public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []): bool { > $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); > foreach ($cachedvalues as $usecount => $ids) { $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); > $questionids = array_merge($questionids, array_keys($ids)); > } foreach ($this->availablequestionscache[$categorykey] as $questionids) { > if (isset($questionids[$questionid])) { > return $questionids; $this->use_question($questionid); > } return true; > } > /**
}
> * @deprecated since Moodle 4.3 > * @todo Final deprecation on Moodle 4.7 MDL-78091
return false;
> debugging( } > 'Function get_question_ids() is deprecated, please use get_filtered_question_ids() instead.', > DEBUG_DEVELOPER /** > ); * Get the list of available questions for the given criteria. >
*
> * @param array $filters filter array * @param int $categoryid The id of a category in the question bank. > * @param int $questionid the question that is being used. * @param bool $includesubcategories Whether to pick a question from exactly > * @return bool whether the question is available in the requested category. * that category, or that category and subcategories. > */ * @param array $tagids An array of tag ids. If an array is provided, then > public function is_filtered_question_available(array $filters, int $questionid): bool { * only the questions that are tagged with ALL the provided tagids will be loaded. > $this->ensure_filtered_questions_loaded($filters); * @param int $limit Maximum number of results to return. > $categorykey = $this->get_filtered_questions_key($filters); * @param int $offset Number of items to skip from the begging of the result set. > * @param string[] $fields The fields to return for each question. > foreach ($this->availablequestionscache[$categorykey] as $questionids) { * @return \stdClass[] The list of question records > if (isset($questionids[$questionid])) { */ > $this->use_question($questionid); public function get_questions($categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = []) { > return true; global $DB; > } > } $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); > if (empty($questionids)) { > return false; return []; > } } > > /** if (empty($fields)) { > * Check whether a given question is available in a given category. If so, mark it used. // Return all fields. > * If an optional list of tag ids are provided, then the question must be tagged with $fieldsstring = '*'; > * ALL of the provided tags to be considered as available. } else { > *
$fieldsstring = implode(',', $fields);
> * @deprecated since Moodle 4.3 } > * @todo Final deprecation on Moodle 4.7 MDL-78091
> debugging( // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql). > 'Function is_question_available() is deprecated, please use is_filtered_question_available() instead.', $hasquestions = false; > DEBUG_DEVELOPER if (!empty($questionids)) { > );
$hasquestions = true;
> * @param array $filters filter array } > * @param int $limit Maximum number of results to return. if ($hasquestions) { > * @param int $offset Number of items to skip from the begging of the result set. list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); > * @param string[] $fields The fields to return for each question. $condition = 'WHERE q.id ' . $condition; > * @return \stdClass[] The list of question records $sql = "SELECT {$fieldsstring} > */ FROM (SELECT q.*, qbe.questioncategoryid as category > public function get_filtered_questions($filters, $limit = 100, $offset = 0, $fields = []) { FROM {question} q > global $DB; JOIN {question_versions} qv ON qv.questionid = q.id > JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid > $questionids = $this->get_filtered_question_ids($filters); {$condition}) q ORDER BY q.id"; > > if (empty($questionids)) { return $DB->get_records_sql($sql, $param, $offset, $limit); > return []; } else { > } return []; > } > if (empty($fields)) { } > // Return all fields. > $fieldsstring = '*'; /** > } else { * Count the number of available questions for the given criteria. > $fieldsstring = implode(',', $fields); * > } * @param int $categoryid The id of a category in the question bank. > * @param bool $includesubcategories Whether to pick a question from exactly > // Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql). * that category, or that category and subcategories. > $hasquestions = false; * @param array $tagids An array of tag ids. If an array is provided, then > if (!empty($questionids)) { * only the questions that are tagged with ALL the provided tagids will be loaded. > $hasquestions = true; * @return int The number of questions matching the criteria. > } */ > if ($hasquestions) { public function count_questions($categoryid, $includesubcategories, $tagids = []): int { > [$condition, $param] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); > $condition = 'WHERE q.id ' . $condition; return count($questionids); > $sql = "SELECT {$fieldsstring} } > FROM (SELECT q.*, qbe.questioncategoryid as category } > FROM {question} q > JOIN {question_versions} qv ON qv.questionid = q.id > JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid > {$condition}) q"; > > return $DB->get_records_sql($sql, $param, $offset, $limit); > } else { > return []; > } > } > > /** > * Get the list of available questions for the given criteria. > *
> * @deprecated since Moodle 4.3 > * @todo Final deprecation on Moodle 4.7 MDL-78091
> debugging( > 'Function get_questions() is deprecated, please use get_filtered_questions() instead.', > DEBUG_DEVELOPER > );
> * Count number of filtered questions > * > * @param array $filters filter array > * @return int number of question > */ > public function count_filtered_questions(array $filters): int { > $questionids = $this->get_filtered_question_ids($filters); > return count($questionids); > } > > /**
> * @deprecated since Moodle 4.3 > * @todo Final deprecation on Moodle 4.7 MDL-78091
> debugging( > 'Function count_questions() is deprecated, please use count_filtered_questions() instead.', > DEBUG_DEVELOPER > );