MDL-29815 question engine DB: bad group-by clause detected by Oracle.
[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 = '' . $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                 question_state::$gaveup,
511                 question_state::$gradedwrong,
512                 question_state::$gradedpartial,
513                 question_state::$gradedright,
514                 question_state::$mangaveup,
515                 question_state::$mangrwrong,
516                 question_state::$mangrpartial,
517                 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($states, SQL_PARAMS_NAMED, $prefix, $equal);
824     }
826     /**
827      * Change the maxmark for the question_attempt with number in usage $slot
828      * for all the specified question_attempts.
829      * @param qubaid_condition $qubaids Selects which usages are updated.
830      * @param int $slot the number is usage to affect.
831      * @param number $newmaxmark the new max mark to set.
832      */
833     public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
834         $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
835                 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
836                 $qubaids->usage_id_in_params() + array('slot' => $slot));
837     }
839     /**
840      * Return a subquery that computes the sum of the marks for all the questions
841      * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
842      * parameter.
843      *
844      * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
845      * this method.
846      *
847      * @param string $qubaid SQL fragment that controls which usage is summed.
848      * This will normally be the name of a column in the outer query. Not that this
849      * SQL fragment must not contain any placeholders.
850      * @return string SQL code for the subquery.
851      */
852     public function sum_usage_marks_subquery($qubaid) {
853         return "SELECT SUM(qa.maxmark * qas.fraction)
854             FROM {question_attempts} qa
855             JOIN {question_attempt_steps} qas ON qas.id = (
856                 SELECT MAX(summarks_qas.id)
857                   FROM {question_attempt_steps} summarks_qas
858                  WHERE summarks_qas.questionattemptid = qa.id
859             )
860             WHERE qa.questionusageid = $qubaid
861             HAVING COUNT(CASE
862                 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
863                 ELSE NULL
864             END) = 0";
865     }
867     public function question_attempt_latest_state_view($alias) {
868         return "(
869                 SELECT
870                     {$alias}qa.id AS questionattemptid,
871                     {$alias}qa.questionusageid,
872                     {$alias}qa.slot,
873                     {$alias}qa.behaviour,
874                     {$alias}qa.questionid,
875                     {$alias}qa.variant,
876                     {$alias}qa.maxmark,
877                     {$alias}qa.minfraction,
878                     {$alias}qa.flagged,
879                     {$alias}qa.questionsummary,
880                     {$alias}qa.rightanswer,
881                     {$alias}qa.responsesummary,
882                     {$alias}qa.timemodified,
883                     {$alias}qas.id AS attemptstepid,
884                     {$alias}qas.sequencenumber,
885                     {$alias}qas.state,
886                     {$alias}qas.fraction,
887                     {$alias}qas.timecreated,
888                     {$alias}qas.userid
890                 FROM {question_attempts} {$alias}qa
891                 JOIN {question_attempt_steps} {$alias}qas ON
892                         {$alias}qas.id = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
893             ) $alias";
894     }
896     protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
897         return "(
898                 SELECT MAX(id)
899                 FROM {question_attempt_steps}
900                 WHERE questionattemptid = $questionattemptid
901             )";
902     }
904     /**
905      * @param array $questionids of question ids.
906      * @param qubaid_condition $qubaids ids of the usages to consider.
907      * @return boolean whether any of these questions are being used by any of
908      *      those usages.
909      */
910     public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
911         list($test, $params) = $this->db->get_in_or_equal($questionids);
912         return $this->db->record_exists_select('question_attempts',
913                 'questionid ' . $test . ' AND questionusageid ' .
914                 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
915     }
919 /**
920  * Implementation of the unit of work pattern for the question engine.
921  *
922  * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
923  * changes to a {@link question_usage_by_activity}, and its constituent parts,
924  * so that the changes can be saved to the database when {@link save()} is called.
925  *
926  * @copyright  2009 The Open University
927  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
928  */
929 class question_engine_unit_of_work implements question_usage_observer {
930     /** @var question_usage_by_activity the usage being tracked. */
931     protected $quba;
933     /** @var boolean whether any of the fields of the usage have been changed. */
934     protected $modified = false;
936     /**
937      * @var array list of number in usage => {@link question_attempt}s that
938      * were already in the usage, and which have been modified.
939      */
940     protected $attemptsmodified = array();
942     /**
943      * @var array list of number in usage => {@link question_attempt}s that
944      * have been added to the usage.
945      */
946     protected $attemptsadded = array();
948     /**
949      * @var array list of question attempt ids to delete the steps for, before
950      * inserting new steps.
951      */
952     protected $attemptstodeletestepsfor = array();
954     /**
955      * @var array list of array(question_attempt_step, question_attempt id, seq number)
956      * of steps that have been added to question attempts in this usage.
957      */
958     protected $stepsadded = array();
960     /**
961      * Constructor.
962      * @param question_usage_by_activity $quba the usage to track.
963      */
964     public function __construct(question_usage_by_activity $quba) {
965         $this->quba = $quba;
966     }
968     public function notify_modified() {
969         $this->modified = true;
970     }
972     public function notify_attempt_modified(question_attempt $qa) {
973         $no = $qa->get_slot();
974         if (!array_key_exists($no, $this->attemptsadded)) {
975             $this->attemptsmodified[$no] = $qa;
976         }
977     }
979     public function notify_attempt_added(question_attempt $qa) {
980         $this->attemptsadded[$qa->get_slot()] = $qa;
981     }
983     public function notify_delete_attempt_steps(question_attempt $qa) {
985         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
986             return;
987         }
989         $qaid = $qa->get_database_id();
990         foreach ($this->stepsadded as $key => $stepinfo) {
991             if ($stepinfo[1] == $qaid) {
992                 unset($this->stepsadded[$key]);
993             }
994         }
996         $this->attemptstodeletestepsfor[$qaid] = 1;
997     }
999     public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
1000         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1001             return;
1002         }
1003         $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1004     }
1006     /**
1007      * Write all the changes we have recorded to the database.
1008      * @param question_engine_data_mapper $dm the mapper to use to update the database.
1009      */
1010     public function save(question_engine_data_mapper $dm) {
1011         $dm->delete_steps_for_question_attempts(array_keys($this->attemptstodeletestepsfor),
1012                 $this->quba->get_owning_context());
1014         foreach ($this->stepsadded as $stepinfo) {
1015             list($step, $questionattemptid, $seq) = $stepinfo;
1016             $dm->insert_question_attempt_step($step, $questionattemptid, $seq,
1017                     $this->quba->get_owning_context());
1018         }
1020         foreach ($this->attemptsadded as $qa) {
1021             $dm->insert_question_attempt($qa, $this->quba->get_owning_context());
1022         }
1024         foreach ($this->attemptsmodified as $qa) {
1025             $dm->update_question_attempt($qa);
1026         }
1028         if ($this->modified) {
1029             $dm->update_questions_usage_by_activity($this->quba);
1030         }
1031     }
1035 /**
1036  * This class represents the promise to save some files from a particular draft
1037  * file area into a particular file area. It is used beause the necessary
1038  * information about what to save is to hand in the
1039  * {@link question_attempt::process_response_files()} method, but we don't know
1040  * if this question attempt will actually be saved in the database until later,
1041  * when the {@link question_engine_unit_of_work} is saved, if it is.
1042  *
1043  * @copyright  2011 The Open University
1044  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1045  */
1046 class question_file_saver {
1047     /** @var int the id of the draft file area to save files from. */
1048     protected $draftitemid;
1049     /** @var string the owning component name. */
1050     protected $component;
1051     /** @var string the file area name. */
1052     protected $filearea;
1054     /**
1055      * @var string the value to store in the question_attempt_step_data to
1056      * represent these files.
1057      */
1058     protected $value = null;
1060     /**
1061      * Constuctor.
1062      * @param int $draftitemid the draft area to save the files from.
1063      * @param string $component the component for the file area to save into.
1064      * @param string $filearea the name of the file area to save into.
1065      */
1066     public function __construct($draftitemid, $component, $filearea, $text = null) {
1067         $this->draftitemid = $draftitemid;
1068         $this->component = $component;
1069         $this->filearea = $filearea;
1070         $this->value = $this->compute_value($draftitemid, $text);
1071     }
1073     /**
1074      * Compute the value that should be stored in the question_attempt_step_data
1075      * table. Contains a hash that (almost) uniquely encodes all the files.
1076      * @param int $draftitemid the draft file area itemid.
1077      * @param string $text optional content containing file links.
1078      */
1079     protected function compute_value($draftitemid, $text) {
1080         global $USER;
1082         $fs = get_file_storage();
1083         $usercontext = get_context_instance(CONTEXT_USER, $USER->id);
1085         $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
1086                 $draftitemid, 'sortorder, filepath, filename', false);
1088         $string = '';
1089         foreach ($files as $file) {
1090             $string .= $file->get_filepath() . $file->get_filename() . '|' .
1091                     $file->get_contenthash() . '|';
1092         }
1094         if ($string) {
1095             $hash = md5($string);
1096         } else {
1097             $hash = '';
1098         }
1100         if (is_null($text)) {
1101             return $hash;
1102         }
1104         // We add the file hash so a simple string comparison will say if the
1105         // files have been changed. First strip off any existing file hash.
1106         $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1107         $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1108         if ($hash) {
1109             $text .= '<!-- File hash: ' . $hash . ' -->';
1110         }
1111         return $text;
1112     }
1114     public function __toString() {
1115         return $this->value;
1116     }
1118     /**
1119      * Actually save the files.
1120      * @param integer $itemid the item id for the file area to save into.
1121      */
1122     public function save_files($itemid, $context) {
1123         file_save_draft_area_files($this->draftitemid, $context->id,
1124                 $this->component, $this->filearea, $itemid);
1125     }
1129 /**
1130  * This class represents a restriction on the set of question_usage ids to include
1131  * in a larger database query. Depending of the how you are going to restrict the
1132  * list of usages, construct an appropriate subclass.
1133  *
1134  * If $qubaids is an instance of this class, example usage might be
1135  *
1136  * SELECT qa.id, qa.maxmark
1137  * FROM $qubaids->from_question_attempts('qa')
1138  * WHERE $qubaids->where() AND qa.slot = 1
1139  *
1140  * @copyright  2010 The Open University
1141  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1142  */
1143 abstract class qubaid_condition {
1145     /**
1146      * @return string the SQL that needs to go in the FROM clause when trying
1147      * to select records from the 'question_attempts' table based on the
1148      * qubaid_condition.
1149      */
1150     public abstract function from_question_attempts($alias);
1152     /** @return string the SQL that needs to go in the where clause. */
1153     public abstract function where();
1155     /**
1156      * @return the params needed by a query that uses
1157      * {@link from_question_attempts()} and {@link where()}.
1158      */
1159     public abstract function from_where_params();
1161     /**
1162      * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1163      * This method returns the "IN (...)" part.
1164      */
1165     public abstract function usage_id_in();
1167     /**
1168      * @return the params needed by a query that uses {@link usage_id_in()}.
1169      */
1170     public abstract function usage_id_in_params();
1174 /**
1175  * This class represents a restriction on the set of question_usage ids to include
1176  * in a larger database query based on an explicit list of ids.
1177  *
1178  * @copyright  2010 The Open University
1179  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1180  */
1181 class qubaid_list extends qubaid_condition {
1182     /** @var array of ids. */
1183     protected $qubaids;
1184     protected $columntotest = null;
1185     protected $params;
1187     /**
1188      * Constructor.
1189      * @param array $qubaids of question usage ids.
1190      */
1191     public function __construct(array $qubaids) {
1192         $this->qubaids = $qubaids;
1193     }
1195     public function from_question_attempts($alias) {
1196         $this->columntotest = $alias . '.questionusageid';
1197         return '{question_attempts} ' . $alias;
1198     }
1200     public function where() {
1201         global $DB;
1203         if (is_null($this->columntotest)) {
1204             throw new coding_exception('Must call from_question_attempts before where().');
1205         }
1206         if (empty($this->qubaids)) {
1207             $this->params = array();
1208             return '1 = 0';
1209         }
1211         return $this->columntotest . ' ' . $this->usage_id_in();
1212     }
1214     public function from_where_params() {
1215         return $this->params;
1216     }
1218     public function usage_id_in() {
1219         global $DB;
1221         if (empty($this->qubaids)) {
1222             $this->params = array();
1223             return '= 0';
1224         }
1225         list($where, $this->params) = $DB->get_in_or_equal(
1226                 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
1227         return $where;
1228     }
1230     public function usage_id_in_params() {
1231         return $this->params;
1232     }
1236 /**
1237  * This class represents a restriction on the set of question_usage ids to include
1238  * in a larger database query based on JOINing to some other tables.
1239  *
1240  * The general form of the query is something like
1241  *
1242  * SELECT qa.id, qa.maxmark
1243  * FROM $from
1244  * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1245  * WHERE $where AND qa.slot = 1
1246  *
1247  * where $from, $usageidcolumn and $where are the arguments to the constructor.
1248  *
1249  * @copyright  2010 The Open University
1250  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1251  */
1252 class qubaid_join extends qubaid_condition {
1253     public $from;
1254     public $usageidcolumn;
1255     public $where;
1256     public $params;
1258     /**
1259      * Constructor. The meaning of the arguments is explained in the class comment.
1260      * @param string $from SQL fragemnt to go in the FROM clause.
1261      * @param string $usageidcolumn the column in $from that should be
1262      * made equal to the usageid column in the JOIN clause.
1263      * @param string $where SQL fragment to go in the where clause.
1264      * @param array $params required by the SQL. You must use named parameters.
1265      */
1266     public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1267         $this->from = $from;
1268         $this->usageidcolumn = $usageidcolumn;
1269         $this->params = $params;
1270         if (empty($where)) {
1271             $where = '1 = 1';
1272         }
1273         $this->where = $where;
1274     }
1276     public function from_question_attempts($alias) {
1277         return "$this->from
1278                 JOIN {question_attempts} {$alias} ON " .
1279                         "{$alias}.questionusageid = $this->usageidcolumn";
1280     }
1282     public function where() {
1283         return $this->where;
1284     }
1286     public function from_where_params() {
1287         return $this->params;
1288     }
1290     public function usage_id_in() {
1291         return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
1292     }
1294     public function usage_id_in_params() {
1295         return $this->params;
1296     }