Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * Code for loading and saving question attempts to and from the database.
19
 *
20
 * Note that many of the methods of this class should be considered private to
21
 * the question engine. They should be accessed through the
22
 * {@link question_engine} class. For example, you should call
23
 * {@link question_engine::save_questions_usage_by_activity()} rather than
24
 * {@link question_engine_data_mapper::insert_questions_usage_by_activity()}.
25
 * The exception to this is some of the reporting methods, like
26
 * {@link question_engine_data_mapper::load_attempts_at_question()}.
27
 *
28
 * A note for future reference. This code is pretty efficient but there are some
29
 * potential optimisations that could be contemplated, at the cost of making the
30
 * code more complex:
31
 *
32
 * 1. (This is probably not worth doing.) In the unit-of-work save method, we
33
 *    could get all the ids for steps due to be deleted or modified,
34
 *    and delete all the question_attempt_step_data for all of those steps in one
35
 *    query. That would save one DB query for each ->stepsupdated. However that number
36
 *    is 0 except when re-grading, and when regrading, there are many more inserts
37
 *    into question_attempt_step_data than deletes, so it is really hardly worth it.
38
 *
39
 * @package    core_question
40
 * @copyright  2009 The Open University
41
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42
 */
43
 
44
 
45
defined('MOODLE_INTERNAL') || die();
46
 
47
 
48
/**
49
 * This class controls the loading and saving of question engine data to and from
50
 * the database.
51
 *
52
 * @copyright  2009 The Open University
53
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
54
 */
55
class question_engine_data_mapper {
56
    /**
57
     * @var moodle_database normally points to global $DB, but I prefer not to
58
     * use globals if I can help it.
59
     */
60
    protected $db;
61
 
62
    /**
63
     * @param moodle_database $db a database connectoin. Defaults to global $DB.
64
     */
65
    public function __construct(moodle_database $db = null) {
66
        if (is_null($db)) {
67
            global $DB;
68
            $this->db = $DB;
69
        } else {
70
            $this->db = $db;
71
        }
72
    }
73
 
74
    /**
75
     * Store an entire {@link question_usage_by_activity} in the database,
76
     * including all the question_attempts that comprise it.
77
     *
78
     * You should not call this method directly. You should use
79
     * @link question_engine::save_questions_usage_by_activity()}.
80
     *
81
     * @param question_usage_by_activity $quba the usage to store.
82
     */
83
    public function insert_questions_usage_by_activity(question_usage_by_activity $quba) {
84
        $record = new stdClass();
85
        $record->contextid = $quba->get_owning_context()->id;
86
        $record->component = $quba->get_owning_component();
87
        $record->preferredbehaviour = $quba->get_preferred_behaviour();
88
 
89
        $newid = $this->db->insert_record('question_usages', $record);
90
        $quba->set_id_from_database($newid);
91
 
92
        // Initially an array of array of question_attempt_step_objects.
93
        // Built as a nested array for efficiency, then flattened.
94
        $stepdata = array();
95
 
96
        foreach ($quba->get_attempt_iterator() as $qa) {
97
            $stepdata[] = $this->insert_question_attempt($qa, $quba->get_owning_context());
98
        }
99
 
100
        $this->insert_all_step_data($this->combine_step_data($stepdata));
101
 
102
        $quba->set_observer(new question_engine_unit_of_work($quba));
103
    }
104
 
105
    /**
106
     * Store an entire {@link question_attempt} in the database,
107
     * including all the question_attempt_steps that comprise it.
108
     *
109
     * You should not call this method directly. You should use
110
     * @link question_engine::save_questions_usage_by_activity()}.
111
     *
112
     * @param question_attempt $qa the question attempt to store.
113
     * @param context $context the context of the owning question_usage_by_activity.
114
     * @return array of question_attempt_step_data rows, that still need to be inserted.
115
     */
116
    public function insert_question_attempt(question_attempt $qa, $context) {
117
        $record = new stdClass();
118
        $record->questionusageid = $qa->get_usage_id();
119
        $record->slot = $qa->get_slot();
120
        $record->behaviour = $qa->get_behaviour_name();
121
        $record->questionid = $qa->get_question_id();
122
        $record->variant = $qa->get_variant();
123
        $record->maxmark = $qa->get_max_mark();
124
        $record->minfraction = $qa->get_min_fraction();
125
        $record->maxfraction = $qa->get_max_fraction();
126
        $record->flagged = $qa->is_flagged();
127
        $record->questionsummary = $qa->get_question_summary();
128
        if (core_text::strlen($record->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
129
            // It seems some people write very long quesions! MDL-30760
130
            $record->questionsummary = core_text::substr($record->questionsummary,
131
                    0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
132
        }
133
        $record->rightanswer = $qa->get_right_answer_summary();
134
        $record->responsesummary = $qa->get_response_summary();
135
        $record->timemodified = time();
136
        $record->id = $this->db->insert_record('question_attempts', $record);
137
        $qa->set_database_id($record->id);
138
 
139
        // Initially an array of array of question_attempt_step_objects.
140
        // Built as a nested array for efficiency, then flattened.
141
        $stepdata = array();
142
 
143
        foreach ($qa->get_step_iterator() as $seq => $step) {
144
            $stepdata[] = $this->insert_question_attempt_step($step, $record->id, $seq, $context);
145
        }
146
 
147
        return $this->combine_step_data($stepdata);
148
    }
149
 
150
    /**
151
     * Helper method used by insert_question_attempt_step and update_question_attempt_step
152
     *
153
     * @param question_attempt_step $step the step to store.
154
     * @param int $questionattemptid the question attept id this step belongs to.
155
     * @param int $seq the sequence number of this stop.
156
     * @return stdClass data to insert into the database.
157
     */
158
    protected function make_step_record(question_attempt_step $step, $questionattemptid, $seq) {
159
        $record = new stdClass();
160
        $record->questionattemptid = $questionattemptid;
161
        $record->sequencenumber = $seq;
162
        $record->state = $step->get_state()?->__toString();
163
        $record->fraction = $step->get_fraction();
164
        $record->timecreated = $step->get_timecreated();
165
        $record->userid = $step->get_user_id();
166
        return $record;
167
    }
168
 
169
    /**
170
     * Take an array of arrays, and flatten it, even if the outer array is empty.
171
     *
172
     * Only public so it can be called from the unit of work. Not part of the
173
     * public API of this class.
174
     *
175
     * @param array $stepdata array of zero or more arrays.
176
     * @return array made by concatenating all the separate arrays.
177
     */
178
    public function combine_step_data(array $stepdata): array {
179
        if (empty($stepdata)) {
180
            return [];
181
        }
182
        return call_user_func_array('array_merge', $stepdata);
183
    }
184
 
185
    /**
186
     * Helper method used by insert_question_attempt_step and update_question_attempt_step
187
     * @param question_attempt_step $step the step to store.
188
     * @param int $stepid the id of the step.
189
     * @param context $context the context of the owning question_usage_by_activity.
190
     * @return array of question_attempt_step_data rows, that still need to be inserted.
191
     */
192
    protected function prepare_step_data(question_attempt_step $step, $stepid, $context) {
193
        $rows = array();
194
        foreach ($step->get_all_data() as $name => $value) {
195
            if ($value instanceof question_file_saver) {
196
                $value->save_files($stepid, $context);
197
            }
198
            if ($value instanceof question_response_files) {
199
                $value = (string) $value;
200
            }
201
 
202
            $data = new stdClass();
203
            $data->attemptstepid = $stepid;
204
            $data->name = $name;
205
            $data->value = $value;
206
            $rows[] = $data;
207
        }
208
        return $rows;
209
    }
210
 
211
    /**
212
     * Insert a lot of records into question_attempt_step_data in one go.
213
     *
214
     * Private method, only for use by other parts of the question engine.
215
     *
216
     * @param array $rows the rows to insert.
217
     */
218
    public function insert_all_step_data(array $rows) {
219
        if (!$rows) {
220
            return;
221
        }
222
        $this->db->insert_records('question_attempt_step_data', $rows);
223
    }
224
 
225
    /**
226
     * Store a {@link question_attempt_step} in the database.
227
     *
228
     * Private method, only for use by other parts of the question engine.
229
     *
230
     * @param question_attempt_step $step the step to store.
231
     * @param int $questionattemptid the question attept id this step belongs to.
232
     * @param int $seq the sequence number of this stop.
233
     * @param context $context the context of the owning question_usage_by_activity.
234
     * @return array of question_attempt_step_data rows, that still need to be inserted.
235
     */
236
    public function insert_question_attempt_step(question_attempt_step $step,
237
            $questionattemptid, $seq, $context) {
238
 
239
        $record = $this->make_step_record($step, $questionattemptid, $seq);
240
        $record->id = $this->db->insert_record('question_attempt_steps', $record);
241
 
242
        return $this->prepare_step_data($step, $record->id, $context);
243
    }
244
 
245
    /**
246
     * Update a {@link question_attempt_step} in the database.
247
     *
248
     * Private method, only for use by other parts of the question engine.
249
     *
250
     * @param question_attempt_step $step the step to store.
251
     * @param int $questionattemptid the question attept id this step belongs to.
252
     * @param int $seq the sequence number of this stop.
253
     * @param context $context the context of the owning question_usage_by_activity.
254
     * @return array of question_attempt_step_data rows, that still need to be inserted.
255
     */
256
    public function update_question_attempt_step(question_attempt_step $step,
257
            $questionattemptid, $seq, $context) {
258
 
259
        $record = $this->make_step_record($step, $questionattemptid, $seq);
260
        $record->id = $step->get_id();
261
        $this->db->update_record('question_attempt_steps', $record);
262
 
263
        $this->db->delete_records('question_attempt_step_data',
264
                array('attemptstepid' => $record->id));
265
        return $this->prepare_step_data($step, $record->id, $context);
266
    }
267
 
268
    /**
269
     * Store new metadata for an existing {@link question_attempt} in the database.
270
     *
271
     * Private method, only for use by other parts of the question engine.
272
     *
273
     * @param question_attempt $qa the question attempt to store meta data for.
274
     * @param array $names the names of the metadata variables to store.
275
     * @return array of question_attempt_step_data rows, that still need to be inserted.
276
     */
277
    public function insert_question_attempt_metadata(question_attempt $qa, array $names) {
278
        $firststep = $qa->get_step(0);
279
 
280
        $rows = array();
281
        foreach ($names as $name) {
282
            $data = new stdClass();
283
            $data->attemptstepid = $firststep->get_id();
284
            $data->name = ':_' . $name;
285
            $data->value = $firststep->get_metadata_var($name);
286
            $rows[] = $data;
287
        }
288
 
289
        return $rows;
290
    }
291
 
292
    /**
293
     * Updates existing metadata for an existing {@link question_attempt} in the database.
294
     *
295
     * Private method, only for use by other parts of the question engine.
296
     *
297
     * @param question_attempt $qa the question attempt to store meta data for.
298
     * @param array $names the names of the metadata variables to store.
299
     * @return array of question_attempt_step_data rows, that still need to be inserted.
300
     */
301
    public function update_question_attempt_metadata(question_attempt $qa, array $names) {
302
        global $DB;
303
        if (!$names) {
304
            return [];
305
        }
306
        // Use case-sensitive function sql_equal() and not get_in_or_equal().
307
        // Some databases may use case-insensitive collation, we don't want to delete 'X' instead of 'x'.
308
        $sqls = [];
309
        $params = [$qa->get_step(0)->get_id()];
310
        foreach ($names as $name) {
311
            $sqls[] = $DB->sql_equal('name', '?');
312
            $params[] = $name;
313
        }
314
        $DB->delete_records_select('question_attempt_step_data',
315
            'attemptstepid = ? AND (' . join(' OR ', $sqls) . ')', $params);
316
        return $this->insert_question_attempt_metadata($qa, $names);
317
    }
318
 
319
    /**
320
     * Load a {@link question_attempt_step} from the database.
321
     *
322
     * Private method, only for use by other parts of the question engine.
323
     *
324
     * @param int $stepid the id of the step to load.
325
     * @return question_attempt_step the step that was loaded.
326
     */
327
    public function load_question_attempt_step($stepid) {
328
        $records = $this->db->get_recordset_sql("
329
SELECT
330
    quba.contextid,
331
    COALESCE(q.qtype, 'missingtype') AS qtype,
332
    qas.id AS attemptstepid,
333
    qas.questionattemptid,
334
    qas.sequencenumber,
335
    qas.state,
336
    qas.fraction,
337
    qas.timecreated,
338
    qas.userid,
339
    qasd.name,
340
    qasd.value
341
 
342
FROM      {question_attempt_steps}     qas
343
JOIN      {question_attempts}          qa   ON qa.id              = qas.questionattemptid
344
JOIN      {question_usages}            quba ON quba.id            = qa.questionusageid
345
LEFT JOIN {question}                   q    ON q.id               = qa.questionid
346
LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
347
 
348
WHERE
349
    qas.id = :stepid
350
        ", array('stepid' => $stepid));
351
 
352
        if (!$records->valid()) {
353
            throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
354
        }
355
 
356
        $step = question_attempt_step::load_from_records($records, $stepid);
357
        $records->close();
358
 
359
        return $step;
360
    }
361
 
362
    /**
363
     * Load a {@link question_attempt} from the database, including all its
364
     * steps.
365
     *
366
     * Normally, you should use {@link question_engine::load_questions_usage_by_activity()}
367
     * but there may be rare occasions where for performance reasons, you only
368
     * wish to load one qa, in which case you may call this method.
369
     *
370
     * @param int $questionattemptid the id of the question attempt to load.
371
     * @return question_attempt the question attempt that was loaded.
372
     */
373
    public function load_question_attempt($questionattemptid) {
374
        $records = $this->db->get_recordset_sql("
375
SELECT
376
    quba.contextid,
377
    quba.preferredbehaviour,
378
    qa.id AS questionattemptid,
379
    qa.questionusageid,
380
    qa.slot,
381
    qa.behaviour,
382
    qa.questionid,
383
    qa.variant,
384
    qa.maxmark,
385
    qa.minfraction,
386
    qa.maxfraction,
387
    qa.flagged,
388
    qa.questionsummary,
389
    qa.rightanswer,
390
    qa.responsesummary,
391
    qa.timemodified,
392
    qas.id AS attemptstepid,
393
    qas.sequencenumber,
394
    qas.state,
395
    qas.fraction,
396
    qas.timecreated,
397
    qas.userid,
398
    qasd.name,
399
    qasd.value
400
 
401
FROM      {question_attempts}          qa
402
JOIN      {question_usages}            quba ON quba.id               = qa.questionusageid
403
LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
404
LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
405
 
406
WHERE
407
    qa.id = :questionattemptid
408
 
409
ORDER BY
410
    qas.sequencenumber
411
        ", array('questionattemptid' => $questionattemptid));
412
 
413
        if (!$records->valid()) {
414
            throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
415
        }
416
 
417
        $record = $records->current();
418
        $qa = question_attempt::load_from_records($records, $questionattemptid,
419
                new question_usage_null_observer(), $record->preferredbehaviour);
420
        $records->close();
421
 
422
        return $qa;
423
    }
424
 
425
    /**
426
     * Load a {@link question_usage_by_activity} from the database, including
427
     * all its {@link question_attempt}s and all their steps.
428
     *
429
     * You should call {@link question_engine::load_questions_usage_by_activity()}
430
     * rather than calling this method directly.
431
     *
432
     * @param int $qubaid the id of the usage to load.
433
     * @return question_usage_by_activity the usage that was loaded.
434
     */
435
    public function load_questions_usage_by_activity($qubaid) {
436
        $records = $this->db->get_recordset_sql("
437
SELECT
438
    quba.id AS qubaid,
439
    quba.contextid,
440
    quba.component,
441
    quba.preferredbehaviour,
442
    qa.id AS questionattemptid,
443
    qa.questionusageid,
444
    qa.slot,
445
    qa.behaviour,
446
    qa.questionid,
447
    qa.variant,
448
    qa.maxmark,
449
    qa.minfraction,
450
    qa.maxfraction,
451
    qa.flagged,
452
    qa.questionsummary,
453
    qa.rightanswer,
454
    qa.responsesummary,
455
    qa.timemodified,
456
    qas.id AS attemptstepid,
457
    qas.sequencenumber,
458
    qas.state,
459
    qas.fraction,
460
    qas.timecreated,
461
    qas.userid,
462
    qasd.name,
463
    qasd.value
464
 
465
FROM      {question_usages}            quba
466
LEFT JOIN {question_attempts}          qa   ON qa.questionusageid    = quba.id
467
LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
468
LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
469
 
470
WHERE
471
    quba.id = :qubaid
472
 
473
ORDER BY
474
    qa.slot,
475
    qas.sequencenumber
476
    ", array('qubaid' => $qubaid));
477
 
478
        if (!$records->valid()) {
479
            throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
480
        }
481
 
482
        $quba = question_usage_by_activity::load_from_records($records, $qubaid);
483
        $records->close();
484
 
485
        return $quba;
486
    }
487
 
488
    /**
489
     * Load all {@link question_usage_by_activity} from the database for one qubaid_condition
490
     * Include all its {@link question_attempt}s and all their steps.
491
     *
492
     * This method may be called publicly.
493
     *
494
     * @param qubaid_condition $qubaids the condition that tells us which usages to load.
495
     * @return question_usage_by_activity[] the usages that were loaded.
496
     */
497
    public function load_questions_usages_by_activity($qubaids) {
498
        $records = $this->db->get_recordset_sql("
499
SELECT
500
    quba.id AS qubaid,
501
    quba.contextid,
502
    quba.component,
503
    quba.preferredbehaviour,
504
    qa.id AS questionattemptid,
505
    qa.questionusageid,
506
    qa.slot,
507
    qa.behaviour,
508
    qa.questionid,
509
    qa.variant,
510
    qa.maxmark,
511
    qa.minfraction,
512
    qa.maxfraction,
513
    qa.flagged,
514
    qa.questionsummary,
515
    qa.rightanswer,
516
    qa.responsesummary,
517
    qa.timemodified,
518
    qas.id AS attemptstepid,
519
    qas.sequencenumber,
520
    qas.state,
521
    qas.fraction,
522
    qas.timecreated,
523
    qas.userid,
524
    qasd.name,
525
    qasd.value
526
 
527
FROM      {question_usages}            quba
528
LEFT JOIN {question_attempts}          qa   ON qa.questionusageid    = quba.id
529
LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
530
LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
531
 
532
WHERE
533
    quba.id {$qubaids->usage_id_in()}
534
 
535
ORDER BY
536
    quba.id,
537
    qa.slot,
538
    qas.sequencenumber
539
    ", $qubaids->usage_id_in_params());
540
 
541
        $qubas = array();
542
        while ($records->valid()) {
543
            $record = $records->current();
544
            $qubas[$record->qubaid] = question_usage_by_activity::load_from_records($records, $record->qubaid);
545
        }
546
 
547
        $records->close();
548
 
549
        return $qubas;
550
    }
551
 
552
    /**
553
     * Load information about the latest state of each question from the database.
554
     *
555
     * This method may be called publicly.
556
     *
557
     * @param qubaid_condition $qubaids used to restrict which usages are included
558
     *                                  in the query. See {@link qubaid_condition}.
559
     * @param array|null       $slots   (optional) list of slots for which to return information. Default all slots.
560
     * @param string|null      $fields
561
     * @return array of records. See the SQL in this function to see the fields available.
562
     */
563
    public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots = null, $fields = null) {
564
        if ($slots === []) {
565
            return [];
566
        } else if ($slots !== null) {
567
            [$slottest, $params] = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
568
            $slotwhere = " AND qa.slot {$slottest}";
569
        } else {
570
            $slotwhere = '';
571
            $params = [];
572
        }
573
 
574
        if ($fields === null) {
575
            $fields = "qas.id,
576
    qa.id AS questionattemptid,
577
    qa.questionusageid,
578
    qa.slot,
579
    qa.behaviour,
580
    qa.questionid,
581
    qa.variant,
582
    qa.maxmark,
583
    qa.minfraction,
584
    qa.maxfraction,
585
    qa.flagged,
586
    qa.questionsummary,
587
    qa.rightanswer,
588
    qa.responsesummary,
589
    qa.timemodified,
590
    qas.id AS attemptstepid,
591
    qas.sequencenumber,
592
    qas.state,
593
    qas.fraction,
594
    qas.timecreated,
595
    qas.userid";
596
 
597
        }
598
 
599
        $records = $this->db->get_records_sql("
600
SELECT
601
    {$fields}
602
 
603
FROM {$qubaids->from_question_attempts('qa')}
604
JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
605
        AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
606
 
607
WHERE
608
    {$qubaids->where()}
609
    $slotwhere
610
        ", $params + $qubaids->from_where_params());
611
 
612
        return $records;
613
    }
614
 
615
    /**
616
     * Load summary information about the state of each question in a group of
617
     * attempts. This is used, for example, by the quiz manual grading report,
618
     * to show how many attempts at each question need to be graded.
619
     *
620
     * This method may be called publicly.
621
     *
622
     * @param qubaid_condition $qubaids used to restrict which usages are included
623
     * in the query. See {@link qubaid_condition}.
624
     * @param array|null $slots (optional) list of slots for which to return information. Default all slots.
625
     * @return array The array keys are 'slot,questionid'. The values are objects with
626
     * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
627
     * $manuallygraded and $all.
628
     */
629
    public function load_questions_usages_question_state_summary(qubaid_condition $qubaids, $slots = null) {
630
        if ($slots !== null) {
631
            [$slottest, $params] = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
632
            $slotwhere = " AND qa.slot {$slottest}";
633
        } else {
634
            $slotwhere = '';
635
            $params = [];
636
        }
637
 
638
        $rs = $this->db->get_recordset_sql("
639
SELECT
640
    qa.slot,
641
    qa.questionid,
642
    q.name,
643
    CASE qas.state
644
        {$this->full_states_to_summary_state_sql()}
645
    END AS summarystate,
646
    COUNT(1) AS numattempts
647
 
648
FROM {$qubaids->from_question_attempts('qa')}
649
JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
650
        AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
651
JOIN {question} q ON q.id = qa.questionid
652
 
653
WHERE
654
    {$qubaids->where()}
655
    $slotwhere
656
 
657
GROUP BY
658
    qa.slot,
659
    qa.questionid,
660
    q.name,
661
    q.id,
662
    CASE qas.state
663
        {$this->full_states_to_summary_state_sql()}
664
    END
665
 
666
ORDER BY
667
    qa.slot,
668
    qa.questionid,
669
    q.name,
670
    q.id
671
        ", $params + $qubaids->from_where_params());
672
 
673
        $results = array();
674
        foreach ($rs as $row) {
675
            $index = $row->slot . ',' . $row->questionid;
676
 
677
            if (!array_key_exists($index, $results)) {
678
                $res = new stdClass();
679
                $res->slot = $row->slot;
680
                $res->questionid = $row->questionid;
681
                $res->name = $row->name;
682
                $res->inprogress = 0;
683
                $res->needsgrading = 0;
684
                $res->autograded = 0;
685
                $res->manuallygraded = 0;
686
                $res->all = 0;
687
                $results[$index] = $res;
688
            }
689
 
690
            $results[$index]->{$row->summarystate} = $row->numattempts;
691
            $results[$index]->all += $row->numattempts;
692
        }
693
        $rs->close();
694
 
695
        return $results;
696
    }
697
 
698
    /**
699
     * Get a list of usage ids where the question with slot $slot, and optionally
700
     * also with question id $questionid, is in summary state $summarystate. Also
701
     * return the total count of such states.
702
     *
703
     * Only a subset of the ids can be returned by using $orderby, $limitfrom and
704
     * $limitnum. A special value 'random' can be passed as $orderby, in which case
705
     * $limitfrom is ignored.
706
     *
707
     * This method may be called publicly.
708
     *
709
     * @param qubaid_condition $qubaids used to restrict which usages are included
710
     * in the query. See {@link qubaid_condition}.
711
     * @param int $slot The slot for the questions you want to know about.
712
     * @param int $questionid (optional) Only return attempts that were of this specific question.
713
     * @param string $summarystate the summary state of interest, or 'all'.
714
     * @param string $orderby the column to order by.
715
     * @param array $params any params required by any of the SQL fragments.
716
     * @param int $limitfrom implements paging of the results.
717
     *      Ignored if $orderby = random or $limitnum is null.
718
     * @param int $limitnum implements paging of the results. null = all.
719
     * @param string $extraselect anything passed here will be added to the SELECT list, use this to return extra data.
720
     * @return array with two elements, an array of usage ids, and a count of the total number.
721
     */
722
    public function load_questions_usages_where_question_in_state(
723
            qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
724
            $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null, $extraselect = '') {
725
 
726
        $extrawhere = '';
727
        if ($questionid) {
728
            $extrawhere .= ' AND qa.questionid = :questionid';
729
            $params['questionid'] = $questionid;
730
        }
731
        if ($summarystate !== 'all') {
732
            list($test, $sparams) = $this->in_summary_state_test($summarystate);
733
            $extrawhere .= ' AND qas.state ' . $test;
734
            $params += $sparams;
735
        }
736
 
737
        if (!empty($extraselect)) {
738
            $extraselect = ', ' . $extraselect;
739
        }
740
 
741
        if ($orderby === 'random') {
742
            $sqlorderby = '';
743
        } else if ($orderby) {
744
            $sqlorderby = 'ORDER BY ' . $orderby;
745
        } else {
746
            $sqlorderby = '';
747
        }
748
 
749
        // We always want the total count, as well as the partcular list of ids
750
        // based on the paging and sort order. Because the list of ids is never
751
        // going to be too ridiculously long. My worst-case scenario is
752
        // 10,000 students in the course, each doing 5 quiz attempts. That
753
        // is a 50,000 element int => int array, which PHP seems to use 5MB
754
        // memory to store on a 64 bit server.
755
        $qubaidswhere = $qubaids->where(); // Must call this before params.
756
        $params += $qubaids->from_where_params();
757
        $params['slot'] = $slot;
758
        $sql = "SELECT qa.questionusageid,
759
                       1
760
                       $extraselect
761
                  FROM {$qubaids->from_question_attempts('qa')}
762
                  JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
763
                   AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
764
                  JOIN {question} q ON q.id = qa.questionid
765
                 WHERE {$qubaidswhere}
766
                   AND qa.slot = :slot
767
                       $extrawhere
768
                       $sqlorderby";
769
 
770
        $qubaids = $this->db->get_records_sql_menu($sql, $params);
771
 
772
        $qubaids = array_keys($qubaids);
773
        $count = count($qubaids);
774
 
775
        if ($orderby === 'random') {
776
            shuffle($qubaids);
777
            $limitfrom = 0;
778
        }
779
 
780
        if (!is_null($limitnum)) {
781
            $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
782
        }
783
 
784
        return array($qubaids, $count);
785
    }
786
 
787
    /**
788
     * Load the average mark, and number of attempts, for each slot in a set of
789
     * question usages..
790
     *
791
     * This method may be called publicly.
792
     *
793
     * @param qubaid_condition $qubaids used to restrict which usages are included
794
     * in the query. See {@link qubaid_condition}.
795
     * @param array|null $slots if null, load info for all quesitions, otherwise only
796
     * load the averages for the specified questions.
797
     * @return array of objects with fields ->slot, ->averagefraction and ->numaveraged.
798
     */
799
    public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
800
        if (!empty($slots)) {
801
            list($slottest, $slotsparams) = $this->db->get_in_or_equal(
802
                    $slots, SQL_PARAMS_NAMED, 'slot');
803
            $slotwhere = " AND qa.slot {$slottest}";
804
        } else {
805
            $slotwhere = '';
806
            $slotsparams = array();
807
        }
808
 
809
        list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
810
                (string) question_state::$gaveup,
811
                (string) question_state::$gradedwrong,
812
                (string) question_state::$gradedpartial,
813
                (string) question_state::$gradedright,
814
                (string) question_state::$mangaveup,
815
                (string) question_state::$mangrwrong,
816
                (string) question_state::$mangrpartial,
817
                (string) question_state::$mangrright), SQL_PARAMS_NAMED, 'st');
818
 
819
        return $this->db->get_records_sql("
820
SELECT
821
    qa.slot,
822
    AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
823
    COUNT(1) AS numaveraged
824
 
825
FROM {$qubaids->from_question_attempts('qa')}
826
JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
827
        AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
828
 
829
WHERE
830
    {$qubaids->where()}
831
    $slotwhere
832
    AND qas.state $statetest
833
 
834
GROUP BY qa.slot
835
 
836
ORDER BY qa.slot
837
        ", $slotsparams + $stateparams + $qubaids->from_where_params());
838
    }
839
 
840
    /**
841
     * Load all the attempts at a given queston from a set of question_usages.
842
     * steps.
843
     *
844
     * This method may be called publicly.
845
     *
846
     * @param int $questionid the question to load all the attempts fors.
847
     * @param qubaid_condition $qubaids used to restrict which usages are included
848
     * in the query. See {@link qubaid_condition}.
849
     * @return question_attempt[] array of question_attempts that were loaded.
850
     */
851
    public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
852
        $sql = "
853
SELECT
854
    quba.contextid,
855
    quba.preferredbehaviour,
856
    qa.id AS questionattemptid,
857
    qa.questionusageid,
858
    qa.slot,
859
    qa.behaviour,
860
    qa.questionid,
861
    qa.variant,
862
    qa.maxmark,
863
    qa.minfraction,
864
    qa.maxfraction,
865
    qa.flagged,
866
    qa.questionsummary,
867
    qa.rightanswer,
868
    qa.responsesummary,
869
    qa.timemodified,
870
    qas.id AS attemptstepid,
871
    qas.sequencenumber,
872
    qas.state,
873
    qas.fraction,
874
    qas.timecreated,
875
    qas.userid,
876
    qasd.name,
877
    qasd.value
878
 
879
FROM {$qubaids->from_question_attempts('qa')}
880
JOIN {question_usages} quba ON quba.id = qa.questionusageid
881
LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
882
LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
883
 
884
WHERE
885
    {$qubaids->where()} AND
886
    qa.questionid = :questionid
887
 
888
ORDER BY
889
    quba.id,
890
    qa.id,
891
    qas.sequencenumber";
892
 
893
        // For qubaid_list must call this after calling methods that generate sql.
894
        $params = $qubaids->from_where_params();
895
        $params['questionid'] = $questionid;
896
 
897
        $records = $this->db->get_recordset_sql($sql, $params);
898
 
899
        $questionattempts = array();
900
        while ($records->valid()) {
901
            $record = $records->current();
902
            $questionattempts[$record->questionattemptid] =
903
                    question_attempt::load_from_records($records,
904
                    $record->questionattemptid, new question_usage_null_observer(),
905
                    $record->preferredbehaviour);
906
        }
907
        $records->close();
908
 
909
        return $questionattempts;
910
    }
911
 
912
    /**
913
     * Update a question_usages row to refect any changes in a usage (but not
914
     * any of its question_attempts.
915
     *
916
     * You should not call this method directly. You should use
917
     * @link question_engine::save_questions_usage_by_activity()}.
918
     *
919
     * @param question_usage_by_activity $quba the usage that has changed.
920
     */
921
    public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
922
        $record = new stdClass();
923
        $record->id = $quba->get_id();
924
        $record->contextid = $quba->get_owning_context()->id;
925
        $record->component = $quba->get_owning_component();
926
        $record->preferredbehaviour = $quba->get_preferred_behaviour();
927
 
928
        $this->db->update_record('question_usages', $record);
929
    }
930
 
931
    /**
932
     * Update a question_attempts row to refect any changes in a question_attempt
933
     * (but not any of its steps).
934
     *
935
     * You should not call this method directly. You should use
936
     * @link question_engine::save_questions_usage_by_activity()}.
937
     *
938
     * @param question_attempt $qa the question attempt that has changed.
939
     */
940
    public function update_question_attempt(question_attempt $qa) {
941
        $record = new stdClass();
942
        $record->id = $qa->get_database_id();
943
        $record->slot = $qa->get_slot();
944
        $record->questionid = $qa->get_question(false)->id;
945
        $record->variant = $qa->get_variant();
946
        $record->maxmark = $qa->get_max_mark();
947
        $record->minfraction = $qa->get_min_fraction();
948
        $record->maxfraction = $qa->get_max_fraction();
949
        $record->flagged = $qa->is_flagged();
950
        $record->questionsummary = $qa->get_question_summary();
951
        $record->rightanswer = $qa->get_right_answer_summary();
952
        $record->responsesummary = $qa->get_response_summary();
953
        $record->timemodified = time();
954
 
955
        $this->db->update_record('question_attempts', $record);
956
    }
957
 
958
    /**
959
     * Delete a question_usage_by_activity and all its associated
960
     *
961
     * You should not call this method directly. You should use
962
     * @link question_engine::delete_questions_usage_by_activities()}.
963
     *
964
     * {@link question_attempts} and {@link question_attempt_steps} from the
965
     * database.
966
     * @param qubaid_condition $qubaids identifies which question useages to delete.
967
     */
968
    public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
969
        $where = "qa.questionusageid {$qubaids->usage_id_in()}";
970
        $params = $qubaids->usage_id_in_params();
971
 
972
        $contextids = $this->db->get_records_sql_menu("
973
                SELECT DISTINCT contextid, 1
974
                FROM {question_usages}
975
                WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
976
        foreach ($contextids as $contextid => $notused) {
977
            $this->delete_response_files($contextid, "IN (
978
                    SELECT qas.id
979
                    FROM {question_attempts} qa
980
                    JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
981
                    WHERE $where)", $params);
982
        }
983
 
984
        if ($this->db->get_dbfamily() == 'mysql') {
985
            $this->delete_usage_records_for_mysql($qubaids);
986
            return;
987
        }
988
 
989
        $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
990
                SELECT qas.id
991
                FROM {question_attempts} qa
992
                JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
993
                WHERE $where)", $params);
994
 
995
        $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
996
                SELECT qa.id
997
                FROM {question_attempts} qa
998
                WHERE $where)", $params);
999
 
1000
        $this->db->delete_records_select('question_attempts',
1001
                "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
1002
                $qubaids->usage_id_in_params());
1003
 
1004
        $this->db->delete_records_select('question_usages',
1005
                "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
1006
    }
1007
 
1008
    /**
1009
     * This function is a work-around for poor MySQL performance with
1010
     * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
1011
     * syntax to get good performance. See MDL-29520.
1012
     * @param qubaid_condition $qubaids identifies which question useages to delete.
1013
     */
1014
    protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) {
1015
        // Get the list of question attempts to delete and delete them in chunks.
1016
        $allids = $this->db->get_records_sql_menu("
1017
                SELECT DISTINCT id, id AS id2
1018
                  FROM {question_usages}
1019
                 WHERE id " . $qubaids->usage_id_in(),
1020
                $qubaids->usage_id_in_params());
1021
 
1022
        foreach (array_chunk($allids, 1000) as $todelete) {
1023
            list($idsql, $idparams) = $this->db->get_in_or_equal($todelete);
1024
            $this->db->execute('
1025
                    DELETE qu, qa, qas, qasd
1026
                      FROM {question_usages}            qu
1027
                      JOIN {question_attempts}          qa   ON qa.questionusageid = qu.id
1028
                 LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
1029
                 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
1030
                     WHERE qu.id ' . $idsql,
1031
                    $idparams);
1032
        }
1033
    }
1034
 
1035
    /**
1036
     * Delete some steps of a question attempt.
1037
     *
1038
     * Private method, only for use by other parts of the question engine.
1039
     *
1040
     * @param array $stepids array of step ids to delete.
1041
     * @param context $context the context that the $quba belongs to.
1042
     */
1043
    public function delete_steps($stepids, $context) {
1044
        if (empty($stepids)) {
1045
            return;
1046
        }
1047
        list($test, $params) = $this->db->get_in_or_equal($stepids, SQL_PARAMS_NAMED);
1048
 
1049
        $this->delete_response_files($context->id, $test, $params);
1050
 
1051
        $this->db->delete_records_select('question_attempt_step_data',
1052
                "attemptstepid {$test}", $params);
1053
        $this->db->delete_records_select('question_attempt_steps',
1054
                "id {$test}", $params);
1055
    }
1056
 
1057
    /**
1058
     * Delete all the files belonging to the response variables in the gives
1059
     * question attempt steps.
1060
     * @param int $contextid the context these attempts belong to.
1061
     * @param string $itemidstest a bit of SQL that can be used in a
1062
     *      WHERE itemid $itemidstest clause. Must use named params.
1063
     * @param array $params any query parameters used in $itemidstest.
1064
     */
1065
    protected function delete_response_files($contextid, $itemidstest, $params) {
1066
        $fs = get_file_storage();
1067
        foreach (question_engine::get_all_response_file_areas() as $filearea) {
1068
            $fs->delete_area_files_select($contextid, 'question', $filearea,
1069
                    $itemidstest, $params);
1070
        }
1071
    }
1072
 
1073
    /**
1074
     * Delete all the previews for a given question.
1075
     *
1076
     * Private method, only for use by other parts of the question engine.
1077
     *
1078
     * @param int $questionid question id.
1079
     */
1080
    public function delete_previews($questionid) {
1081
        $previews = $this->db->get_records_sql_menu("
1082
                SELECT DISTINCT quba.id, 1
1083
                FROM {question_usages} quba
1084
                JOIN {question_attempts} qa ON qa.questionusageid = quba.id
1085
                WHERE quba.component = 'core_question_preview' AND
1086
                    qa.questionid = ?", array($questionid));
1087
        if (empty($previews)) {
1088
            return;
1089
        }
1090
        $this->delete_questions_usage_by_activities(new qubaid_list($previews));
1091
    }
1092
 
1093
    /**
1094
     * Update the flagged state of a question in the database.
1095
     *
1096
     * You should call {@link question_engine::update_flag()()}
1097
     * rather than calling this method directly.
1098
     *
1099
     * @param int $qubaid the question usage id.
1100
     * @param int $questionid the question id.
1101
     * @param int $qaid the question_attempt id.
1102
     * @param int $slot the slot number of the question attempt to update.
1103
     * @param bool $newstate the new state of the flag. true = flagged.
1104
     */
1105
    public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
1106
        if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
1107
                'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
1108
            throw new moodle_exception('errorsavingflags', 'question');
1109
        }
1110
 
1111
        $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
1112
    }
1113
 
1114
    /**
1115
     * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
1116
     * column to a summary state. Use this like
1117
     * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
1118
     *
1119
     * @return string SQL fragment.
1120
     */
1121
    protected function full_states_to_summary_state_sql() {
1122
        $sql = '';
1123
        foreach (question_state::get_all() as $state) {
1124
            $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n";
1125
        }
1126
        return $sql;
1127
    }
1128
 
1129
    /**
1130
     * Get the SQL needed to test that question_attempt_steps.state is in a
1131
     * state corresponding to $summarystate.
1132
     *
1133
     * This method may be called publicly.
1134
     *
1135
     * @param string $summarystate one of
1136
     * inprogress, needsgrading, manuallygraded or autograded
1137
     * @param bool $equal if false, do a NOT IN test. Default true.
1138
     * @param string $prefix used in the call to $DB->get_in_or_equal().
1139
     * @return array as returned by $DB->get_in_or_equal().
1140
     */
1141
    public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
1142
        $states = question_state::get_all_for_summary_state($summarystate);
1143
        return $this->db->get_in_or_equal(array_map('strval', $states),
1144
                SQL_PARAMS_NAMED, $prefix, $equal);
1145
    }
1146
 
1147
    /**
1148
     * Change the maxmark for the question_attempt with number in usage $slot
1149
     * for all the specified question_attempts.
1150
     *
1151
     * You should call {@link question_engine::set_max_mark_in_attempts()}
1152
     * rather than calling this method directly.
1153
     *
1154
     * @param qubaid_condition $qubaids Selects which usages are updated.
1155
     * @param int $slot the number is usage to affect.
1156
     * @param number $newmaxmark the new max mark to set.
1157
     */
1158
    public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
1159
        if ($this->db->get_dbfamily() == 'mysql') {
1160
            // MySQL's query optimiser completely fails to cope with the
1161
            // set_field_select call below, so we have to give it a clue. See MDL-32616.
1162
            // TODO MDL-29589 encapsulate this MySQL-specific code with a $DB method.
1163
            $this->db->execute("
1164
                    UPDATE " . $qubaids->from_question_attempts('qa') . "
1165
                       SET qa.maxmark = :newmaxmark
1166
                     WHERE " . $qubaids->where() . "
1167
                       AND slot = :slot
1168
                    ", $qubaids->from_where_params() + array('newmaxmark' => $newmaxmark, 'slot' => $slot));
1169
            return;
1170
        }
1171
 
1172
        // Normal databases.
1173
        $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
1174
                "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
1175
                $qubaids->usage_id_in_params() + array('slot' => $slot));
1176
    }
1177
 
1178
    /**
1179
     * Return a sub-query that computes the sum of the marks for all the questions
1180
     * in a usage. Which usage to compute the sum for is controlled by the $qubaid
1181
     * parameter.
1182
     *
1183
     * See {@see \mod_quiz\grade_calculator::recompute_all_attempt_sumgrades()} for an example of the usage of
1184
     * this method.
1185
     *
1186
     * This method may be called publicly.
1187
     *
1188
     * @param string $qubaid SQL fragment that controls which usage is summed.
1189
     * This will normally be the name of a column in the outer query. Not that this
1190
     * SQL fragment must not contain any placeholders.
1191
     * @return string SQL code for the subquery.
1192
     */
1193
    public function sum_usage_marks_subquery($qubaid) {
1194
        // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
1195
        // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
1196
        // We always want to return a number, so the COALESCE is there to turn the
1197
        // NULL total into a 0.
1198
        return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
1199
            FROM {question_attempts} qa
1200
            JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1201
                    AND qas.sequencenumber = (
1202
                            SELECT MAX(summarks_qas.sequencenumber)
1203
                              FROM {question_attempt_steps} summarks_qas
1204
                             WHERE summarks_qas.questionattemptid = qa.id
1205
            )
1206
            WHERE qa.questionusageid = $qubaid
1207
            HAVING COUNT(CASE
1208
                WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
1209
                ELSE NULL
1210
            END) = 0";
1211
    }
1212
 
1213
    /**
1214
     * Get a subquery that returns the latest step of every qa in some qubas.
1215
     * Currently, this is only used by the quiz reports. See
1216
     * {@see \mod_quiz\local\reports\attempts_report_table::add_latest_state_join()}.
1217
     *
1218
     * This method may be called publicly.
1219
     *
1220
     * @param string $alias alias to use for this inline-view.
1221
     * @param qubaid_condition $qubaids restriction on which question_usages we
1222
     *      are interested in. This is important for performance.
1223
     * @return array with two elements, the SQL fragment and any params requried.
1224
     */
1225
    public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) {
1226
        return array("(
1227
                SELECT {$alias}qa.id AS questionattemptid,
1228
                       {$alias}qa.questionusageid,
1229
                       {$alias}qa.slot,
1230
                       {$alias}qa.behaviour,
1231
                       {$alias}qa.questionid,
1232
                       {$alias}qa.variant,
1233
                       {$alias}qa.maxmark,
1234
                       {$alias}qa.minfraction,
1235
                       {$alias}qa.maxfraction,
1236
                       {$alias}qa.flagged,
1237
                       {$alias}qa.questionsummary,
1238
                       {$alias}qa.rightanswer,
1239
                       {$alias}qa.responsesummary,
1240
                       {$alias}qa.timemodified,
1241
                       {$alias}qas.id AS attemptstepid,
1242
                       {$alias}qas.sequencenumber,
1243
                       {$alias}qas.state,
1244
                       {$alias}qas.fraction,
1245
                       {$alias}qas.timecreated,
1246
                       {$alias}qas.userid
1247
 
1248
                  FROM {$qubaids->from_question_attempts($alias . 'qa')}
1249
                  JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
1250
                            AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
1251
                 WHERE {$qubaids->where()}
1252
            ) {$alias}", $qubaids->from_where_params());
1253
    }
1254
 
1255
    /**
1256
     * Get the subquery which selects the latest step for each question_attempt.
1257
     *
1258
     * @param string $questionattemptid column alias for the column to join on which is question_attempt.id.
1259
     * @return string SQL fragment to include in the query. Has not placeholders.
1260
     */
1261
    public function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
1262
        return "(
1263
                SELECT MAX(sequencenumber)
1264
                FROM {question_attempt_steps}
1265
                WHERE questionattemptid = $questionattemptid
1266
            )";
1267
    }
1268
 
1269
    /**
1270
     * Are any of these questions are currently in use?
1271
     *
1272
     * You should call {@link question_engine::questions_in_use()}
1273
     * rather than calling this method directly.
1274
     *
1275
     * @param array $questionids of question ids.
1276
     * @param qubaid_condition $qubaids ids of the usages to consider.
1277
     * @return bool whether any of these questions are being used by any of
1278
     *      those usages.
1279
     */
1280
    public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
1281
        list($test, $params) = $this->db->get_in_or_equal($questionids);
1282
        return $this->db->record_exists_select('question_attempts',
1283
                'questionid ' . $test . ' AND questionusageid ' .
1284
                $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
1285
    }
1286
 
1287
    /**
1288
     * Get the number of times each variant has been used for each question in a list
1289
     * in a set of usages.
1290
     * @param array $questionids of question ids.
1291
     * @param qubaid_condition $qubaids ids of the usages to consider.
1292
     * @return array questionid => variant number => num uses.
1293
     */
1294
    public function load_used_variants(array $questionids, qubaid_condition $qubaids) {
1295
        list($test, $params) = $this->db->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'qid');
1296
        $recordset = $this->db->get_recordset_sql("
1297
                SELECT qa.questionid, qa.variant, COUNT(1) AS usescount
1298
                  FROM " . $qubaids->from_question_attempts('qa') . "
1299
                 WHERE qa.questionid $test
1300
                   AND " . $qubaids->where() . "
1301
              GROUP BY qa.questionid, qa.variant
1302
              ORDER BY COUNT(1) ASC
1303
                ", $params + $qubaids->from_where_params());
1304
 
1305
        $usedvariants = array_combine($questionids, array_fill(0, count($questionids), array()));
1306
        foreach ($recordset as $row) {
1307
            $usedvariants[$row->questionid][$row->variant] = $row->usescount;
1308
        }
1309
        $recordset->close();
1310
        return $usedvariants;
1311
    }
1312
}
1313
 
1314
 
1315
/**
1316
 * Implementation of the unit of work pattern for the question engine.
1317
 *
1318
 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1319
 * changes to a {@link question_usage_by_activity}, and its constituent parts,
1320
 * so that the changes can be saved to the database when {@link save()} is called.
1321
 *
1322
 * @copyright  2009 The Open University
1323
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1324
 */
1325
class question_engine_unit_of_work implements question_usage_observer {
1326
    /** @var question_usage_by_activity the usage being tracked. */
1327
    protected $quba;
1328
 
1329
    /** @var boolean whether any of the fields of the usage have been changed. */
1330
    protected $modified = false;
1331
 
1332
    /**
1333
     * @var question_attempt[] list of slot => {@link question_attempt}s that
1334
     * have been added to the usage.
1335
     */
1336
    protected $attemptsadded = array();
1337
 
1338
    /**
1339
     * @var question_attempt[] list of slot => {@link question_attempt}s that
1340
     * were already in the usage, and which have been modified.
1341
     */
1342
    protected $attemptsmodified = array();
1343
 
1344
    /**
1345
     * @var question_attempt[] list of slot => {@link question_attempt}s that
1346
     * have been added to the usage.
1347
     */
1348
    protected $attemptsdeleted = array();
1349
 
1350
    /**
1351
     * @var array of array(question_attempt_step, question_attempt id, seq number)
1352
     * of steps that have been added to question attempts in this usage.
1353
     */
1354
    protected $stepsadded = array();
1355
 
1356
    /**
1357
     * @var array of array(question_attempt_step, question_attempt id, seq number)
1358
     * of steps that have been modified in their attempt.
1359
     */
1360
    protected $stepsmodified = array();
1361
 
1362
    /**
1363
     * @var question_attempt_step[] list of question_attempt_step.id => question_attempt_step of steps
1364
     * that were previously stored in the database, but which are no longer required.
1365
     */
1366
    protected $stepsdeleted = array();
1367
 
1368
    /**
1369
     * @var array int slot => string name => question_attempt.
1370
     */
1371
    protected $metadataadded = array();
1372
 
1373
    /**
1374
     * @var array int slot => string name => question_attempt.
1375
     */
1376
    protected $metadatamodified = array();
1377
 
1378
    /**
1379
     * Constructor.
1380
     * @param question_usage_by_activity $quba the usage to track.
1381
     */
1382
    public function __construct(question_usage_by_activity $quba) {
1383
        $this->quba = $quba;
1384
    }
1385
 
1386
    public function notify_modified() {
1387
        $this->modified = true;
1388
    }
1389
 
1390
    public function notify_attempt_added(question_attempt $qa) {
1391
        $this->attemptsadded[$qa->get_slot()] = $qa;
1392
    }
1393
 
1394
    public function notify_attempt_modified(question_attempt $qa) {
1395
        $slot = $qa->get_slot();
1396
        if (!array_key_exists($slot, $this->attemptsadded)) {
1397
            $this->attemptsmodified[$slot] = $qa;
1398
        }
1399
    }
1400
 
1401
    public function notify_attempt_moved(question_attempt $qa, $oldslot) {
1402
        $newslot = $qa->get_slot();
1403
 
1404
        if (array_key_exists($oldslot, $this->attemptsadded)) {
1405
            unset($this->attemptsadded[$oldslot]);
1406
            $this->attemptsadded[$newslot] = $qa;
1407
            return;
1408
        }
1409
 
1410
        if (array_key_exists($oldslot, $this->attemptsmodified)) {
1411
            unset($this->attemptsmodified[$oldslot]);
1412
        }
1413
        $this->attemptsmodified[$newslot] = $qa;
1414
 
1415
        if (array_key_exists($oldslot, $this->metadataadded)) {
1416
            $this->metadataadded[$newslot] = $this->metadataadded[$oldslot];
1417
            unset($this->metadataadded[$oldslot]);
1418
        }
1419
        if (array_key_exists($oldslot, $this->metadatamodified)) {
1420
            $this->metadatamodified[$newslot] = $this->metadatamodified[$oldslot];
1421
            unset($this->metadatamodified[$oldslot]);
1422
        }
1423
    }
1424
 
1425
    public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
1426
        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1427
            return;
1428
        }
1429
 
1430
        if (($key = $this->is_step_added($step)) !== false) {
1431
            return;
1432
        }
1433
 
1434
        if (($key = $this->is_step_modified($step)) !== false) {
1435
            throw new coding_exception('Cannot add a step that has already been modified.');
1436
        }
1437
 
1438
        if (($key = $this->is_step_deleted($step)) !== false) {
1439
            unset($this->stepsdeleted[$step->get_id()]);
1440
            $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1441
            return;
1442
        }
1443
 
1444
        $stepid = $step->get_id();
1445
        if ($stepid) {
1446
            if (array_key_exists($stepid, $this->stepsdeleted)) {
1447
                unset($this->stepsdeleted[$stepid]);
1448
            }
1449
            $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1450
 
1451
        } else {
1452
            $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1453
        }
1454
    }
1455
 
1456
    public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
1457
        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1458
            return;
1459
        }
1460
 
1461
        if (($key = $this->is_step_added($step)) !== false) {
1462
            return;
1463
        }
1464
 
1465
        if (($key = $this->is_step_deleted($step)) !== false) {
1466
            throw new coding_exception('Cannot modify a step after it has been deleted.');
1467
        }
1468
 
1469
        $stepid = $step->get_id();
1470
        if (empty($stepid)) {
1471
            throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1472
        }
1473
 
1474
        $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1475
    }
1476
 
1477
    public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
1478
        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1479
            return;
1480
        }
1481
 
1482
        if (($key = $this->is_step_added($step)) !== false) {
1483
            unset($this->stepsadded[$key]);
1484
            return;
1485
        }
1486
 
1487
        if (($key = $this->is_step_modified($step)) !== false) {
1488
            unset($this->stepsmodified[$key]);
1489
        }
1490
 
1491
        $stepid = $step->get_id();
1492
        if (empty($stepid)) {
1493
            return; // Was never in the database.
1494
        }
1495
 
1496
        $this->stepsdeleted[$stepid] = $step;
1497
    }
1498
 
1499
    public function notify_metadata_added(question_attempt $qa, $name) {
1500
        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1501
            return;
1502
        }
1503
 
1504
        if ($this->is_step_added($qa->get_step(0)) !== false) {
1505
            return;
1506
        }
1507
 
1508
        if (isset($this->metadataadded[$qa->get_slot()][$name])) {
1509
            return;
1510
        }
1511
 
1512
        $this->metadataadded[$qa->get_slot()][$name] = $qa;
1513
    }
1514
 
1515
    public function notify_metadata_modified(question_attempt $qa, $name) {
1516
        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1517
            return;
1518
        }
1519
 
1520
        if ($this->is_step_added($qa->get_step(0)) !== false) {
1521
            return;
1522
        }
1523
 
1524
        if (isset($this->metadataadded[$qa->get_slot()][$name])) {
1525
            return;
1526
        }
1527
 
1528
        if (isset($this->metadatamodified[$qa->get_slot()][$name])) {
1529
            return;
1530
        }
1531
 
1532
        $this->metadatamodified[$qa->get_slot()][$name] = $qa;
1533
    }
1534
 
1535
    /**
1536
     * Determine if a step is new. If so get its array key.
1537
     *
1538
     * @param question_attempt_step $step a step
1539
     * @return int|false if the step is in the list of steps to be added, return
1540
     *      the key, otherwise return false.
1541
     */
1542
    protected function is_step_added(question_attempt_step $step) {
1543
        foreach ($this->stepsadded as $key => $data) {
1544
            list($addedstep) = $data;
1545
            if ($addedstep === $step) {
1546
                return $key;
1547
            }
1548
        }
1549
        return false;
1550
    }
1551
 
1552
    /**
1553
     * Determine if a step is modified. If so get its array key.
1554
     *
1555
     * @param question_attempt_step $step a step
1556
     * @return int|false if the step is in the list of steps to be modified, return
1557
     *      the key, otherwise return false.
1558
     */
1559
    protected function is_step_modified(question_attempt_step $step) {
1560
        foreach ($this->stepsmodified as $key => $data) {
1561
            list($modifiedstep) = $data;
1562
            if ($modifiedstep === $step) {
1563
                return $key;
1564
            }
1565
        }
1566
        return false;
1567
    }
1568
 
1569
    /**
1570
     * @param question_attempt_step $step a step
1571
     * @return bool whether the step is in the list of steps to be deleted.
1572
     */
1573
    protected function is_step_deleted(question_attempt_step $step) {
1574
        foreach ($this->stepsdeleted as $deletedstep) {
1575
            if ($deletedstep === $step) {
1576
                return true;
1577
            }
1578
        }
1579
        return false;
1580
    }
1581
 
1582
    /**
1583
     * Write all the changes we have recorded to the database.
1584
     * @param question_engine_data_mapper $dm the mapper to use to update the database.
1585
     */
1586
    public function save(question_engine_data_mapper $dm) {
1587
        $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context());
1588
 
1589
        // Initially an array of array of question_attempt_step_objects.
1590
        // Built as a nested array for efficiency, then flattened.
1591
        $stepdata = array();
1592
 
1593
        foreach ($this->stepsmodified as $stepinfo) {
1594
            list($step, $questionattemptid, $seq) = $stepinfo;
1595
            $stepdata[] = $dm->update_question_attempt_step(
1596
                    $step, $questionattemptid, $seq, $this->quba->get_owning_context());
1597
        }
1598
 
1599
        foreach ($this->stepsadded as $stepinfo) {
1600
            list($step, $questionattemptid, $seq) = $stepinfo;
1601
            $stepdata[] = $dm->insert_question_attempt_step(
1602
                    $step, $questionattemptid, $seq, $this->quba->get_owning_context());
1603
        }
1604
 
1605
        foreach ($this->attemptsmodified as $qa) {
1606
            $dm->update_question_attempt($qa);
1607
        }
1608
 
1609
        foreach ($this->attemptsadded as $qa) {
1610
            $stepdata[] = $dm->insert_question_attempt(
1611
                    $qa, $this->quba->get_owning_context());
1612
        }
1613
 
1614
        foreach ($this->metadataadded as $info) {
1615
            $qa = reset($info);
1616
            $stepdata[] = $dm->insert_question_attempt_metadata($qa, array_keys($info));
1617
        }
1618
 
1619
        foreach ($this->metadatamodified as $info) {
1620
            $qa = reset($info);
1621
            $stepdata[] = $dm->update_question_attempt_metadata($qa, array_keys($info));
1622
        }
1623
 
1624
        if ($this->modified) {
1625
            $dm->update_questions_usage_by_activity($this->quba);
1626
        }
1627
 
1628
        $dm->insert_all_step_data($dm->combine_step_data($stepdata));
1629
 
1630
        $this->stepsdeleted = array();
1631
        $this->stepsmodified = array();
1632
        $this->stepsadded = array();
1633
        $this->attemptsdeleted = array();
1634
        $this->attemptsadded = array();
1635
        $this->attemptsmodified = array();
1636
        $this->modified = false;
1637
    }
1638
}
1639
 
1640
 
1641
/**
1642
 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1643
 *
1644
 * @copyright  2012 The Open University
1645
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1646
 */
1647
interface question_response_files {
1648
    /**
1649
     * Get the files that were submitted.
1650
     * @return array of stored_files objects.
1651
     */
1652
    public function get_files();
1653
}
1654
 
1655
 
1656
/**
1657
 * This class represents the promise to save some files from a particular draft
1658
 * file area into a particular file area. It is used beause the necessary
1659
 * information about what to save is to hand in the
1660
 * {@link question_attempt::process_response_files()} method, but we don't know
1661
 * if this question attempt will actually be saved in the database until later,
1662
 * when the {@link question_engine_unit_of_work} is saved, if it is.
1663
 *
1664
 * @copyright  2011 The Open University
1665
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1666
 */
1667
class question_file_saver implements question_response_files {
1668
    /** @var int the id of the draft file area to save files from. */
1669
    protected $draftitemid;
1670
    /** @var string the owning component name. */
1671
    protected $component;
1672
    /** @var string the file area name. */
1673
    protected $filearea;
1674
 
1675
    /**
1676
     * @var string the value to store in the question_attempt_step_data to
1677
     * represent these files.
1678
     */
1679
    protected $value = null;
1680
 
1681
    /**
1682
     * Constructor.
1683
     *
1684
     * @param int $draftitemid the draft area to save the files from.
1685
     * @param string $component the component for the file area to save into.
1686
     * @param string $uncleanedfilearea the name of the file area to save into - but before it has been cleaned up.
1687
     * @param string $text optional content containing file links.
1688
     */
1689
    public function __construct($draftitemid, $component, $uncleanedfilearea, $text = null) {
1690
        $this->draftitemid = $draftitemid;
1691
        $this->component = $component;
1692
        $this->filearea = self::clean_file_area_name($uncleanedfilearea);
1693
        $this->value = $this->compute_value($draftitemid, $text);
1694
    }
1695
 
1696
    /**
1697
     * Compute the value that should be stored in the question_attempt_step_data table.
1698
     *
1699
     * Contains a hash that (almost) uniquely encodes all the files.
1700
     *
1701
     * @param int $draftitemid the draft file area itemid.
1702
     * @param string $text optional content containing file links.
1703
     * @return string the value.
1704
     */
1705
    protected function compute_value($draftitemid, $text) {
1706
        global $USER;
1707
 
1708
        $fs = get_file_storage();
1709
        $usercontext = context_user::instance($USER->id);
1710
 
1711
        $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
1712
                $draftitemid, 'sortorder, filepath, filename', false);
1713
 
1714
        $string = '';
1715
        foreach ($files as $file) {
1716
            $string .= $file->get_filepath() . $file->get_filename() . '|' .
1717
                    $file->get_contenthash() . '|';
1718
        }
1719
        $hash = md5($string);
1720
 
1721
        if (is_null($text)) {
1722
            if ($string) {
1723
                return $hash;
1724
            } else {
1725
                return '';
1726
            }
1727
        }
1728
 
1729
        // We add the file hash so a simple string comparison will say if the
1730
        // files have been changed. First strip off any existing file hash.
1731
        if ($text !== '') {
1732
            $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1733
            $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1734
            if ($string) {
1735
                $text .= '<!-- File hash: ' . $hash . ' -->';
1736
            }
1737
        }
1738
        return $text;
1739
    }
1740
 
1741
    public function __toString() {
1742
        return $this->value;
1743
    }
1744
 
1745
    /**
1746
     * Actually save the files.
1747
     *
1748
     * @param integer $itemid the item id for the file area to save into.
1749
     * @param context $context the context where the files should be saved.
1750
     */
1751
    public function save_files($itemid, $context) {
1752
        file_save_draft_area_files($this->draftitemid, $context->id,
1753
                $this->component, $this->filearea, $itemid);
1754
    }
1755
 
1756
    /**
1757
     * Clean up a possible file area name to ensure that it matches the required rules.
1758
     *
1759
     * @param string $uncleanedfilearea the proposed file area name (e.g. 'response_-attachments').
1760
     * @return string a similar valid file area name. E.g: response_attachments.
1761
     */
1762
    public static function clean_file_area_name(string $uncleanedfilearea): string {
1763
        $filearea = $uncleanedfilearea;
1764
        if ($filearea !== clean_param($filearea, PARAM_AREA)) {
1765
            // Only lowercase ascii letters, numbers and underscores are allowed.
1766
            // Remove the invalid character in the filearea string.
1767
            $filearea = preg_replace('~[^a-z0-9_]~', '', core_text::strtolower($filearea));
1768
            // Replace multiple underscore to a single underscore.
1769
            $filearea = preg_replace('~_+~', '_', $filearea);
1770
            // If, after attempted cleaning, the filearea is not valid, throw a clear error to avoid subtle bugs.
1771
            if ($filearea !== clean_param($filearea, PARAM_AREA)) {
1772
                throw new coding_exception('Name ' . $filearea .
1773
                    ' cannot be used with question_file_saver because it does not match the rules for file area names');
1774
            }
1775
        }
1776
        return $filearea;
1777
    }
1778
 
1779
    /**
1780
     * Get the files that were submitted.
1781
     * @return array of stored_files objects.
1782
     */
1783
    public function get_files() {
1784
        global $USER;
1785
 
1786
        $fs = get_file_storage();
1787
        $usercontext = context_user::instance($USER->id);
1788
 
1789
        return $fs->get_area_files($usercontext->id, 'user', 'draft',
1790
                $this->draftitemid, 'sortorder, filepath, filename', false);
1791
    }
1792
}
1793
 
1794
 
1795
/**
1796
 * This class is the mirror image of {@link question_file_saver}. It allows
1797
 * files to be accessed again later (e.g. when re-grading) using that same
1798
 * API as when doing the original grading.
1799
 *
1800
 * @copyright  2012 The Open University
1801
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1802
 */
1803
class question_file_loader implements question_response_files {
1804
    /** @var question_attempt_step the step that these files belong to. */
1805
    protected $step;
1806
 
1807
    /** @var string the field name for these files - which is used to construct the file area name. */
1808
    protected $name;
1809
 
1810
    /**
1811
     * @var string the value to stored in the question_attempt_step_data to
1812
     * represent these files.
1813
     */
1814
    protected $value;
1815
 
1816
    /** @var int the context id that the files belong to. */
1817
    protected $contextid;
1818
 
1819
    /**
1820
     * Constuctor.
1821
     * @param question_attempt_step $step the step that these files belong to.
1822
     * @param string $name string the field name for these files - which is used to construct the file area name.
1823
     * @param string $value the value to stored in the question_attempt_step_data to
1824
     *      represent these files.
1825
     * @param int $contextid the context id that the files belong to.
1826
     */
1827
    public function __construct(question_attempt_step $step, $name, $value, $contextid) {
1828
        $this->step = $step;
1829
        $this->name = $name;
1830
        $this->value = $value;
1831
        $this->contextid = $contextid;
1832
    }
1833
 
1834
    public function __toString() {
1835
        return $this->value;
1836
    }
1837
 
1838
    /**
1839
     * Get the files that were submitted.
1840
     * @return array of stored_files objects.
1841
     */
1842
    public function get_files() {
1843
        return $this->step->get_qt_files($this->name, $this->contextid);
1844
    }
1845
 
1846
    /**
1847
     * Copy these files into a draft area, and return the corresponding
1848
     * {@link question_file_saver} that can save them again.
1849
     *
1850
     * This is used by {@link question_attempt::start_based_on()}, which is used
1851
     * (for example) by the quizzes 'Each attempt builds on last' feature.
1852
     *
1853
     * @return question_file_saver that can re-save these files again.
1854
     */
1855
    public function get_question_file_saver() {
1856
 
1857
        // There are three possibilities here for what $value will look like:
1858
        // 1) some HTML content followed by an MD5 hash in a HTML comment;
1859
        // 2) a plain MD5 hash;
1860
        // 3) or some real content, without any hash.
1861
        // The problem is that 3) is ambiguous in the case where a student writes
1862
        // a response that looks exactly like an MD5 hash. For attempts made now,
1863
        // we avoid case 3) by always going for case 1) or 2) (except when the
1864
        // response is blank. However, there may be case 3) data in the database
1865
        // so we need to handle it as best we can.
1866
        if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) {
1867
            $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value);
1868
 
1869
        } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) {
1870
            $value = null;
1871
 
1872
        } else {
1873
            $value = $this->value;
1874
        }
1875
 
1876
        list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text(
1877
                $this->name, $this->contextid, $value);
1878
        return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text);
1879
    }
1880
}
1881
 
1882
 
1883
/**
1884
 * This class represents a restriction on the set of question_usage ids to include
1885
 * in a larger database query. Depending of the how you are going to restrict the
1886
 * list of usages, construct an appropriate subclass.
1887
 *
1888
 * If $qubaids is an instance of this class, example usage might be
1889
 *
1890
 * SELECT qa.id, qa.maxmark
1891
 * FROM $qubaids->from_question_attempts('qa')
1892
 * WHERE $qubaids->where() AND qa.slot = 1
1893
 *
1894
 * @copyright  2010 The Open University
1895
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1896
 */
1897
abstract class qubaid_condition {
1898
 
1899
    /**
1900
     * Get the SQL fragment to go in a FROM clause.
1901
     *
1902
     * The SQL that needs to go in the FROM clause when trying
1903
     * to select records from the 'question_attempts' table based on this
1904
     * qubaid_condition.
1905
     *
1906
     * @param string $alias
1907
     * @return string SQL fragment.
1908
     */
1909
    abstract public function from_question_attempts($alias);
1910
 
1911
    /** @return string the SQL that needs to go in the where clause. */
1912
    abstract public function where();
1913
 
1914
    /**
1915
     * @return array the params needed by a query that uses
1916
     * {@link from_question_attempts()} and {@link where()}.
1917
     */
1918
    abstract public function from_where_params();
1919
 
1920
    /**
1921
     * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1922
     * This method returns the "IN (...)" part.
1923
     */
1924
    abstract public function usage_id_in();
1925
 
1926
    /**
1927
     * @return array the params needed by a query that uses {@link usage_id_in()}.
1928
     */
1929
    abstract public function usage_id_in_params();
1930
 
1931
    /**
1932
     * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1933
     *                  condition.
1934
     */
1935
    public function get_hash_code() {
1936
        return sha1(serialize($this));
1937
    }
1938
}
1939
 
1940
 
1941
/**
1942
 * This class represents a restriction on the set of question_usage ids to include
1943
 * in a larger database query based on an explicit list of ids.
1944
 *
1945
 * @copyright  2010 The Open University
1946
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1947
 */
1948
class qubaid_list extends qubaid_condition {
1949
    /** @var array of ids. */
1950
    protected $qubaids;
1951
    protected $columntotest = null;
1952
    protected $params;
1953
 
1954
    /**
1955
     * Constructor.
1956
     * @param array $qubaids of question usage ids.
1957
     */
1958
    public function __construct(array $qubaids) {
1959
        $this->qubaids = $qubaids;
1960
    }
1961
 
1962
    public function from_question_attempts($alias) {
1963
        $this->columntotest = $alias . '.questionusageid';
1964
        return '{question_attempts} ' . $alias;
1965
    }
1966
 
1967
    public function where() {
1968
        if (is_null($this->columntotest)) {
1969
            throw new coding_exception('Must call from_question_attempts before where().');
1970
        }
1971
        if (empty($this->qubaids)) {
1972
            $this->params = array();
1973
            return '1 = 0';
1974
        }
1975
 
1976
        return $this->columntotest . ' ' . $this->usage_id_in();
1977
    }
1978
 
1979
    public function from_where_params() {
1980
        return $this->params;
1981
    }
1982
 
1983
    public function usage_id_in() {
1984
        global $DB;
1985
 
1986
        if (empty($this->qubaids)) {
1987
            $this->params = array();
1988
            return '= 0';
1989
        }
1990
        list($where, $this->params) = $DB->get_in_or_equal(
1991
                $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
1992
        return $where;
1993
    }
1994
 
1995
    public function usage_id_in_params() {
1996
        return $this->params;
1997
    }
1998
}
1999
 
2000
 
2001
/**
2002
 * This class represents a restriction on the set of question_usage ids to include
2003
 * in a larger database query based on JOINing to some other tables.
2004
 *
2005
 * The general form of the query is something like
2006
 *
2007
 * SELECT qa.id, qa.maxmark
2008
 * FROM $from
2009
 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
2010
 * WHERE $where AND qa.slot = 1
2011
 *
2012
 * where $from, $usageidcolumn and $where are the arguments to the constructor.
2013
 *
2014
 * @copyright  2010 The Open University
2015
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2016
 */
2017
class qubaid_join extends qubaid_condition {
2018
    public $from;
2019
    public $usageidcolumn;
2020
    public $where;
2021
    public $params;
2022
 
2023
    /**
2024
     * Constructor. The meaning of the arguments is explained in the class comment.
2025
     * @param string $from SQL fragemnt to go in the FROM clause.
2026
     * @param string $usageidcolumn the column in $from that should be
2027
     * made equal to the usageid column in the JOIN clause.
2028
     * @param string $where SQL fragment to go in the where clause.
2029
     * @param array $params required by the SQL. You must use named parameters.
2030
     */
2031
    public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
2032
        $this->from = $from;
2033
        $this->usageidcolumn = $usageidcolumn;
2034
        $this->params = $params;
2035
        if (empty($where)) {
2036
            $where = '1 = 1';
2037
        }
2038
        $this->where = $where;
2039
    }
2040
 
2041
    public function from_question_attempts($alias) {
2042
        return "{$this->from}
2043
                JOIN {question_attempts} {$alias} ON " .
2044
                        "{$alias}.questionusageid = $this->usageidcolumn";
2045
    }
2046
 
2047
    public function where() {
2048
        return $this->where;
2049
    }
2050
 
2051
    public function from_where_params() {
2052
        return $this->params;
2053
    }
2054
 
2055
    public function usage_id_in() {
2056
        return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})";
2057
    }
2058
 
2059
    public function usage_id_in_params() {
2060
        return $this->params;
2061
    }
2062
}