Search moodle.org's
Developer Documentation

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
  • Differences Between: [Versions 37 and 311] [Versions 38 and 311] [Versions 39 and 311]

       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  }