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
27defined('MOODLE_INTERNAL') || die();
28
29require_once($CFG->dirroot . '/question/type/questionbase.php');
30require_once($CFG->dirroot . '/question/type/numerical/question.php');
31require_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 */
39class 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 */
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(
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 */
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. */
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 */
281class 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}
499