Merge branch 'MDL-70094-310' of https://github.com/SangNguyen2601/moodle into MOODLE_...
[moodle.git] / question / type / calculated / question.php
CommitLineData
06525476
TH
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
cdece95e 22 * @copyright 2011 The Open University
06525476
TH
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26
27defined('MOODLE_INTERNAL') || die();
28
97562c4d 29require_once($CFG->dirroot . '/question/type/questionbase.php');
06525476 30require_once($CFG->dirroot . '/question/type/numerical/question.php');
75aa674b 31require_once($CFG->dirroot . '/question/type/calculated/questiontype.php');
06525476 32
06525476
TH
33/**
34 * Represents a calculated question.
35 *
cdece95e 36 * @copyright 2011 The Open University
06525476
TH
37 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38 */
cdece95e
TH
39class qtype_calculated_question extends qtype_numerical_question
40 implements qtype_calculated_question_with_expressions {
e35ba43c 41
06525476 42 /** @var qtype_calculated_dataset_loader helper for loading the dataset. */
1da4060f 43 public $datasetloader;
e35ba43c 44
06525476 45 /** @var qtype_calculated_variable_substituter stores the dataset we are using. */
1da4060f 46 public $vs;
06525476 47
e35ba43c
TH
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
1da821bb 54 public function start_attempt(question_attempt_step $step, $variant) {
c014b989 55 qtype_calculated_question_helper::start_attempt($this, $step, $variant);
1da821bb 56 parent::start_attempt($step, $variant);
cdece95e
TH
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 }
c014b989
TH
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) &&
409199d6 84 $this->datasetloader->datasets_are_synchronised($this->category)) {
c014b989
TH
85 return 'category' . $this->category;
86 } else {
87 return parent::get_variants_selection_seed();
88 }
89 }
9ddb8a56 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
cdece95e
TH
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
e0736817 117 * fields
cdece95e
TH
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 */
124interface 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 */
144abstract class qtype_calculated_question_helper {
145 public static function start_attempt(
c014b989
TH
146 qtype_calculated_question_with_expressions $question,
147 question_attempt_step $step, $variant) {
06525476 148
cdece95e 149 $question->vs = new qtype_calculated_variable_substituter(
c014b989 150 $question->datasetloader->get_values($variant),
1da4060f 151 get_string('decsep', 'langconfig'));
cdece95e 152 $question->calculate_all_expressions();
06525476 153
cdece95e 154 foreach ($question->vs->get_values() as $name => $value) {
06525476
TH
155 $step->set_qt_var('_var_' . $name, $value);
156 }
06525476
TH
157 }
158
cdece95e
TH
159 public static function apply_attempt_state(
160 qtype_calculated_question_with_expressions $question, question_attempt_step $step) {
06525476 161 $values = array();
18f9b2d2 162 foreach ($step->get_qt_data() as $name => $value) {
06525476
TH
163 if (substr($name, 0, 5) === '_var_') {
164 $values[substr($name, 5)] = $value;
165 }
166 }
06525476 167
cdece95e 168 $question->vs = new qtype_calculated_variable_substituter(
1da4060f 169 $values, get_string('decsep', 'langconfig'));
cdece95e 170 $question->calculate_all_expressions();
06525476
TH
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 */
182class 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. */
19e911a2 187 protected $itemsavailable = null;
06525476
TH
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
19e911a2
TH
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() {
06525476
TH
204 global $DB;
205
19e911a2
TH
206 if (is_null($this->itemsavailable)) {
207 $this->itemsavailable = $DB->get_field_sql('
06525476
TH
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 = ?
19e911a2 212 ', array($this->questionid), MUST_EXIST);
06525476
TH
213 }
214
19e911a2 215 return $this->itemsavailable;
06525476
TH
216 }
217
1da4060f
TH
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) {
18f9b2d2
TH
224 global $DB;
225
226 return $DB->get_records_sql_menu('
1da4060f
TH
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
06525476 236 /**
19e911a2
TH
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()}.
1da4060f 240 * @return array name => value.
06525476 241 */
1da4060f 242 public function get_values($itemnumber) {
19e911a2 243 if ($itemnumber <= 0 || $itemnumber > $this->get_number_of_items()) {
06525476 244 $a = new stdClass();
19e911a2
TH
245 $a->id = $this->questionid;
246 $a->item = $itemnumber;
06525476
TH
247 throw new moodle_exception('cannotgetdsfordependent', 'question', '', $a);
248 }
249
1da4060f 250 return $this->load_values($itemnumber);
06525476 251 }
e35ba43c
TH
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 }
06525476
TH
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 */
281class qtype_calculated_variable_substituter {
155bc754 282
06525476
TH
283 /** @var array variable name => value */
284 protected $values;
285
1da4060f
TH
286 /** @var string character to use for the decimal point in displayed numbers. */
287 protected $decimalpoint;
288
06525476
TH
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 */
1da4060f
TH
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;
06525476
TH
303
304 /**
305 * Constructor
306 * @param array $values variable name => value.
307 */
1da4060f 308 public function __construct(array $values, $decimalpoint) {
06525476 309 $this->values = $values;
1da4060f 310 $this->decimalpoint = $decimalpoint;
06525476
TH
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 . '}';
1da4060f
TH
324 $this->safevalue[] = '(' . $value . ')';
325 $this->prettyvalue[] = $this->format_float($value);
06525476
TH
326 }
327 }
328
1da4060f
TH
329 /**
330 * Display a float properly formatted with a certain number of decimal places.
01533e9c
TH
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.
1da4060f 336 */
cdece95e 337 public function format_float($x, $length = null, $format = null) {
3aa15970 338 if (!is_null($length) && !is_null($format)) {
3d9645ae 339 if ($format == '1' ) { // Answer is to have $length decimals.
cdece95e
TH
340 // Decimal places.
341 $x = sprintf('%.' . $length . 'F', $x);
9ddb8a56 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.
f4fe3968
TH
389 $answer *= "1e{$p10}";
390 if (0.1 <= $answer / "1e{$length}") {
9ddb8a56 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;
cdece95e
TH
403 }
404 }
1da4060f
TH
405 return str_replace('.', $this->decimalpoint, $x);
406 }
407
06525476
TH
408 /**
409 * Return an array of the variables and their values.
e0736817 410 * @return array name => value.
06525476
TH
411 */
412 public function get_values() {
1da4060f 413 return $this->values;
06525476
TH
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) {
82b3260e
AA
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 }
897a4927
MG
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);
1da4060f
TH
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) {
c6f91070
TL
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.
19e911a2 452 }
c6f91070
TL
453 // In either case of an invalid $expression, we end here.
454 throw new moodle_exception('illegalformulasyntax', 'qtype_calculated', '', $expression);
06525476
TH
455 }
456
457 /**
1da4060f 458 * Substitute variable placehodlers like {a} with their value wrapped in ().
06525476
TH
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 */
1da4060f
TH
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);
06525476
TH
478 }
479
1da4060f
TH
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 */
cdece95e 486 public function replace_expressions_in_text($text, $length = null, $format = null) {
29005a54 487 $vs = $this; // Can't use $this in a PHP closure.
155bc754 488 $text = preg_replace_callback(qtype_calculated::FORMULAS_IN_TEXT_REGEX,
cdece95e
TH
489 function ($matches) use ($vs, $format, $length) {
490 return $vs->format_float($vs->calculate($matches[1]), $length, $format);
491 }, $text);
1da4060f 492 return $this->substitute_values_pretty($text);
06525476 493 }
29005a54 494}