. /** * Calculated question definition class. * * @package qtype * @subpackage calculated * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questionbase.php'); require_once($CFG->dirroot . '/question/type/numerical/question.php'); /** * Represents a calculated question. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_question extends qtype_numerical_question implements qtype_calculated_question_with_expressions { /** @var qtype_calculated_dataset_loader helper for loading the dataset. */ public $datasetloader; /** @var qtype_calculated_variable_substituter stores the dataset we are using. */ public $vs; /** * @var bool wheter the dataset item to use should be chose based on attempt * start time, rather than randomly. */ public $synchronised; public function start_attempt(question_attempt_step $step, $variant) { qtype_calculated_question_helper::start_attempt($this, $step, $variant); parent::start_attempt($step, $variant); } public function apply_attempt_state(question_attempt_step $step) { qtype_calculated_question_helper::apply_attempt_state($this, $step); parent::apply_attempt_state($step); } public function calculate_all_expressions() { $this->questiontext = $this->vs->replace_expressions_in_text($this->questiontext); $this->generalfeedback = $this->vs->replace_expressions_in_text($this->generalfeedback); foreach ($this->answers as $ans) { if ($ans->answer && $ans->answer !== '*') { $ans->answer = $this->vs->calculate($ans->answer, $ans->correctanswerlength, $ans->correctanswerformat); } $ans->feedback = $this->vs->replace_expressions_in_text($ans->feedback, $ans->correctanswerlength, $ans->correctanswerformat); } } public function get_num_variants() { return $this->datasetloader->get_number_of_items(); } public function get_variants_selection_seed() { if (!empty($this->synchronised) && $this->datasetloader->datasets_are_synchronised($this->category)) { return 'category' . $this->category; } else { return parent::get_variants_selection_seed(); } } public function get_correct_response() { $answer = $this->get_correct_answer(); if (!$answer) { return array(); } $response = array('answer' => $this->vs->format_float($answer->answer, $answer->correctanswerlength, $answer->correctanswerformat)); if ($this->has_separate_unit_field()) { $response['unit'] = $this->ap->get_default_unit(); } else if ($this->unitdisplay == qtype_numerical::UNITINPUT) { $response['answer'] = $this->ap->add_unit($response['answer']); } return $response; } } /** * This interface defines the method that a quetsion type must implement if it * is to work with {@link qtype_calculated_question_helper}. * * As well as this method, the class that implements this interface must have * fields * public $datasetloader; // of type qtype_calculated_dataset_loader * public $vs; // of type qtype_calculated_variable_substituter * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ interface qtype_calculated_question_with_expressions { /** * Replace all the expression in the question definition with the values * computed from the selected dataset by calling $this->vs->calculate() and * $this->vs->replace_expressions_in_text() on the parts of the question * that require it. */ public function calculate_all_expressions(); } /** * Helper class for questions that use datasets. Works with the interface * {@link qtype_calculated_question_with_expressions} and the class * {@link qtype_calculated_dataset_loader} to set up the value of each variable * in start_attempt, and restore that in apply_attempt_state. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class qtype_calculated_question_helper { public static function start_attempt( qtype_calculated_question_with_expressions $question, question_attempt_step $step, $variant) { $question->vs = new qtype_calculated_variable_substituter( $question->datasetloader->get_values($variant), get_string('decsep', 'langconfig')); $question->calculate_all_expressions(); foreach ($question->vs->get_values() as $name => $value) { $step->set_qt_var('_var_' . $name, $value); } } public static function apply_attempt_state( qtype_calculated_question_with_expressions $question, question_attempt_step $step) { $values = array(); foreach ($step->get_qt_data() as $name => $value) { if (substr($name, 0, 5) === '_var_') { $values[substr($name, 5)] = $value; } } $question->vs = new qtype_calculated_variable_substituter( $values, get_string('decsep', 'langconfig')); $question->calculate_all_expressions(); } } /** * This class is responsible for loading the dataset that a question needs from * the database. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_dataset_loader { /** @var int the id of the question we are helping. */ protected $questionid; /** @var int the id of the question we are helping. */ protected $itemsavailable = null; /** * Constructor * @param int $questionid the question to load datasets for. */ public function __construct($questionid) { $this->questionid = $questionid; } /** * Get the number of items (different values) in each dataset used by this * question. This is the minimum number of items in any dataset used by this * question. * @return int the number of items available. */ public function get_number_of_items() { global $DB; if (is_null($this->itemsavailable)) { $this->itemsavailable = $DB->get_field_sql(' SELECT MIN(qdd.itemcount) FROM {question_dataset_definitions} qdd JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition WHERE qd.question = ? ', array($this->questionid), MUST_EXIST); } return $this->itemsavailable; } /** * Actually query the database for the values. * @param int $itemnumber which set of values to load. * @return array name => value; */ protected function load_values($itemnumber) { global $DB; return $DB->get_records_sql_menu(' SELECT qdd.name, qdi.value FROM {question_dataset_items} qdi JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition WHERE qd.question = ? AND qdi.itemnumber = ? ', array($this->questionid, $itemnumber)); } /** * Load a particular set of values for each dataset used by this question. * @param int $itemnumber which set of values to load. * 0 < $itemnumber <= {@link get_number_of_items()}. * @return array name => value. */ public function get_values($itemnumber) { if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) { $a = new stdClass(); $a->id = $this->questionid; $a->item = $itemnumber; throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a); } return $this->load_values($itemnumber); } public function datasets_are_synchronised($category) { global $DB; // We need to ensure that there are synchronised datasets, and that they // all use the right category. $categories = $DB->get_record_sql(' SELECT MAX(qdd.category) AS max, MIN(qdd.category) AS min FROM {question_dataset_definitions} qdd JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition WHERE qd.question = ? AND qdd.category <> 0 ', array($this->questionid)); return $categories && $categories->max == $category && $categories->min == $category; } } /** * This class holds the current values of all the variables used by a calculated * question. * * It can compute formulae using those values, and can substitute equations * embedded in text. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_calculated_variable_substituter { /** @var array variable name => value */ protected $values; /** @var string character to use for the decimal point in displayed numbers. */ protected $decimalpoint; /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */ protected $search; /** * @var array variable values, with negative numbers wrapped in (...). * Used by {@link substitute_values()}. */ protected $safevalue; /** * @var array variable values, with negative numbers wrapped in (...). * Used by {@link substitute_values()}. */ protected $prettyvalue; /** * Constructor * @param array $values variable name => value. */ public function __construct(array $values, $decimalpoint) { $this->values = $values; $this->decimalpoint = $decimalpoint; // Prepare an array for {@link substitute_values()}. $this->search = array(); $this->replace = array(); foreach ($values as $name => $value) { if (!is_numeric($value)) { $a = new stdClass(); $a->name = '{' . $name . '}'; $a->value = $value; throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a); } $this->search[] = '{' . $name . '}'; $this->safevalue[] = '(' . $value . ')'; $this->prettyvalue[] = $this->format_float($value); } } /** * Display a float properly formatted with a certain number of decimal places. * @param number $x the number to format * @param int $length restrict to this many decimal places or significant * figures. If null, the number is not rounded. * @param int format 1 => decimalformat, 2 => significantfigures. * @return string formtted number. */ public function format_float($x, $length = null, $format = null) { if (!is_null($length) && !is_null($format)) { if ($format == '1' ) { // Answer is to have $length decimals. // Decimal places. $x = sprintf('%.' . $length . 'F', $x); } else if ($x) { // Significant figures does only apply if the result is non-zero. $answer = $x; // Convert to positive answer. if ($answer < 0) { $answer = -$answer; $sign = '-'; } else { $sign = ''; } // Determine the format 0.[1-9][0-9]* for the answer... $p10 = 0; while ($answer < 1) { --$p10; $answer *= 10; } while ($answer >= 1) { ++$p10; $answer /= 10; } // ... and have the answer rounded of to the correct length. $answer = round($answer, $length); // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format. if ($answer >= 1) { ++$p10; $answer /= 10; } // Have the answer written on a suitable format. // Either scientific or plain numeric. if (-2 > $p10 || 4 < $p10) { // Use scientific format. $exponent = 'e'.--$p10; $answer *= 10; if (1 == $length) { $x = $sign.$answer.$exponent; } else { // Attach additional zeros at the end of $answer. $answer .= (1 == strlen($answer) ? '.' : '') . '00000000000000000000000000000000000000000x'; $x = $sign .substr($answer, 0, $length +1).$exponent; } } else { // Stick to plain numeric format. $answer *= "1e{$p10}"; if (0.1 <= $answer / "1e{$length}") { $x = $sign.$answer; } else { // Could be an idea to add some zeros here. $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '') . '00000000000000000000000000000000000000000x'; $oklen = $length + ($p10 < 1 ? 2-$p10 : 1); $x = $sign.substr($answer, 0, $oklen); } } } else { $x = 0.0; } } return str_replace('.', $this->decimalpoint, $x); } /** * Return an array of the variables and their values. * @return array name => value. */ public function get_values() { return $this->values; } /** * Evaluate an expression using the variable values. * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return float the computed result. */ public function calculate($expression) { // Make sure no malicious code is present in the expression. Refer MDL-46148 for details. if ($error = qtype_calculated_find_formula_errors($expression)) { throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error); } return $this->calculate_raw($this->substitute_values_for_eval($expression)); } /** * Evaluate an expression after the variable values have been substituted. * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return float the computed result. */ protected function calculate_raw($expression) { try { // In older PHP versions this this is a way to validate code passed to eval. // The trick came from http://php.net/manual/en/function.eval.php. if (@eval('return true; $result = ' . $expression . ';')) { return eval('return ' . $expression . ';'); } } catch (Throwable $e) { // PHP7 and later now throws ParseException and friends from eval(), // which is much better. } // In either case of an invalid $expression, we end here. throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression); } /** * Substitute variable placehodlers like {a} with their value wrapped in (). * @param string $expression the expression. A PHP expression with placeholders * like {a} for where the variables need to go. * @return string the expression with each placeholder replaced by the * corresponding value. */ protected function substitute_values_for_eval($expression) { return str_replace($this->search, $this->safevalue, $expression); } /** * Substitute variable placehodlers like {a} with their value without wrapping * the value in anything. * @param string $text some content with placeholders * like {a} for where the variables need to go. * @return string the expression with each placeholder replaced by the * corresponding value. */ protected function substitute_values_pretty($text) { return str_replace($this->search, $this->prettyvalue, $text); } /** * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}}) * in some text with the corresponding values. * @param string $text the text to process. * @return string the text with values substituted. */ public function replace_expressions_in_text($text, $length = null, $format = null) { $vs = $this; // Can't use $this in a PHP closure. $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX, function ($matches) use ($vs, $format, $length) { return $vs->format_float($vs->calculate($matches[1]), $length, $format); }, $text); return $this->substitute_values_pretty($text); } }