Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

   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/>.
  16  
  17  /**
  18   * Calculated question definition class.
  19   *
  20   * @package    qtype
  21   * @subpackage calculated
  22   * @copyright  2011 The Open University
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  require_once($CFG->dirroot . '/question/type/questionbase.php');
  30  require_once($CFG->dirroot . '/question/type/numerical/question.php');
  31  require_once($CFG->dirroot . '/question/type/calculated/questiontype.php');
  32  
  33  /**
  34   * Represents a calculated question.
  35   *
  36   * @copyright  2011 The Open University
  37   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class qtype_calculated_question extends qtype_numerical_question
  40          implements qtype_calculated_question_with_expressions {
  41  
  42      /** @var qtype_calculated_dataset_loader helper for loading the dataset. */
  43      public $datasetloader;
  44  
  45      /** @var qtype_calculated_variable_substituter stores the dataset we are using. */
  46      public $vs;
  47  
  48      /**
  49       * @var bool wheter the dataset item to use should be chose based on attempt
  50       * start time, rather than randomly.
  51       */
  52      public $synchronised;
  53  
  54      public function start_attempt(question_attempt_step $step, $variant) {
  55          qtype_calculated_question_helper::start_attempt($this, $step, $variant);
  56          parent::start_attempt($step, $variant);
  57      }
  58  
  59      public function apply_attempt_state(question_attempt_step $step) {
  60          qtype_calculated_question_helper::apply_attempt_state($this, $step);
  61          parent::apply_attempt_state($step);
  62      }
  63  
  64      public function calculate_all_expressions() {
  65          $this->questiontext = $this->vs->replace_expressions_in_text($this->questiontext);
  66          $this->generalfeedback = $this->vs->replace_expressions_in_text($this->generalfeedback);
  67  
  68          foreach ($this->answers as $ans) {
  69              if ($ans->answer && $ans->answer !== '*') {
  70                  $ans->answer = $this->vs->calculate($ans->answer,
  71                          $ans->correctanswerlength, $ans->correctanswerformat);
  72              }
  73              $ans->feedback = $this->vs->replace_expressions_in_text($ans->feedback,
  74                          $ans->correctanswerlength, $ans->correctanswerformat);
  75          }
  76      }
  77  
  78      public function get_num_variants() {
  79          return $this->datasetloader->get_number_of_items();
  80      }
  81  
  82      public function get_variants_selection_seed() {
  83          if (!empty($this->synchronised) &&
  84                  $this->datasetloader->datasets_are_synchronised($this->category)) {
  85              return 'category' . $this->category;
  86          } else {
  87              return parent::get_variants_selection_seed();
  88          }
  89      }
  90  
  91      public function get_correct_response() {
  92          $answer = $this->get_correct_answer();
  93          if (!$answer) {
  94              return array();
  95          }
  96  
  97          $response = array('answer' => $this->vs->format_float($answer->answer,
  98              $answer->correctanswerlength, $answer->correctanswerformat));
  99  
 100          if ($this->has_separate_unit_field()) {
 101              $response['unit'] = $this->ap->get_default_unit();
 102          } else if ($this->unitdisplay == qtype_numerical::UNITINPUT) {
 103              $response['answer'] = $this->ap->add_unit($response['answer']);
 104          }
 105  
 106          return $response;
 107      }
 108  
 109  }
 110  
 111  
 112  /**
 113   * This interface defines the method that a quetsion type must implement if it
 114   * is to work with {@link qtype_calculated_question_helper}.
 115   *
 116   * As well as this method, the class that implements this interface must have
 117   * fields
 118   * public $datasetloader; // of type qtype_calculated_dataset_loader
 119   * public $vs; // of type qtype_calculated_variable_substituter
 120   *
 121   * @copyright  2011 The Open University
 122   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 123   */
 124  interface qtype_calculated_question_with_expressions {
 125      /**
 126       * Replace all the expression in the question definition with the values
 127       * computed from the selected dataset by calling $this->vs->calculate() and
 128       * $this->vs->replace_expressions_in_text() on the parts of the question
 129       * that require it.
 130       */
 131      public function calculate_all_expressions();
 132  }
 133  
 134  
 135  /**
 136   * Helper class for questions that use datasets. Works with the interface
 137   * {@link qtype_calculated_question_with_expressions} and the class
 138   * {@link qtype_calculated_dataset_loader} to set up the value of each variable
 139   * in start_attempt, and restore that in apply_attempt_state.
 140   *
 141   * @copyright  2011 The Open University
 142   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 143   */
 144  abstract class qtype_calculated_question_helper {
 145      public static function start_attempt(
 146              qtype_calculated_question_with_expressions $question,
 147              question_attempt_step $step, $variant) {
 148  
 149          $question->vs = new qtype_calculated_variable_substituter(
 150                  $question->datasetloader->get_values($variant),
 151                  get_string('decsep', 'langconfig'));
 152          $question->calculate_all_expressions();
 153  
 154          foreach ($question->vs->get_values() as $name => $value) {
 155              $step->set_qt_var('_var_' . $name, $value);
 156          }
 157      }
 158  
 159      public static function apply_attempt_state(
 160              qtype_calculated_question_with_expressions $question, question_attempt_step $step) {
 161          $values = array();
 162          foreach ($step->get_qt_data() as $name => $value) {
 163              if (substr($name, 0, 5) === '_var_') {
 164                  $values[substr($name, 5)] = $value;
 165              }
 166          }
 167  
 168          $question->vs = new qtype_calculated_variable_substituter(
 169                  $values, get_string('decsep', 'langconfig'));
 170          $question->calculate_all_expressions();
 171      }
 172  }
 173  
 174  
 175  /**
 176   * This class is responsible for loading the dataset that a question needs from
 177   * the database.
 178   *
 179   * @copyright  2011 The Open University
 180   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 181   */
 182  class qtype_calculated_dataset_loader {
 183      /** @var int the id of the question we are helping. */
 184      protected $questionid;
 185  
 186      /** @var int the id of the question we are helping. */
 187      protected $itemsavailable = null;
 188  
 189      /**
 190       * Constructor
 191       * @param int $questionid the question to load datasets for.
 192       */
 193      public function __construct($questionid) {
 194          $this->questionid = $questionid;
 195      }
 196  
 197      /**
 198       * Get the number of items (different values) in each dataset used by this
 199       * question. This is the minimum number of items in any dataset used by this
 200       * question.
 201       * @return int the number of items available.
 202       */
 203      public function get_number_of_items() {
 204          global $DB;
 205  
 206          if (is_null($this->itemsavailable)) {
 207              $this->itemsavailable = $DB->get_field_sql('
 208                      SELECT MIN(qdd.itemcount)
 209                        FROM {question_dataset_definitions} qdd
 210                        JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
 211                       WHERE qd.question = ?
 212                      ', array($this->questionid), MUST_EXIST);
 213          }
 214  
 215          return $this->itemsavailable;
 216      }
 217  
 218      /**
 219       * Actually query the database for the values.
 220       * @param int $itemnumber which set of values to load.
 221       * @return array name => value;
 222       */
 223      protected function load_values($itemnumber) {
 224          global $DB;
 225  
 226          return $DB->get_records_sql_menu('
 227                  SELECT qdd.name, qdi.value
 228                    FROM {question_dataset_items} qdi
 229                    JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
 230                    JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
 231                   WHERE qd.question = ?
 232                     AND qdi.itemnumber = ?
 233                  ', array($this->questionid, $itemnumber));
 234      }
 235  
 236      /**
 237       * Load a particular set of values for each dataset used by this question.
 238       * @param int $itemnumber which set of values to load.
 239       *      0 < $itemnumber <= {@link get_number_of_items()}.
 240       * @return array name => value.
 241       */
 242      public function get_values($itemnumber) {
 243          if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) {
 244              $a = new stdClass();
 245              $a->id = $this->questionid;
 246              $a->item = $itemnumber;
 247              throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a);
 248          }
 249  
 250          return $this->load_values($itemnumber);
 251      }
 252  
 253      public function datasets_are_synchronised($category) {
 254          global $DB;
 255          // We need to ensure that there are synchronised datasets, and that they
 256          // all use the right category.
 257          $categories = $DB->get_record_sql('
 258                  SELECT MAX(qdd.category) AS max,
 259                         MIN(qdd.category) AS min
 260                    FROM {question_dataset_definitions} qdd
 261                    JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
 262                   WHERE qd.question = ?
 263                     AND qdd.category <> 0
 264              ', array($this->questionid));
 265  
 266          return $categories && $categories->max == $category && $categories->min == $category;
 267      }
 268  }
 269  
 270  
 271  /**
 272   * This class holds the current values of all the variables used by a calculated
 273   * question.
 274   *
 275   * It can compute formulae using those values, and can substitute equations
 276   * embedded in text.
 277   *
 278   * @copyright  2011 The Open University
 279   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 280   */
 281  class qtype_calculated_variable_substituter {
 282  
 283      /** @var array variable name => value */
 284      protected $values;
 285  
 286      /** @var string character to use for the decimal point in displayed numbers. */
 287      protected $decimalpoint;
 288  
 289      /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */
 290      protected $search;
 291  
 292      /**
 293       * @var array variable values, with negative numbers wrapped in (...).
 294       * Used by {@link substitute_values()}.
 295       */
 296      protected $safevalue;
 297  
 298      /**
 299       * @var array variable values, with negative numbers wrapped in (...).
 300       * Used by {@link substitute_values()}.
 301       */
 302      protected $prettyvalue;
 303  
 304      /**
 305       * Constructor
 306       * @param array $values variable name => value.
 307       */
 308      public function __construct(array $values, $decimalpoint) {
 309          $this->values = $values;
 310          $this->decimalpoint = $decimalpoint;
 311  
 312          // Prepare an array for {@link substitute_values()}.
 313          $this->search = array();
 314          $this->replace = array();
 315          foreach ($values as $name => $value) {
 316              if (!is_numeric($value)) {
 317                  $a = new stdClass();
 318                  $a->name = '{' . $name . '}';
 319                  $a->value = $value;
 320                  throw new moodle_exception('notvalidnumber', 'qtype_calculated', '', $a);
 321              }
 322  
 323              $this->search[] = '{' . $name . '}';
 324              $this->safevalue[] = '(' . $value . ')';
 325              $this->prettyvalue[] = $this->format_float($value);
 326          }
 327      }
 328  
 329      /**
 330       * Display a float properly formatted with a certain number of decimal places.
 331       * @param number $x the number to format
 332       * @param int $length restrict to this many decimal places or significant
 333       *      figures. If null, the number is not rounded.
 334       * @param int format 1 => decimalformat, 2 => significantfigures.
 335       * @return string formtted number.
 336       */
 337      public function format_float($x, $length = null, $format = null) {
 338          if (!is_null($length) && !is_null($format)) {
 339              if ($format == '1' ) { // Answer is to have $length decimals.
 340                  // Decimal places.
 341                  $x = sprintf('%.' . $length . 'F', $x);
 342  
 343              } else if ($x) { // Significant figures does only apply if the result is non-zero.
 344                  $answer = $x;
 345                  // Convert to positive answer.
 346                  if ($answer < 0) {
 347                      $answer = -$answer;
 348                      $sign = '-';
 349                  } else {
 350                      $sign = '';
 351                  }
 352  
 353                  // Determine the format 0.[1-9][0-9]* for the answer...
 354                  $p10 = 0;
 355                  while ($answer < 1) {
 356                      --$p10;
 357                      $answer *= 10;
 358                  }
 359                  while ($answer >= 1) {
 360                      ++$p10;
 361                      $answer /= 10;
 362                  }
 363                  // ... and have the answer rounded of to the correct length.
 364                  $answer = round($answer, $length);
 365  
 366                  // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.
 367                  if ($answer >= 1) {
 368                      ++$p10;
 369                      $answer /= 10;
 370                  }
 371  
 372                  // Have the answer written on a suitable format.
 373                  // Either scientific or plain numeric.
 374                  if (-2 > $p10 || 4 < $p10) {
 375                      // Use scientific format.
 376                      $exponent = 'e'.--$p10;
 377                      $answer *= 10;
 378                      if (1 == $length) {
 379                          $x = $sign.$answer.$exponent;
 380                      } else {
 381                          // Attach additional zeros at the end of $answer.
 382                          $answer .= (1 == strlen($answer) ? '.' : '')
 383                              . '00000000000000000000000000000000000000000x';
 384                          $x = $sign
 385                              .substr($answer, 0, $length +1).$exponent;
 386                      }
 387                  } else {
 388                      // Stick to plain numeric format.
 389                      $answer *= "1e{$p10}";
 390                      if (0.1 <= $answer / "1e{$length}") {
 391                          $x = $sign.$answer;
 392                      } else {
 393                          // Could be an idea to add some zeros here.
 394                          $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '')
 395                              . '00000000000000000000000000000000000000000x';
 396                          $oklen = $length + ($p10 < 1 ? 2-$p10 : 1);
 397                          $x = $sign.substr($answer, 0, $oklen);
 398                      }
 399                  }
 400  
 401              } else {
 402                  $x = 0.0;
 403              }
 404          }
 405          return str_replace('.', $this->decimalpoint, $x);
 406      }
 407  
 408      /**
 409       * Return an array of the variables and their values.
 410       * @return array name => value.
 411       */
 412      public function get_values() {
 413          return $this->values;
 414      }
 415  
 416      /**
 417       * Evaluate an expression using the variable values.
 418       * @param string $expression the expression. A PHP expression with placeholders
 419       *      like {a} for where the variables need to go.
 420       * @return float the computed result.
 421       */
 422      public function calculate($expression) {
 423          // Make sure no malicious code is present in the expression. Refer MDL-46148 for details.
 424          if ($error = qtype_calculated_find_formula_errors($expression)) {
 425              throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error);
 426          }
 427          $expression = $this->substitute_values_for_eval($expression);
 428          if ($datasets = question_bank::get_qtype('calculated')->find_dataset_names($expression)) {
 429              // Some placeholders were not substituted.
 430              throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '',
 431                  '{' . reset($datasets) . '}');
 432          }
 433          return $this->calculate_raw($expression);
 434      }
 435  
 436      /**
 437       * Evaluate an expression after the variable values have been substituted.
 438       * @param string $expression the expression. A PHP expression with placeholders
 439       *      like {a} for where the variables need to go.
 440       * @return float the computed result.
 441       */
 442      protected function calculate_raw($expression) {
 443          try {
 444              // In older PHP versions this this is a way to validate code passed to eval.
 445              // The trick came from http://php.net/manual/en/function.eval.php.
 446              if (@eval('return true; $result = ' . $expression . ';')) {
 447                  return eval('return ' . $expression . ';');
 448              }
 449          } catch (Throwable $e) {
 450              // PHP7 and later now throws ParseException and friends from eval(),
 451              // which is much better.
 452          }
 453          // In either case of an invalid $expression, we end here.
 454          throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression);
 455      }
 456  
 457      /**
 458       * Substitute variable placehodlers like {a} with their value wrapped in ().
 459       * @param string $expression the expression. A PHP expression with placeholders
 460       *      like {a} for where the variables need to go.
 461       * @return string the expression with each placeholder replaced by the
 462       *      corresponding value.
 463       */
 464      protected function substitute_values_for_eval($expression) {
 465          return str_replace($this->search, $this->safevalue, $expression);
 466      }
 467  
 468      /**
 469       * Substitute variable placehodlers like {a} with their value without wrapping
 470       * the value in anything.
 471       * @param string $text some content with placeholders
 472       *      like {a} for where the variables need to go.
 473       * @return string the expression with each placeholder replaced by the
 474       *      corresponding value.
 475       */
 476      protected function substitute_values_pretty($text) {
 477          return str_replace($this->search, $this->prettyvalue, $text);
 478      }
 479  
 480      /**
 481       * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})
 482       * in some text with the corresponding values.
 483       * @param string $text the text to process.
 484       * @return string the text with values substituted.
 485       */
 486      public function replace_expressions_in_text($text, $length = null, $format = null) {
 487          $vs = $this; // Can't use $this in a PHP closure.
 488          $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX,
 489                  function ($matches) use ($vs, $format, $length) {
 490                      return $vs->format_float($vs->calculate($matches[1]), $length, $format);
 491                  }, $text);
 492          return $this->substitute_values_pretty($text);
 493      }
 494  }