Merge branch 'MDL-68749-310-2' of git://github.com/mickhawkins/moodle into MOODLE_310...
[moodle.git] / question / type / calculated / question.php
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/>.
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  */
27 defined('MOODLE_INTERNAL') || die();
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');
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 {
42     /** @var qtype_calculated_dataset_loader helper for loading the dataset. */
43     public $datasetloader;
45     /** @var qtype_calculated_variable_substituter stores the dataset we are using. */
46     public $vs;
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;
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     }
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     }
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);
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     }
78     public function get_num_variants() {
79         return $this->datasetloader->get_number_of_items();
80     }
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     }
91     public function get_correct_response() {
92         $answer = $this->get_correct_answer();
93         if (!$answer) {
94             return array();
95         }
97         $response = array('answer' => $this->vs->format_float($answer->answer,
98             $answer->correctanswerlength, $answer->correctanswerformat));
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         }
106         return $response;
107     }
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();
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) {
149         $question->vs = new qtype_calculated_variable_substituter(
150                 $question->datasetloader->get_values($variant),
151                 get_string('decsep', 'langconfig'));
152         $question->calculate_all_expressions();
154         foreach ($question->vs->get_values() as $name => $value) {
155             $step->set_qt_var('_var_' . $name, $value);
156         }
157     }
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         }
168         $question->vs = new qtype_calculated_variable_substituter(
169                 $values, get_string('decsep', 'langconfig'));
170         $question->calculate_all_expressions();
171     }
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;
186     /** @var int the id of the question we are helping. */
187     protected $itemsavailable = null;
189     /**
190      * Constructor
191      * @param int $questionid the question to load datasets for.
192      */
193     public function __construct($questionid) {
194         $this->questionid = $questionid;
195     }
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;
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         }
215         return $this->itemsavailable;
216     }
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;
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     }
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         }
250         return $this->load_values($itemnumber);
251     }
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));
266         return $categories && $categories->max == $category && $categories->min == $category;
267     }
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 {
283     /** @var array variable name => value */
284     protected $values;
286     /** @var string character to use for the decimal point in displayed numbers. */
287     protected $decimalpoint;
289     /** @var array variable names wrapped in {...}. Used by {@link substitute_values()}. */
290     protected $search;
292     /**
293      * @var array variable values, with negative numbers wrapped in (...).
294      * Used by {@link substitute_values()}.
295      */
296     protected $safevalue;
298     /**
299      * @var array variable values, with negative numbers wrapped in (...).
300      * Used by {@link substitute_values()}.
301      */
302     protected $prettyvalue;
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;
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             }
323             $this->search[] = '{' . $name . '}';
324             $this->safevalue[] = '(' . $value . ')';
325             $this->prettyvalue[] = $this->format_float($value);
326         }
327     }
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);
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                 }
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);
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                 }
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                 }
401             } else {
402                 $x = 0.0;
403             }
404         }
405         return str_replace('.', $this->decimalpoint, $x);
406     }
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     }
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     }
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     }
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     }
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     }
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     }