Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 39 and 401] [Versions 401 and 402] [Versions 401 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_nan($x)) {
 339              $x = 'NAN';
 340          } else if (is_infinite($x)) {
 341              $x = ($x < 0) ? '-INF' : 'INF';
 342          } else if (!is_null($length) && !is_null($format)) {
 343              if ($format == '1' ) { // Answer is to have $length decimals.
 344                  // Decimal places.
 345                  $x = sprintf('%.' . $length . 'F', $x);
 346  
 347              } else if ($x) { // Significant figures does only apply if the result is non-zero.
 348                  $answer = $x;
 349                  // Convert to positive answer.
 350                  if ($answer < 0) {
 351                      $answer = -$answer;
 352                      $sign = '-';
 353                  } else {
 354                      $sign = '';
 355                  }
 356  
 357                  // Determine the format 0.[1-9][0-9]* for the answer...
 358                  $p10 = 0;
 359                  while ($answer < 1) {
 360                      --$p10;
 361                      $answer *= 10;
 362                  }
 363                  while ($answer >= 1) {
 364                      ++$p10;
 365                      $answer /= 10;
 366                  }
 367                  // ... and have the answer rounded of to the correct length.
 368                  $answer = round($answer, $length);
 369  
 370                  // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format.
 371                  if ($answer >= 1) {
 372                      ++$p10;
 373                      $answer /= 10;
 374                  }
 375  
 376                  // Have the answer written on a suitable format.
 377                  // Either scientific or plain numeric.
 378                  if (-2 > $p10 || 4 < $p10) {
 379                      // Use scientific format.
 380                      $exponent = 'e'.--$p10;
 381                      $answer *= 10;
 382                      if (1 == $length) {
 383                          $x = $sign.$answer.$exponent;
 384                      } else {
 385                          // Attach additional zeros at the end of $answer.
 386                          $answer .= (1 == strlen($answer) ? '.' : '')
 387                              . '00000000000000000000000000000000000000000x';
 388                          $x = $sign
 389                              .substr($answer, 0, $length +1).$exponent;
 390                      }
 391                  } else {
 392                      // Stick to plain numeric format.
 393                      $answer *= "1e{$p10}";
 394                      if (0.1 <= $answer / "1e{$length}") {
 395                          $x = $sign.$answer;
 396                      } else {
 397                          // Could be an idea to add some zeros here.
 398                          $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '')
 399                              . '00000000000000000000000000000000000000000x';
 400                          $oklen = $length + ($p10 < 1 ? 2-$p10 : 1);
 401                          $x = $sign.substr($answer, 0, $oklen);
 402                      }
 403                  }
 404  
 405              } else {
 406                  $x = 0.0;
 407              }
 408          }
 409          return str_replace('.', $this->decimalpoint, $x);
 410      }
 411  
 412      /**
 413       * Return an array of the variables and their values.
 414       * @return array name => value.
 415       */
 416      public function get_values() {
 417          return $this->values;
 418      }
 419  
 420      /**
 421       * Evaluate an expression using the variable values.
 422       * @param string $expression the expression. A PHP expression with placeholders
 423       *      like {a} for where the variables need to go.
 424       * @return float the computed result.
 425       */
 426      public function calculate($expression) {
 427          // Make sure no malicious code is present in the expression. Refer MDL-46148 for details.
 428          if ($error = qtype_calculated_find_formula_errors($expression)) {
 429              throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $error);
 430          }
 431          $expression = $this->substitute_values_for_eval($expression);
 432          if ($datasets = question_bank::get_qtype('calculated')->find_dataset_names($expression)) {
 433              // Some placeholders were not substituted.
 434              throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '',
 435                  '{' . reset($datasets) . '}');
 436          }
 437          return $this->calculate_raw($expression);
 438      }
 439  
 440      /**
 441       * Evaluate an expression after the variable values have been substituted.
 442       * @param string $expression the expression. A PHP expression with placeholders
 443       *      like {a} for where the variables need to go.
 444       * @return float the computed result.
 445       */
 446      protected function calculate_raw($expression) {
 447          try {
 448              // In older PHP versions this this is a way to validate code passed to eval.
 449              // The trick came from http://php.net/manual/en/function.eval.php.
 450              if (@eval('return true; $result = ' . $expression . ';')) {
 451                  return eval('return ' . $expression . ';');
 452              }
 453          } catch (Throwable $e) {
 454              // PHP7 and later now throws ParseException and friends from eval(),
 455              // which is much better.
 456          }
 457          // In either case of an invalid $expression, we end here.
 458          throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression);
 459      }
 460  
 461      /**
 462       * Substitute variable placehodlers like {a} with their value wrapped in ().
 463       * @param string $expression the expression. A PHP expression with placeholders
 464       *      like {a} for where the variables need to go.
 465       * @return string the expression with each placeholder replaced by the
 466       *      corresponding value.
 467       */
 468      protected function substitute_values_for_eval($expression) {
 469          return str_replace($this->search, $this->safevalue, $expression);
 470      }
 471  
 472      /**
 473       * Substitute variable placehodlers like {a} with their value without wrapping
 474       * the value in anything.
 475       * @param string $text some content with placeholders
 476       *      like {a} for where the variables need to go.
 477       * @return string the expression with each placeholder replaced by the
 478       *      corresponding value.
 479       */
 480      protected function substitute_values_pretty($text) {
 481          return str_replace($this->search, $this->prettyvalue, $text);
 482      }
 483  
 484      /**
 485       * Replace any embedded variables (like {a}) or formulae (like {={a} + {b}})
 486       * in some text with the corresponding values.
 487       * @param string $text the text to process.
 488       * @return string the text with values substituted.
 489       */
 490      public function replace_expressions_in_text($text, $length = null, $format = null) {
 491          $vs = $this; // Can't use $this in a PHP closure.
 492          $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX,
 493                  function ($matches) use ($vs, $format, $length) {
 494                      return $vs->format_float($vs->calculate($matches[1]), $length, $format);
 495                  }, $text);
 496          return $this->substitute_values_pretty($text);
 497      }
 498  }