. /** * 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\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 * @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 = array(); /** * @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 = array()) { $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 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. * * It is up the the caller to verify that the cateogry exists. An unknown category * behaves like an empty one. * * @param int $categoryid the id of a category in the question bank. * @param bool $includesubcategories wether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any) * in order to be eligible for being picked. * @return int|null the id of the question picked, or null if there aren't any. */ public function get_next_question_id($categoryid, $includesubcategories, $tagids = []) { $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); if (empty($this->availablequestionscache[$categorykey])) { return null; } reset($this->availablequestionscache[$categorykey]); $lowestcount = key($this->availablequestionscache[$categorykey]); reset($this->availablequestionscache[$categorykey][$lowestcount]); $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]); $this->use_question($questionid); return $questionid; } /** * Get the key into {@link $availablequestionscache} for this combination of options. * @param int $categoryid the id of a category in the question bank. * @param bool $includesubcategories wether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids an array of tag ids. * @return string the cache key. */ protected function get_category_key($categoryid, $includesubcategories, $tagids = []) { if ($includesubcategories) { $key = $categoryid . '|1'; } else { $key = $categoryid . '|0'; } if (!empty($tagids)) { $key .= '|' . implode('|', $tagids); } return $key; } /** * Populate {@link $availablequestionscache} for this combination of options. * @param int $categoryid The id of a category in the question bank. * @param bool $includesubcategories Whether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. If an array is provided, then * only the questions that are tagged with ALL the provided tagids will be loaded. */ protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []) { global $DB; $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); if (isset($this->availablequestionscache[$categorykey])) { // Data is already in the cache, nothing to do. return; } // Load the available questions from the question bank. if ($includesubcategories) { $categoryids = question_categorylist($categoryid); } else { $categoryids = array($categoryid); } list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes, SQL_PARAMS_NAMED, 'excludedqtype', false); $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts( $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids); if (!$questionidsandcounts) { // No questions in this category. $this->availablequestionscache[$categorykey] = array(); return; } // Put all the questions with each value of $prevusecount in separate arrays. $idsbyusecount = array(); foreach ($questionidsandcounts as $questionid => $prevusecount) { if (isset($this->recentlyusedquestions[$questionid])) { // Recently used questions are never returned. continue; } $idsbyusecount[$prevusecount][] = $questionid; } // Now put that data into our cache. For each count, we need to shuffle // questionids, and make those the keys of an array. $this->availablequestionscache[$categorykey] = array(); foreach ($idsbyusecount as $prevusecount => $questionids) { shuffle($questionids); $this->availablequestionscache[$categorykey][$prevusecount] = array_combine( $questionids, array_fill(0, count($questionids), 1)); } ksort($this->availablequestionscache[$categorykey]); } /** * Update the internal data structures to indicate that a given question has * been used one more time. * * @param int $questionid the question that is being used. */ protected function use_question($questionid) { if (isset($this->recentlyusedquestions[$questionid])) { $this->recentlyusedquestions[$questionid] += 1; } else { $this->recentlyusedquestions[$questionid] = 1; } foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) { foreach ($questionsforcategory as $numuses => $questionids) { if (!isset($questionids[$questionid])) { continue; } unset($this->availablequestionscache[$categorykey][$numuses][$questionid]); if (empty($this->availablequestionscache[$categorykey][$numuses])) { unset($this->availablequestionscache[$categorykey][$numuses]); } } } } /** * Get the list of available question ids for the given criteria. * * @param int $categoryid The id of a category in the question bank. * @param bool $includesubcategories Whether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. If an array is provided, then * only the questions that are tagged with ALL the provided tagids will be loaded. * @return int[] The list of question ids */ protected function get_question_ids($categoryid, $includesubcategories, $tagids = []) { $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); $cachedvalues = $this->availablequestionscache[$categorykey]; $questionids = []; foreach ($cachedvalues as $usecount => $ids) { $questionids = array_merge($questionids, array_keys($ids)); } return $questionids; } /** * Check whether a given question is available in a given category. If so, mark it used. * 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 int $categoryid the id of a category in the question bank. * @param bool $includesubcategories wether to pick a question from exactly * that category, or that category and subcategories. * @param int $questionid the question that is being used. * @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. */ public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []) { $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids); $categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids); foreach ($this->availablequestionscache[$categorykey] as $questionids) { if (isset($questionids[$questionid])) { $this->use_question($questionid); return true; } } return false; } /** * Get the list of available questions for the given criteria. * * @param int $categoryid The id of a category in the question bank. * @param bool $includesubcategories Whether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. If an array is provided, then * only the questions that are tagged with ALL the provided tagids will be loaded. * @param int $limit Maximum number of results to return. * @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. * @return \stdClass[] The list of question records */ public function get_questions( $categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = [] ) { global $DB; $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); if (empty($questionids)) { return []; } if (empty($fields)) { // Return all fields. $fieldsstring = '*'; } else { $fieldsstring = implode(',', $fields); } return $DB->get_records_list( 'question', 'id', $questionids, 'id', $fieldsstring, $offset, $limit ); } /** * Count the number of available questions for the given criteria. * * @param int $categoryid The id of a category in the question bank. * @param bool $includesubcategories Whether to pick a question from exactly * that category, or that category and subcategories. * @param array $tagids An array of tag ids. If an array is provided, then * only the questions that are tagged with ALL the provided tagids will be loaded. * @return int The number of questions matching the criteria. */ public function count_questions($categoryid, $includesubcategories, $tagids = []) { $questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids); return count($questionids); } }