MDL-31360 use extra param in get_in_or_equal - one less line of code...
[moodle.git] / question / engine / datalib.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  * Code for loading and saving question attempts to and from the database.
19  *
20  * @package    moodlecore
21  * @subpackage questionengine
22  * @copyright  2009 The Open University
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
27 defined('MOODLE_INTERNAL') || die();
30 /**
31  * This class controls the loading and saving of question engine data to and from
32  * the database.
33  *
34  * @copyright  2009 The Open University
35  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36  */
37 class question_engine_data_mapper {
38     /**
39      * @var moodle_database normally points to global $DB, but I prefer not to
40      * use globals if I can help it.
41      */
42     protected $db;
44     /**
45      * @param moodle_database $db a database connectoin. Defaults to global $DB.
46      */
47     public function __construct($db = null) {
48         if (is_null($db)) {
49             global $DB;
50             $this->db = $DB;
51         } else {
52             $this->db = $db;
53         }
54     }
56     /**
57      * Store an entire {@link question_usage_by_activity} in the database,
58      * including all the question_attempts that comprise it.
59      * @param question_usage_by_activity $quba the usage to store.
60      */
61     public function insert_questions_usage_by_activity(question_usage_by_activity $quba) {
62         $record = new stdClass();
63         $record->contextid = $quba->get_owning_context()->id;
64         $record->component = $quba->get_owning_component();
65         $record->preferredbehaviour = $quba->get_preferred_behaviour();
67         $newid = $this->db->insert_record('question_usages', $record);
68         $quba->set_id_from_database($newid);
70         foreach ($quba->get_attempt_iterator() as $qa) {
71             $this->insert_question_attempt($qa, $quba->get_owning_context());
72         }
73     }
75     /**
76      * Store an entire {@link question_attempt} in the database,
77      * including all the question_attempt_steps that comprise it.
78      * @param question_attempt $qa the question attempt to store.
79      * @param object $context the context of the owning question_usage_by_activity.
80      */
81     public function insert_question_attempt(question_attempt $qa, $context) {
82         $record = new stdClass();
83         $record->questionusageid = $qa->get_usage_id();
84         $record->slot = $qa->get_slot();
85         $record->behaviour = $qa->get_behaviour_name();
86         $record->questionid = $qa->get_question()->id;
87         $record->variant = $qa->get_variant();
88         $record->maxmark = $qa->get_max_mark();
89         $record->minfraction = $qa->get_min_fraction();
90         $record->flagged = $qa->is_flagged();
91         $record->questionsummary = $qa->get_question_summary();
92         if (textlib::strlen($record->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
93             // It seems some people write very long quesions! MDL-30760
94             $record->questionsummary = textlib::substr($record->questionsummary,
95                     0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
96         }
97         $record->rightanswer = $qa->get_right_answer_summary();
98         $record->responsesummary = $qa->get_response_summary();
99         $record->timemodified = time();
100         $record->id = $this->db->insert_record('question_attempts', $record);
102         foreach ($qa->get_step_iterator() as $seq => $step) {
103             $this->insert_question_attempt_step($step, $record->id, $seq, $context);
104         }
105     }
107     /**
108      * Store a {@link question_attempt_step} in the database.
109      * @param question_attempt_step $qa the step to store.
110      * @param int $questionattemptid the question attept id this step belongs to.
111      * @param int $seq the sequence number of this stop.
112      * @param object $context the context of the owning question_usage_by_activity.
113      */
114     public function insert_question_attempt_step(question_attempt_step $step,
115             $questionattemptid, $seq, $context) {
116         $record = new stdClass();
117         $record->questionattemptid = $questionattemptid;
118         $record->sequencenumber = $seq;
119         $record->state = (string) $step->get_state();
120         $record->fraction = $step->get_fraction();
121         $record->timecreated = $step->get_timecreated();
122         $record->userid = $step->get_user_id();
124         $record->id = $this->db->insert_record('question_attempt_steps', $record);
126         foreach ($step->get_all_data() as $name => $value) {
127             if ($value instanceof question_file_saver) {
128                 $value->save_files($record->id, $context);
129             }
131             $data = new stdClass();
132             $data->attemptstepid = $record->id;
133             $data->name = $name;
134             $data->value = $value;
135             $this->db->insert_record('question_attempt_step_data', $data, false);
136         }
137     }
139     /**
140      * Load a {@link question_attempt_step} from the database.
141      * @param int $stepid the id of the step to load.
142      * @param question_attempt_step the step that was loaded.
143      */
144     public function load_question_attempt_step($stepid) {
145         $records = $this->db->get_recordset_sql("
146 SELECT
147     qas.id AS attemptstepid,
148     qas.questionattemptid,
149     qas.sequencenumber,
150     qas.state,
151     qas.fraction,
152     qas.timecreated,
153     qas.userid,
154     qasd.name,
155     qasd.value
157 FROM {question_attempt_steps} qas
158 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
160 WHERE
161     qas.id = :stepid
162         ", array('stepid' => $stepid));
164         if (!$records->valid()) {
165             throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
166         }
168         $step = question_attempt_step::load_from_records($records, $stepid);
169         $records->close();
171         return $step;
172     }
174     /**
175      * Load a {@link question_attempt} from the database, including all its
176      * steps.
177      * @param int $questionattemptid the id of the question attempt to load.
178      * @param question_attempt the question attempt that was loaded.
179      */
180     public function load_question_attempt($questionattemptid) {
181         $records = $this->db->get_recordset_sql("
182 SELECT
183     quba.contextid,
184     quba.preferredbehaviour,
185     qa.id AS questionattemptid,
186     qa.questionusageid,
187     qa.slot,
188     qa.behaviour,
189     qa.questionid,
190     qa.variant,
191     qa.maxmark,
192     qa.minfraction,
193     qa.flagged,
194     qa.questionsummary,
195     qa.rightanswer,
196     qa.responsesummary,
197     qa.timemodified,
198     qas.id AS attemptstepid,
199     qas.sequencenumber,
200     qas.state,
201     qas.fraction,
202     qas.timecreated,
203     qas.userid,
204     qasd.name,
205     qasd.value
207 FROM      {question_attempts           qa
208 JOIN      {question_usages}            quba ON quba.id               = qa.questionusageid
209 LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
210 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
212 WHERE
213     qa.id = :questionattemptid
215 ORDER BY
216     qas.sequencenumber
217         ", array('questionattemptid' => $questionattemptid));
219         if (!$records->valid()) {
220             throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
221         }
223         $record = current($records);
224         $qa = question_attempt::load_from_records($records, $questionattemptid,
225                 new question_usage_null_observer(), $record->preferredbehaviour);
226         $records->close();
228         return $qa;
229     }
231     /**
232      * Load a {@link question_usage_by_activity} from the database, including
233      * all its {@link question_attempt}s and all their steps.
234      * @param int $qubaid the id of the usage to load.
235      * @param question_usage_by_activity the usage that was loaded.
236      */
237     public function load_questions_usage_by_activity($qubaid) {
238         $records = $this->db->get_recordset_sql("
239 SELECT
240     quba.id AS qubaid,
241     quba.contextid,
242     quba.component,
243     quba.preferredbehaviour,
244     qa.id AS questionattemptid,
245     qa.questionusageid,
246     qa.slot,
247     qa.behaviour,
248     qa.questionid,
249     qa.variant,
250     qa.maxmark,
251     qa.minfraction,
252     qa.flagged,
253     qa.questionsummary,
254     qa.rightanswer,
255     qa.responsesummary,
256     qa.timemodified,
257     qas.id AS attemptstepid,
258     qas.sequencenumber,
259     qas.state,
260     qas.fraction,
261     qas.timecreated,
262     qas.userid,
263     qasd.name,
264     qasd.value
266 FROM      {question_usages}            quba
267 LEFT JOIN {question_attempts}          qa   ON qa.questionusageid    = quba.id
268 LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
269 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
271 WHERE
272     quba.id = :qubaid
274 ORDER BY
275     qa.slot,
276     qas.sequencenumber
277     ", array('qubaid' => $qubaid));
279         if (!$records->valid()) {
280             throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
281         }
283         $quba = question_usage_by_activity::load_from_records($records, $qubaid);
284         $records->close();
286         return $quba;
287     }
289     /**
290      * Load information about the latest state of each question from the database.
291      *
292      * @param qubaid_condition $qubaids used to restrict which usages are included
293      * in the query. See {@link qubaid_condition}.
294      * @param array $slots A list of slots for the questions you want to konw about.
295      * @return array of records. See the SQL in this function to see the fields available.
296      */
297     public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots) {
298         list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
300         $records = $this->db->get_records_sql("
301 SELECT
302     qas.id,
303     qa.id AS questionattemptid,
304     qa.questionusageid,
305     qa.slot,
306     qa.behaviour,
307     qa.questionid,
308     qa.variant,
309     qa.maxmark,
310     qa.minfraction,
311     qa.flagged,
312     qa.questionsummary,
313     qa.rightanswer,
314     qa.responsesummary,
315     qa.timemodified,
316     qas.id AS attemptstepid,
317     qas.sequencenumber,
318     qas.state,
319     qas.fraction,
320     qas.timecreated,
321     qas.userid
323 FROM {$qubaids->from_question_attempts('qa')}
324 JOIN {question_attempt_steps} qas ON
325         qas.id = {$this->latest_step_for_qa_subquery()}
327 WHERE
328     {$qubaids->where()} AND
329     qa.slot $slottest
330         ", $params + $qubaids->from_where_params());
332         return $records;
333     }
335     /**
336      * Load summary information about the state of each question in a group of
337      * attempts. This is used, for example, by the quiz manual grading report,
338      * to show how many attempts at each question need to be graded.
339      *
340      * @param qubaid_condition $qubaids used to restrict which usages are included
341      * in the query. See {@link qubaid_condition}.
342      * @param array $slots A list of slots for the questions you want to konw about.
343      * @return array The array keys are slot,qestionid. The values are objects with
344      * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
345      * $manuallygraded and $all.
346      */
347     public function load_questions_usages_question_state_summary(
348             qubaid_condition $qubaids, $slots) {
349         list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
351         $rs = $this->db->get_recordset_sql("
352 SELECT
353     qa.slot,
354     qa.questionid,
355     q.name,
356     CASE qas.state
357         {$this->full_states_to_summary_state_sql()}
358     END AS summarystate,
359     COUNT(1) AS numattempts
361 FROM {$qubaids->from_question_attempts('qa')}
362 JOIN {question_attempt_steps} qas ON
363         qas.id = {$this->latest_step_for_qa_subquery()}
364 JOIN {question} q ON q.id = qa.questionid
366 WHERE
367     {$qubaids->where()} AND
368     qa.slot $slottest
370 GROUP BY
371     qa.slot,
372     qa.questionid,
373     q.name,
374     q.id,
375     CASE qas.state
376         {$this->full_states_to_summary_state_sql()}
377     END
379 ORDER BY
380     qa.slot,
381     qa.questionid,
382     q.name,
383     q.id
384         ", $params + $qubaids->from_where_params());
386         $results = array();
387         foreach ($rs as $row) {
388             $index = $row->slot . ',' . $row->questionid;
390             if (!array_key_exists($index, $results)) {
391                 $res = new stdClass();
392                 $res->slot = $row->slot;
393                 $res->questionid = $row->questionid;
394                 $res->name = $row->name;
395                 $res->inprogress = 0;
396                 $res->needsgrading = 0;
397                 $res->autograded = 0;
398                 $res->manuallygraded = 0;
399                 $res->all = 0;
400                 $results[$index] = $res;
401             }
403             $results[$index]->{$row->summarystate} = $row->numattempts;
404             $results[$index]->all += $row->numattempts;
405         }
406         $rs->close();
408         return $results;
409     }
411     /**
412      * Get a list of usage ids where the question with slot $slot, and optionally
413      * also with question id $questionid, is in summary state $summarystate. Also
414      * return the total count of such states.
415      *
416      * Only a subset of the ids can be returned by using $orderby, $limitfrom and
417      * $limitnum. A special value 'random' can be passed as $orderby, in which case
418      * $limitfrom is ignored.
419      *
420      * @param qubaid_condition $qubaids used to restrict which usages are included
421      * in the query. See {@link qubaid_condition}.
422      * @param int $slot The slot for the questions you want to konw about.
423      * @param int $questionid (optional) Only return attempts that were of this specific question.
424      * @param string $summarystate the summary state of interest, or 'all'.
425      * @param string $orderby the column to order by.
426      * @param array $params any params required by any of the SQL fragments.
427      * @param int $limitfrom implements paging of the results.
428      *      Ignored if $orderby = random or $limitnum is null.
429      * @param int $limitnum implements paging of the results. null = all.
430      * @return array with two elements, an array of usage ids, and a count of the total number.
431      */
432     public function load_questions_usages_where_question_in_state(
433             qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
434             $orderby = 'random', $params, $limitfrom = 0, $limitnum = null) {
436         $extrawhere = '';
437         if ($questionid) {
438             $extrawhere .= ' AND qa.questionid = :questionid';
439             $params['questionid'] = $questionid;
440         }
441         if ($summarystate != 'all') {
442             list($test, $sparams) = $this->in_summary_state_test($summarystate);
443             $extrawhere .= ' AND qas.state ' . $test;
444             $params += $sparams;
445         }
447         if ($orderby == 'random') {
448             $sqlorderby = '';
449         } else if ($orderby) {
450             $sqlorderby = 'ORDER BY ' . $orderby;
451         } else {
452             $sqlorderby = '';
453         }
455         // We always want the total count, as well as the partcular list of ids,
456         // based on the paging and sort order. Becuase the list of ids is never
457         // going to be too rediculously long. My worst-case scenario is
458         // 10,000 students in the coures, each doing 5 quiz attempts. That
459         // is a 50,000 element int => int array, which PHP seems to use 5MB
460         // memeory to store on a 64 bit server.
461         $params += $qubaids->from_where_params();
462         $params['slot'] = $slot;
463         $qubaids = $this->db->get_records_sql_menu("
464 SELECT
465     qa.questionusageid,
466     1
468 FROM {$qubaids->from_question_attempts('qa')}
469 JOIN {question_attempt_steps} qas ON
470         qas.id = {$this->latest_step_for_qa_subquery()}
471 JOIN {question} q ON q.id = qa.questionid
473 WHERE
474     {$qubaids->where()} AND
475     qa.slot = :slot
476     $extrawhere
478 $sqlorderby
479         ", $params);
481         $qubaids = array_keys($qubaids);
482         $count = count($qubaids);
484         if ($orderby == 'random') {
485             shuffle($qubaids);
486             $limitfrom = 0;
487         }
489         if (!is_null($limitnum)) {
490             $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
491         }
493         return array($qubaids, $count);
494     }
496     /**
497      * Load a {@link question_usage_by_activity} from the database, including
498      * all its {@link question_attempt}s and all their steps.
499      * @param qubaid_condition $qubaids used to restrict which usages are included
500      * in the query. See {@link qubaid_condition}.
501      * @param array $slots if null, load info for all quesitions, otherwise only
502      * load the averages for the specified questions.
503      */
504     public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
505         if (!empty($slots)) {
506             list($slottest, $slotsparams) = $this->db->get_in_or_equal(
507                     $slots, SQL_PARAMS_NAMED, 'slot');
508             $slotwhere = " AND qa.slot $slottest";
509         } else {
510             $slotwhere = '';
511             $params = array();
512         }
514         list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
515                 (string) question_state::$gaveup,
516                 (string) question_state::$gradedwrong,
517                 (string) question_state::$gradedpartial,
518                 (string) question_state::$gradedright,
519                 (string) question_state::$mangaveup,
520                 (string) question_state::$mangrwrong,
521                 (string) question_state::$mangrpartial,
522                 (string) question_state::$mangrright), SQL_PARAMS_NAMED, 'st');
524         return $this->db->get_records_sql("
525 SELECT
526     qa.slot,
527     AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
528     COUNT(1) AS numaveraged
530 FROM {$qubaids->from_question_attempts('qa')}
531 JOIN {question_attempt_steps} qas ON
532         qas.id = {$this->latest_step_for_qa_subquery()}
534 WHERE
535     {$qubaids->where()}
536     $slotwhere
537     AND qas.state $statetest
539 GROUP BY qa.slot
541 ORDER BY qa.slot
542         ", $slotsparams + $stateparams + $qubaids->from_where_params());
543     }
545     /**
546      * Load a {@link question_attempt} from the database, including all its
547      * steps.
548      * @param int $questionid the question to load all the attempts fors.
549      * @param qubaid_condition $qubaids used to restrict which usages are included
550      * in the query. See {@link qubaid_condition}.
551      * @return array of question_attempts.
552      */
553     public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
554         global $DB;
556         $params = $qubaids->from_where_params();
557         $params['questionid'] = $questionid;
559         $records = $DB->get_recordset_sql("
560 SELECT
561     quba.contextid,
562     quba.preferredbehaviour,
563     qa.id AS questionattemptid,
564     qa.questionusageid,
565     qa.slot,
566     qa.behaviour,
567     qa.questionid,
568     qa.variant,
569     qa.maxmark,
570     qa.minfraction,
571     qa.flagged,
572     qa.questionsummary,
573     qa.rightanswer,
574     qa.responsesummary,
575     qa.timemodified,
576     qas.id AS attemptstepid,
577     qas.sequencenumber,
578     qas.state,
579     qas.fraction,
580     qas.timecreated,
581     qas.userid,
582     qasd.name,
583     qasd.value
585 FROM {$qubaids->from_question_attempts('qa')}
586 JOIN {question_usages} quba ON quba.id = qa.questionusageid
587 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
588 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
590 WHERE
591     {$qubaids->where()} AND
592     qa.questionid = :questionid
594 ORDER BY
595     quba.id,
596     qa.id,
597     qas.sequencenumber
598         ", $params);
600         $questionattempts = array();
601         while ($records->valid()) {
602             $record = $records->current();
603             $questionattempts[$record->questionattemptid] =
604                     question_attempt::load_from_records($records,
605                     $record->questionattemptid, new question_usage_null_observer(),
606                     $record->preferredbehaviour);
607         }
608         $records->close();
610         return $questionattempts;
611     }
613     /**
614      * Update a question_usages row to refect any changes in a usage (but not
615      * any of its question_attempts.
616      * @param question_usage_by_activity $quba the usage that has changed.
617      */
618     public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
619         $record = new stdClass();
620         $record->id = $quba->get_id();
621         $record->contextid = $quba->get_owning_context()->id;
622         $record->component = $quba->get_owning_component();
623         $record->preferredbehaviour = $quba->get_preferred_behaviour();
625         $this->db->update_record('question_usages', $record);
626     }
628     /**
629      * Update a question_attempts row to refect any changes in a question_attempt
630      * (but not any of its steps).
631      * @param question_attempt $qa the question attempt that has changed.
632      */
633     public function update_question_attempt(question_attempt $qa) {
634         $record = new stdClass();
635         $record->id = $qa->get_database_id();
636         $record->maxmark = $qa->get_max_mark();
637         $record->minfraction = $qa->get_min_fraction();
638         $record->flagged = $qa->is_flagged();
639         $record->questionsummary = $qa->get_question_summary();
640         $record->rightanswer = $qa->get_right_answer_summary();
641         $record->responsesummary = $qa->get_response_summary();
642         $record->timemodified = time();
644         $this->db->update_record('question_attempts', $record);
645     }
647     /**
648      * Delete a question_usage_by_activity and all its associated
649      * {@link question_attempts} and {@link question_attempt_steps} from the
650      * database.
651      * @param qubaid_condition $qubaids identifies which question useages to delete.
652      */
653     public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
654         $where = "qa.questionusageid {$qubaids->usage_id_in()}";
655         $params = $qubaids->usage_id_in_params();
657         $contextids = $this->db->get_records_sql_menu("
658                 SELECT DISTINCT contextid, 1
659                 FROM {question_usages}
660                 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
661         foreach ($contextids as $contextid => $notused) {
662             $this->delete_response_files($contextid, "IN (
663                     SELECT qas.id
664                     FROM {question_attempts} qa
665                     JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
666                     WHERE $where)", $params);
667         }
669         if ($this->db->get_dbfamily() == 'mysql') {
670             $this->delete_usage_records_for_mysql($qubaids);
671             return;
672         }
674         $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
675                 SELECT qas.id
676                 FROM {question_attempts} qa
677                 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
678                 WHERE $where)", $params);
680         $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
681                 SELECT qa.id
682                 FROM {question_attempts} qa
683                 WHERE $where)", $params);
685         $this->db->delete_records_select('question_attempts',
686                 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
687                 $qubaids->usage_id_in_params());
689         $this->db->delete_records_select('question_usages',
690                 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
691     }
693     /**
694      * This function is a work-around for poor MySQL performance with
695      * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
696      * syntax to get good performance. See MDL-29520.
697      * @param qubaid_condition $qubaids identifies which question useages to delete.
698      */
699     protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) {
700         // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
701         $this->db->execute('
702                 DELETE qu, qa, qas, qasd
703                   FROM {question_usages}            qu
704                   JOIN {question_attempts}          qa   ON qa.questionusageid = qu.id
705              LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
706              LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
707                  WHERE qu.id ' . $qubaids->usage_id_in(),
708                 $qubaids->usage_id_in_params());
709     }
711     /**
712      * This function is a work-around for poor MySQL performance with
713      * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
714      * syntax to get good performance. See MDL-29520.
715      * @param string $test sql fragment.
716      * @param array $params used by $test.
717      */
718     protected function delete_attempt_steps_for_mysql($test, $params) {
719         // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
720         $this->db->execute('
721                 DELETE qas, qasd
722                   FROM {question_attempt_steps}     qas
723              LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
724                  WHERE qas.questionattemptid ' . $test, $params);
725     }
727     /**
728      * Delete all the steps for a question attempt.
729      * @param int $qaids question_attempt id.
730      */
731     public function delete_steps_for_question_attempts($qaids, $context) {
732         if (empty($qaids)) {
733             return;
734         }
735         list($test, $params) = $this->db->get_in_or_equal($qaids, SQL_PARAMS_NAMED);
737         $this->delete_response_files($context->id, "IN (
738                 SELECT id
739                 FROM {question_attempt_steps}
740                 WHERE questionattemptid $test)", $params);
742         if ($this->db->get_dbfamily() == 'mysql') {
743             $this->delete_attempt_steps_for_mysql($test, $params);
744             return;
745         }
747         $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
748                 SELECT qas.id
749                 FROM {question_attempt_steps} qas
750                 WHERE questionattemptid $test)", $params);
751         $this->db->delete_records_select('question_attempt_steps',
752                 'questionattemptid ' . $test, $params);
753     }
755     /**
756      * Delete all the files belonging to the response variables in the gives
757      * question attempt steps.
758      * @param int $contextid the context these attempts belong to.
759      * @param string $itemidstest a bit of SQL that can be used in a
760      *      WHERE itemid $itemidstest clause. Must use named params.
761      * @param array $params any query parameters used in $itemidstest.
762      */
763     protected function delete_response_files($contextid, $itemidstest, $params) {
764         $fs = get_file_storage();
765         foreach (question_engine::get_all_response_file_areas() as $filearea) {
766             $fs->delete_area_files_select($contextid, 'question', $filearea,
767                     $itemidstest, $params);
768         }
769     }
771     /**
772      * Delete all the previews for a given question.
773      * @param int $questionid question id.
774      */
775     public function delete_previews($questionid) {
776         $previews = $this->db->get_records_sql_menu("
777                 SELECT DISTINCT quba.id, 1
778                 FROM {question_usages} quba
779                 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
780                 WHERE quba.component = 'core_question_preview' AND
781                     qa.questionid = ?", array($questionid));
782         if (empty($previews)) {
783             return;
784         }
785         $this->delete_questions_usage_by_activities(new qubaid_list($previews));
786     }
788     /**
789      * Update the flagged state of a question in the database.
790      * @param int $qubaid the question usage id.
791      * @param int $questionid the question id.
792      * @param int $sessionid the question_attempt id.
793      * @param bool $newstate the new state of the flag. true = flagged.
794      */
795     public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
796         if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
797                 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
798             throw new moodle_exception('errorsavingflags', 'question');
799         }
801         $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
802     }
804     /**
805      * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
806      * column to a summary state. Use this like
807      * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
808      * @param string SQL fragment.
809      */
810     protected function full_states_to_summary_state_sql() {
811         $sql = '';
812         foreach (question_state::get_all() as $state) {
813             $sql .= "WHEN '$state' THEN '{$state->get_summary_state()}'\n";
814         }
815         return $sql;
816     }
818     /**
819      * Get the SQL needed to test that question_attempt_steps.state is in a
820      * state corresponding to $summarystate.
821      * @param string $summarystate one of
822      * inprogress, needsgrading, manuallygraded or autograded
823      * @param bool $equal if false, do a NOT IN test. Default true.
824      * @return string SQL fragment.
825      */
826     public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
827         $states = question_state::get_all_for_summary_state($summarystate);
828         return $this->db->get_in_or_equal(array_map('strval', $states),
829                 SQL_PARAMS_NAMED, $prefix, $equal);
830     }
832     /**
833      * Change the maxmark for the question_attempt with number in usage $slot
834      * for all the specified question_attempts.
835      * @param qubaid_condition $qubaids Selects which usages are updated.
836      * @param int $slot the number is usage to affect.
837      * @param number $newmaxmark the new max mark to set.
838      */
839     public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
840         $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
841                 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
842                 $qubaids->usage_id_in_params() + array('slot' => $slot));
843     }
845     /**
846      * Return a subquery that computes the sum of the marks for all the questions
847      * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
848      * parameter.
849      *
850      * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
851      * this method.
852      *
853      * @param string $qubaid SQL fragment that controls which usage is summed.
854      * This will normally be the name of a column in the outer query. Not that this
855      * SQL fragment must not contain any placeholders.
856      * @return string SQL code for the subquery.
857      */
858     public function sum_usage_marks_subquery($qubaid) {
859         // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
860         // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
861         // We always want to return a number, so the COALESCE is there to turn the
862         // NULL total into a 0.
863         return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
864             FROM {question_attempts} qa
865             JOIN {question_attempt_steps} qas ON qas.id = (
866                 SELECT MAX(summarks_qas.id)
867                   FROM {question_attempt_steps} summarks_qas
868                  WHERE summarks_qas.questionattemptid = qa.id
869             )
870             WHERE qa.questionusageid = $qubaid
871             HAVING COUNT(CASE
872                 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
873                 ELSE NULL
874             END) = 0";
875     }
877     /**
878      * Get a subquery that returns the latest step of every qa in some qubas.
879      * Currently, this is only used by the quiz reports. See
880      * {@link quiz_attempt_report_table::add_latest_state_join()}.
881      * @param string $alias alias to use for this inline-view.
882      * @param qubaid_condition $qubaids restriction on which question_usages we
883      *      are interested in. This is important for performance.
884      * @return array with two elements, the SQL fragment and any params requried.
885      */
886     public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) {
887         return array("(
888                 SELECT {$alias}qa.id AS questionattemptid,
889                        {$alias}qa.questionusageid,
890                        {$alias}qa.slot,
891                        {$alias}qa.behaviour,
892                        {$alias}qa.questionid,
893                        {$alias}qa.variant,
894                        {$alias}qa.maxmark,
895                        {$alias}qa.minfraction,
896                        {$alias}qa.flagged,
897                        {$alias}qa.questionsummary,
898                        {$alias}qa.rightanswer,
899                        {$alias}qa.responsesummary,
900                        {$alias}qa.timemodified,
901                        {$alias}qas.id AS attemptstepid,
902                        {$alias}qas.sequencenumber,
903                        {$alias}qas.state,
904                        {$alias}qas.fraction,
905                        {$alias}qas.timecreated,
906                        {$alias}qas.userid
908                   FROM {$qubaids->from_question_attempts($alias . 'qa')}
909                   JOIN {question_attempt_steps} {$alias}qas ON
910                            {$alias}qas.id = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
911                  WHERE {$qubaids->where()}
912             ) $alias", $qubaids->from_where_params());
913     }
915     protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
916         return "(
917                 SELECT MAX(id)
918                 FROM {question_attempt_steps}
919                 WHERE questionattemptid = $questionattemptid
920             )";
921     }
923     /**
924      * @param array $questionids of question ids.
925      * @param qubaid_condition $qubaids ids of the usages to consider.
926      * @return boolean whether any of these questions are being used by any of
927      *      those usages.
928      */
929     public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
930         list($test, $params) = $this->db->get_in_or_equal($questionids);
931         return $this->db->record_exists_select('question_attempts',
932                 'questionid ' . $test . ' AND questionusageid ' .
933                 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
934     }
938 /**
939  * Implementation of the unit of work pattern for the question engine.
940  *
941  * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
942  * changes to a {@link question_usage_by_activity}, and its constituent parts,
943  * so that the changes can be saved to the database when {@link save()} is called.
944  *
945  * @copyright  2009 The Open University
946  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
947  */
948 class question_engine_unit_of_work implements question_usage_observer {
949     /** @var question_usage_by_activity the usage being tracked. */
950     protected $quba;
952     /** @var boolean whether any of the fields of the usage have been changed. */
953     protected $modified = false;
955     /**
956      * @var array list of number in usage => {@link question_attempt}s that
957      * were already in the usage, and which have been modified.
958      */
959     protected $attemptsmodified = array();
961     /**
962      * @var array list of number in usage => {@link question_attempt}s that
963      * have been added to the usage.
964      */
965     protected $attemptsadded = array();
967     /**
968      * @var array list of question attempt ids to delete the steps for, before
969      * inserting new steps.
970      */
971     protected $attemptstodeletestepsfor = array();
973     /**
974      * @var array list of array(question_attempt_step, question_attempt id, seq number)
975      * of steps that have been added to question attempts in this usage.
976      */
977     protected $stepsadded = array();
979     /**
980      * Constructor.
981      * @param question_usage_by_activity $quba the usage to track.
982      */
983     public function __construct(question_usage_by_activity $quba) {
984         $this->quba = $quba;
985     }
987     public function notify_modified() {
988         $this->modified = true;
989     }
991     public function notify_attempt_modified(question_attempt $qa) {
992         $no = $qa->get_slot();
993         if (!array_key_exists($no, $this->attemptsadded)) {
994             $this->attemptsmodified[$no] = $qa;
995         }
996     }
998     public function notify_attempt_added(question_attempt $qa) {
999         $this->attemptsadded[$qa->get_slot()] = $qa;
1000     }
1002     public function notify_delete_attempt_steps(question_attempt $qa) {
1004         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1005             return;
1006         }
1008         $qaid = $qa->get_database_id();
1009         foreach ($this->stepsadded as $key => $stepinfo) {
1010             if ($stepinfo[1] == $qaid) {
1011                 unset($this->stepsadded[$key]);
1012             }
1013         }
1015         $this->attemptstodeletestepsfor[$qaid] = 1;
1016     }
1018     public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
1019         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1020             return;
1021         }
1022         $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1023     }
1025     /**
1026      * Write all the changes we have recorded to the database.
1027      * @param question_engine_data_mapper $dm the mapper to use to update the database.
1028      */
1029     public function save(question_engine_data_mapper $dm) {
1030         $dm->delete_steps_for_question_attempts(array_keys($this->attemptstodeletestepsfor),
1031                 $this->quba->get_owning_context());
1033         foreach ($this->stepsadded as $stepinfo) {
1034             list($step, $questionattemptid, $seq) = $stepinfo;
1035             $dm->insert_question_attempt_step($step, $questionattemptid, $seq,
1036                     $this->quba->get_owning_context());
1037         }
1039         foreach ($this->attemptsadded as $qa) {
1040             $dm->insert_question_attempt($qa, $this->quba->get_owning_context());
1041         }
1043         foreach ($this->attemptsmodified as $qa) {
1044             $dm->update_question_attempt($qa);
1045         }
1047         if ($this->modified) {
1048             $dm->update_questions_usage_by_activity($this->quba);
1049         }
1050     }
1054 /**
1055  * This class represents the promise to save some files from a particular draft
1056  * file area into a particular file area. It is used beause the necessary
1057  * information about what to save is to hand in the
1058  * {@link question_attempt::process_response_files()} method, but we don't know
1059  * if this question attempt will actually be saved in the database until later,
1060  * when the {@link question_engine_unit_of_work} is saved, if it is.
1061  *
1062  * @copyright  2011 The Open University
1063  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1064  */
1065 class question_file_saver {
1066     /** @var int the id of the draft file area to save files from. */
1067     protected $draftitemid;
1068     /** @var string the owning component name. */
1069     protected $component;
1070     /** @var string the file area name. */
1071     protected $filearea;
1073     /**
1074      * @var string the value to store in the question_attempt_step_data to
1075      * represent these files.
1076      */
1077     protected $value = null;
1079     /**
1080      * Constuctor.
1081      * @param int $draftitemid the draft area to save the files from.
1082      * @param string $component the component for the file area to save into.
1083      * @param string $filearea the name of the file area to save into.
1084      */
1085     public function __construct($draftitemid, $component, $filearea, $text = null) {
1086         $this->draftitemid = $draftitemid;
1087         $this->component = $component;
1088         $this->filearea = $filearea;
1089         $this->value = $this->compute_value($draftitemid, $text);
1090     }
1092     /**
1093      * Compute the value that should be stored in the question_attempt_step_data
1094      * table. Contains a hash that (almost) uniquely encodes all the files.
1095      * @param int $draftitemid the draft file area itemid.
1096      * @param string $text optional content containing file links.
1097      */
1098     protected function compute_value($draftitemid, $text) {
1099         global $USER;
1101         $fs = get_file_storage();
1102         $usercontext = get_context_instance(CONTEXT_USER, $USER->id);
1104         $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
1105                 $draftitemid, 'sortorder, filepath, filename', false);
1107         $string = '';
1108         foreach ($files as $file) {
1109             $string .= $file->get_filepath() . $file->get_filename() . '|' .
1110                     $file->get_contenthash() . '|';
1111         }
1113         if ($string) {
1114             $hash = md5($string);
1115         } else {
1116             $hash = '';
1117         }
1119         if (is_null($text)) {
1120             return $hash;
1121         }
1123         // We add the file hash so a simple string comparison will say if the
1124         // files have been changed. First strip off any existing file hash.
1125         $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1126         $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1127         if ($hash) {
1128             $text .= '<!-- File hash: ' . $hash . ' -->';
1129         }
1130         return $text;
1131     }
1133     public function __toString() {
1134         return $this->value;
1135     }
1137     /**
1138      * Actually save the files.
1139      * @param integer $itemid the item id for the file area to save into.
1140      */
1141     public function save_files($itemid, $context) {
1142         file_save_draft_area_files($this->draftitemid, $context->id,
1143                 $this->component, $this->filearea, $itemid);
1144     }
1148 /**
1149  * This class represents a restriction on the set of question_usage ids to include
1150  * in a larger database query. Depending of the how you are going to restrict the
1151  * list of usages, construct an appropriate subclass.
1152  *
1153  * If $qubaids is an instance of this class, example usage might be
1154  *
1155  * SELECT qa.id, qa.maxmark
1156  * FROM $qubaids->from_question_attempts('qa')
1157  * WHERE $qubaids->where() AND qa.slot = 1
1158  *
1159  * @copyright  2010 The Open University
1160  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1161  */
1162 abstract class qubaid_condition {
1164     /**
1165      * @return string the SQL that needs to go in the FROM clause when trying
1166      * to select records from the 'question_attempts' table based on the
1167      * qubaid_condition.
1168      */
1169     public abstract function from_question_attempts($alias);
1171     /** @return string the SQL that needs to go in the where clause. */
1172     public abstract function where();
1174     /**
1175      * @return the params needed by a query that uses
1176      * {@link from_question_attempts()} and {@link where()}.
1177      */
1178     public abstract function from_where_params();
1180     /**
1181      * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1182      * This method returns the "IN (...)" part.
1183      */
1184     public abstract function usage_id_in();
1186     /**
1187      * @return the params needed by a query that uses {@link usage_id_in()}.
1188      */
1189     public abstract function usage_id_in_params();
1193 /**
1194  * This class represents a restriction on the set of question_usage ids to include
1195  * in a larger database query based on an explicit list of ids.
1196  *
1197  * @copyright  2010 The Open University
1198  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1199  */
1200 class qubaid_list extends qubaid_condition {
1201     /** @var array of ids. */
1202     protected $qubaids;
1203     protected $columntotest = null;
1204     protected $params;
1206     /**
1207      * Constructor.
1208      * @param array $qubaids of question usage ids.
1209      */
1210     public function __construct(array $qubaids) {
1211         $this->qubaids = $qubaids;
1212     }
1214     public function from_question_attempts($alias) {
1215         $this->columntotest = $alias . '.questionusageid';
1216         return '{question_attempts} ' . $alias;
1217     }
1219     public function where() {
1220         global $DB;
1222         if (is_null($this->columntotest)) {
1223             throw new coding_exception('Must call from_question_attempts before where().');
1224         }
1225         if (empty($this->qubaids)) {
1226             $this->params = array();
1227             return '1 = 0';
1228         }
1230         return $this->columntotest . ' ' . $this->usage_id_in();
1231     }
1233     public function from_where_params() {
1234         return $this->params;
1235     }
1237     public function usage_id_in() {
1238         global $DB;
1240         if (empty($this->qubaids)) {
1241             $this->params = array();
1242             return '= 0';
1243         }
1244         list($where, $this->params) = $DB->get_in_or_equal(
1245                 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
1246         return $where;
1247     }
1249     public function usage_id_in_params() {
1250         return $this->params;
1251     }
1255 /**
1256  * This class represents a restriction on the set of question_usage ids to include
1257  * in a larger database query based on JOINing to some other tables.
1258  *
1259  * The general form of the query is something like
1260  *
1261  * SELECT qa.id, qa.maxmark
1262  * FROM $from
1263  * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1264  * WHERE $where AND qa.slot = 1
1265  *
1266  * where $from, $usageidcolumn and $where are the arguments to the constructor.
1267  *
1268  * @copyright  2010 The Open University
1269  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1270  */
1271 class qubaid_join extends qubaid_condition {
1272     public $from;
1273     public $usageidcolumn;
1274     public $where;
1275     public $params;
1277     /**
1278      * Constructor. The meaning of the arguments is explained in the class comment.
1279      * @param string $from SQL fragemnt to go in the FROM clause.
1280      * @param string $usageidcolumn the column in $from that should be
1281      * made equal to the usageid column in the JOIN clause.
1282      * @param string $where SQL fragment to go in the where clause.
1283      * @param array $params required by the SQL. You must use named parameters.
1284      */
1285     public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1286         $this->from = $from;
1287         $this->usageidcolumn = $usageidcolumn;
1288         $this->params = $params;
1289         if (empty($where)) {
1290             $where = '1 = 1';
1291         }
1292         $this->where = $where;
1293     }
1295     public function from_question_attempts($alias) {
1296         return "$this->from
1297                 JOIN {question_attempts} {$alias} ON " .
1298                         "{$alias}.questionusageid = $this->usageidcolumn";
1299     }
1301     public function where() {
1302         return $this->where;
1303     }
1305     public function from_where_params() {
1306         return $this->params;
1307     }
1309     public function usage_id_in() {
1310         return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
1311     }
1313     public function usage_id_in_params() {
1314         return $this->params;
1315     }