Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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

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