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