Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403]
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 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') { 1256 return "( 1257 SELECT MAX(sequencenumber) 1258 FROM {question_attempt_steps} 1259 WHERE questionattemptid = $questionattemptid 1260 )"; 1261 } 1262 1263 /** 1264 * Are any of these questions are currently in use? 1265 * 1266 * You should call {@link question_engine::questions_in_use()} 1267 * rather than calling this method directly. 1268 * 1269 * @param array $questionids of question ids. 1270 * @param qubaid_condition $qubaids ids of the usages to consider. 1271 * @return bool whether any of these questions are being used by any of 1272 * those usages. 1273 */ 1274 public function questions_in_use(array $questionids, qubaid_condition $qubaids) { 1275 list($test, $params) = $this->db->get_in_or_equal($questionids); 1276 return $this->db->record_exists_select('question_attempts', 1277 'questionid ' . $test . ' AND questionusageid ' . 1278 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params()); 1279 } 1280 1281 /** 1282 * Get the number of times each variant has been used for each question in a list 1283 * in a set of usages. 1284 * @param array $questionids of question ids. 1285 * @param qubaid_condition $qubaids ids of the usages to consider. 1286 * @return array questionid => variant number => num uses. 1287 */ 1288 public function load_used_variants(array $questionids, qubaid_condition $qubaids) { 1289 list($test, $params) = $this->db->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'qid'); 1290 $recordset = $this->db->get_recordset_sql(" 1291 SELECT qa.questionid, qa.variant, COUNT(1) AS usescount 1292 FROM " . $qubaids->from_question_attempts('qa') . " 1293 WHERE qa.questionid $test 1294 AND " . $qubaids->where() . " 1295 GROUP BY qa.questionid, qa.variant 1296 ORDER BY COUNT(1) ASC 1297 ", $params + $qubaids->from_where_params()); 1298 1299 $usedvariants = array_combine($questionids, array_fill(0, count($questionids), array())); 1300 foreach ($recordset as $row) { 1301 $usedvariants[$row->questionid][$row->variant] = $row->usescount; 1302 } 1303 $recordset->close(); 1304 return $usedvariants; 1305 } 1306 } 1307 1308 1309 /** 1310 * Implementation of the unit of work pattern for the question engine. 1311 * 1312 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the 1313 * changes to a {@link question_usage_by_activity}, and its constituent parts, 1314 * so that the changes can be saved to the database when {@link save()} is called. 1315 * 1316 * @copyright 2009 The Open University 1317 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1318 */ 1319 class question_engine_unit_of_work implements question_usage_observer { 1320 /** @var question_usage_by_activity the usage being tracked. */ 1321 protected $quba; 1322 1323 /** @var boolean whether any of the fields of the usage have been changed. */ 1324 protected $modified = false; 1325 1326 /** 1327 * @var question_attempt[] list of slot => {@link question_attempt}s that 1328 * have been added to the usage. 1329 */ 1330 protected $attemptsadded = array(); 1331 1332 /** 1333 * @var question_attempt[] list of slot => {@link question_attempt}s that 1334 * were already in the usage, and which have been modified. 1335 */ 1336 protected $attemptsmodified = array(); 1337 1338 /** 1339 * @var question_attempt[] list of slot => {@link question_attempt}s that 1340 * have been added to the usage. 1341 */ 1342 protected $attemptsdeleted = array(); 1343 1344 /** 1345 * @var array of array(question_attempt_step, question_attempt id, seq number) 1346 * of steps that have been added to question attempts in this usage. 1347 */ 1348 protected $stepsadded = array(); 1349 1350 /** 1351 * @var array of array(question_attempt_step, question_attempt id, seq number) 1352 * of steps that have been modified in their attempt. 1353 */ 1354 protected $stepsmodified = array(); 1355 1356 /** 1357 * @var question_attempt_step[] list of question_attempt_step.id => question_attempt_step of steps 1358 * that were previously stored in the database, but which are no longer required. 1359 */ 1360 protected $stepsdeleted = array(); 1361 1362 /** 1363 * @var array int slot => string name => question_attempt. 1364 */ 1365 protected $metadataadded = array(); 1366 1367 /** 1368 * @var array int slot => string name => question_attempt. 1369 */ 1370 protected $metadatamodified = array(); 1371 1372 /** 1373 * Constructor. 1374 * @param question_usage_by_activity $quba the usage to track. 1375 */ 1376 public function __construct(question_usage_by_activity $quba) { 1377 $this->quba = $quba; 1378 } 1379 1380 public function notify_modified() { 1381 $this->modified = true; 1382 } 1383 1384 public function notify_attempt_added(question_attempt $qa) { 1385 $this->attemptsadded[$qa->get_slot()] = $qa; 1386 } 1387 1388 public function notify_attempt_modified(question_attempt $qa) { 1389 $slot = $qa->get_slot(); 1390 if (!array_key_exists($slot, $this->attemptsadded)) { 1391 $this->attemptsmodified[$slot] = $qa; 1392 } 1393 } 1394 1395 public function notify_attempt_moved(question_attempt $qa, $oldslot) { 1396 $newslot = $qa->get_slot(); 1397 1398 if (array_key_exists($oldslot, $this->attemptsadded)) { 1399 unset($this->attemptsadded[$oldslot]); 1400 $this->attemptsadded[$newslot] = $qa; 1401 return; 1402 } 1403 1404 if (array_key_exists($oldslot, $this->attemptsmodified)) { 1405 unset($this->attemptsmodified[$oldslot]); 1406 } 1407 $this->attemptsmodified[$newslot] = $qa; 1408 1409 if (array_key_exists($oldslot, $this->metadataadded)) { 1410 $this->metadataadded[$newslot] = $this->metadataadded[$oldslot]; 1411 unset($this->metadataadded[$oldslot]); 1412 } 1413 if (array_key_exists($oldslot, $this->metadatamodified)) { 1414 $this->metadatamodified[$newslot] = $this->metadatamodified[$oldslot]; 1415 unset($this->metadatamodified[$oldslot]); 1416 } 1417 } 1418 1419 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) { 1420 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1421 return; 1422 } 1423 1424 if (($key = $this->is_step_added($step)) !== false) { 1425 return; 1426 } 1427 1428 if (($key = $this->is_step_modified($step)) !== false) { 1429 throw new coding_exception('Cannot add a step that has already been modified.'); 1430 } 1431 1432 if (($key = $this->is_step_deleted($step)) !== false) { 1433 unset($this->stepsdeleted[$step->get_id()]); 1434 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq); 1435 return; 1436 } 1437 1438 $stepid = $step->get_id(); 1439 if ($stepid) { 1440 if (array_key_exists($stepid, $this->stepsdeleted)) { 1441 unset($this->stepsdeleted[$stepid]); 1442 } 1443 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq); 1444 1445 } else { 1446 $this->stepsadded[] = array($step, $qa->get_database_id(), $seq); 1447 } 1448 } 1449 1450 public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) { 1451 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1452 return; 1453 } 1454 1455 if (($key = $this->is_step_added($step)) !== false) { 1456 return; 1457 } 1458 1459 if (($key = $this->is_step_deleted($step)) !== false) { 1460 throw new coding_exception('Cannot modify a step after it has been deleted.'); 1461 } 1462 1463 $stepid = $step->get_id(); 1464 if (empty($stepid)) { 1465 throw new coding_exception('Cannot modify a step that has never been stored in the database.'); 1466 } 1467 1468 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq); 1469 } 1470 1471 public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) { 1472 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1473 return; 1474 } 1475 1476 if (($key = $this->is_step_added($step)) !== false) { 1477 unset($this->stepsadded[$key]); 1478 return; 1479 } 1480 1481 if (($key = $this->is_step_modified($step)) !== false) { 1482 unset($this->stepsmodified[$key]); 1483 } 1484 1485 $stepid = $step->get_id(); 1486 if (empty($stepid)) { 1487 return; // Was never in the database. 1488 } 1489 1490 $this->stepsdeleted[$stepid] = $step; 1491 } 1492 1493 public function notify_metadata_added(question_attempt $qa, $name) { 1494 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1495 return; 1496 } 1497 1498 if ($this->is_step_added($qa->get_step(0)) !== false) { 1499 return; 1500 } 1501 1502 if (isset($this->metadataadded[$qa->get_slot()][$name])) { 1503 return; 1504 } 1505 1506 $this->metadataadded[$qa->get_slot()][$name] = $qa; 1507 } 1508 1509 public function notify_metadata_modified(question_attempt $qa, $name) { 1510 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1511 return; 1512 } 1513 1514 if ($this->is_step_added($qa->get_step(0)) !== false) { 1515 return; 1516 } 1517 1518 if (isset($this->metadataadded[$qa->get_slot()][$name])) { 1519 return; 1520 } 1521 1522 if (isset($this->metadatamodified[$qa->get_slot()][$name])) { 1523 return; 1524 } 1525 1526 $this->metadatamodified[$qa->get_slot()][$name] = $qa; 1527 } 1528 1529 /** 1530 * Determine if a step is new. If so get its array key. 1531 * 1532 * @param question_attempt_step $step a step 1533 * @return int|false if the step is in the list of steps to be added, return 1534 * the key, otherwise return false. 1535 */ 1536 protected function is_step_added(question_attempt_step $step) { 1537 foreach ($this->stepsadded as $key => $data) { 1538 list($addedstep) = $data; 1539 if ($addedstep === $step) { 1540 return $key; 1541 } 1542 } 1543 return false; 1544 } 1545 1546 /** 1547 * Determine if a step is modified. If so get its array key. 1548 * 1549 * @param question_attempt_step $step a step 1550 * @return int|false if the step is in the list of steps to be modified, return 1551 * the key, otherwise return false. 1552 */ 1553 protected function is_step_modified(question_attempt_step $step) { 1554 foreach ($this->stepsmodified as $key => $data) { 1555 list($modifiedstep) = $data; 1556 if ($modifiedstep === $step) { 1557 return $key; 1558 } 1559 } 1560 return false; 1561 } 1562 1563 /** 1564 * @param question_attempt_step $step a step 1565 * @return bool whether the step is in the list of steps to be deleted. 1566 */ 1567 protected function is_step_deleted(question_attempt_step $step) { 1568 foreach ($this->stepsdeleted as $deletedstep) { 1569 if ($deletedstep === $step) { 1570 return true; 1571 } 1572 } 1573 return false; 1574 } 1575 1576 /** 1577 * Write all the changes we have recorded to the database. 1578 * @param question_engine_data_mapper $dm the mapper to use to update the database. 1579 */ 1580 public function save(question_engine_data_mapper $dm) { 1581 $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context()); 1582 1583 // Initially an array of array of question_attempt_step_objects. 1584 // Built as a nested array for efficiency, then flattened. 1585 $stepdata = array(); 1586 1587 foreach ($this->stepsmodified as $stepinfo) { 1588 list($step, $questionattemptid, $seq) = $stepinfo; 1589 $stepdata[] = $dm->update_question_attempt_step( 1590 $step, $questionattemptid, $seq, $this->quba->get_owning_context()); 1591 } 1592 1593 foreach ($this->stepsadded as $stepinfo) { 1594 list($step, $questionattemptid, $seq) = $stepinfo; 1595 $stepdata[] = $dm->insert_question_attempt_step( 1596 $step, $questionattemptid, $seq, $this->quba->get_owning_context()); 1597 } 1598 1599 foreach ($this->attemptsmodified as $qa) { 1600 $dm->update_question_attempt($qa); 1601 } 1602 1603 foreach ($this->attemptsadded as $qa) { 1604 $stepdata[] = $dm->insert_question_attempt( 1605 $qa, $this->quba->get_owning_context()); 1606 } 1607 1608 foreach ($this->metadataadded as $info) { 1609 $qa = reset($info); 1610 $stepdata[] = $dm->insert_question_attempt_metadata($qa, array_keys($info)); 1611 } 1612 1613 foreach ($this->metadatamodified as $info) { 1614 $qa = reset($info); 1615 $stepdata[] = $dm->update_question_attempt_metadata($qa, array_keys($info)); 1616 } 1617 1618 if ($this->modified) { 1619 $dm->update_questions_usage_by_activity($this->quba); 1620 } 1621 1622 $dm->insert_all_step_data($dm->combine_step_data($stepdata)); 1623 1624 $this->stepsdeleted = array(); 1625 $this->stepsmodified = array(); 1626 $this->stepsadded = array(); 1627 $this->attemptsdeleted = array(); 1628 $this->attemptsadded = array(); 1629 $this->attemptsmodified = array(); 1630 $this->modified = false; 1631 } 1632 } 1633 1634 1635 /** 1636 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}. 1637 * 1638 * @copyright 2012 The Open University 1639 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1640 */ 1641 interface question_response_files { 1642 /** 1643 * Get the files that were submitted. 1644 * @return array of stored_files objects. 1645 */ 1646 public function get_files(); 1647 } 1648 1649 1650 /** 1651 * This class represents the promise to save some files from a particular draft 1652 * file area into a particular file area. It is used beause the necessary 1653 * information about what to save is to hand in the 1654 * {@link question_attempt::process_response_files()} method, but we don't know 1655 * if this question attempt will actually be saved in the database until later, 1656 * when the {@link question_engine_unit_of_work} is saved, if it is. 1657 * 1658 * @copyright 2011 The Open University 1659 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1660 */ 1661 class question_file_saver implements question_response_files { 1662 /** @var int the id of the draft file area to save files from. */ 1663 protected $draftitemid; 1664 /** @var string the owning component name. */ 1665 protected $component; 1666 /** @var string the file area name. */ 1667 protected $filearea; 1668 1669 /** 1670 * @var string the value to store in the question_attempt_step_data to 1671 * represent these files. 1672 */ 1673 protected $value = null; 1674 1675 /** 1676 * Constructor. 1677 * 1678 * @param int $draftitemid the draft area to save the files from. 1679 * @param string $component the component for the file area to save into. 1680 * @param string $uncleanedfilearea the name of the file area to save into - but before it has been cleaned up. 1681 * @param string $text optional content containing file links. 1682 */ 1683 public function __construct($draftitemid, $component, $uncleanedfilearea, $text = null) { 1684 $this->draftitemid = $draftitemid; 1685 $this->component = $component; 1686 $this->filearea = self::clean_file_area_name($uncleanedfilearea); 1687 $this->value = $this->compute_value($draftitemid, $text); 1688 } 1689 1690 /** 1691 * Compute the value that should be stored in the question_attempt_step_data table. 1692 * 1693 * Contains a hash that (almost) uniquely encodes all the files. 1694 * 1695 * @param int $draftitemid the draft file area itemid. 1696 * @param string $text optional content containing file links. 1697 * @return string the value. 1698 */ 1699 protected function compute_value($draftitemid, $text) { 1700 global $USER; 1701 1702 $fs = get_file_storage(); 1703 $usercontext = context_user::instance($USER->id); 1704 1705 $files = $fs->get_area_files($usercontext->id, 'user', 'draft', 1706 $draftitemid, 'sortorder, filepath, filename', false); 1707 1708 $string = ''; 1709 foreach ($files as $file) { 1710 $string .= $file->get_filepath() . $file->get_filename() . '|' . 1711 $file->get_contenthash() . '|'; 1712 } 1713 $hash = md5($string); 1714 1715 if (is_null($text)) { 1716 if ($string) { 1717 return $hash; 1718 } else { 1719 return ''; 1720 } 1721 } 1722 1723 // We add the file hash so a simple string comparison will say if the 1724 // files have been changed. First strip off any existing file hash. 1725 if ($text !== '') { 1726 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text); 1727 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid); 1728 if ($string) { 1729 $text .= '<!-- File hash: ' . $hash . ' -->'; 1730 } 1731 } 1732 return $text; 1733 } 1734 1735 public function __toString() { 1736 return $this->value; 1737 } 1738 1739 /** 1740 * Actually save the files. 1741 * 1742 * @param integer $itemid the item id for the file area to save into. 1743 * @param context $context the context where the files should be saved. 1744 */ 1745 public function save_files($itemid, $context) { 1746 file_save_draft_area_files($this->draftitemid, $context->id, 1747 $this->component, $this->filearea, $itemid); 1748 } 1749 1750 /** 1751 * Clean up a possible file area name to ensure that it matches the required rules. 1752 * 1753 * @param string $uncleanedfilearea the proposed file area name (e.g. 'response_-attachments'). 1754 * @return string a similar valid file area name. E.g: response_attachments. 1755 */ 1756 public static function clean_file_area_name(string $uncleanedfilearea): string { 1757 $filearea = $uncleanedfilearea; 1758 if ($filearea !== clean_param($filearea, PARAM_AREA)) { 1759 // Only lowercase ascii letters, numbers and underscores are allowed. 1760 // Remove the invalid character in the filearea string. 1761 $filearea = preg_replace('~[^a-z0-9_]~', '', core_text::strtolower($filearea)); 1762 // Replace multiple underscore to a single underscore. 1763 $filearea = preg_replace('~_+~', '_', $filearea); 1764 // If, after attempted cleaning, the filearea is not valid, throw a clear error to avoid subtle bugs. 1765 if ($filearea !== clean_param($filearea, PARAM_AREA)) { 1766 throw new coding_exception('Name ' . $filearea . 1767 ' cannot be used with question_file_saver because it does not match the rules for file area names'); 1768 } 1769 } 1770 return $filearea; 1771 } 1772 1773 /** 1774 * Get the files that were submitted. 1775 * @return array of stored_files objects. 1776 */ 1777 public function get_files() { 1778 global $USER; 1779 1780 $fs = get_file_storage(); 1781 $usercontext = context_user::instance($USER->id); 1782 1783 return $fs->get_area_files($usercontext->id, 'user', 'draft', 1784 $this->draftitemid, 'sortorder, filepath, filename', false); 1785 } 1786 } 1787 1788 1789 /** 1790 * This class is the mirror image of {@link question_file_saver}. It allows 1791 * files to be accessed again later (e.g. when re-grading) using that same 1792 * API as when doing the original grading. 1793 * 1794 * @copyright 2012 The Open University 1795 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1796 */ 1797 class question_file_loader implements question_response_files { 1798 /** @var question_attempt_step the step that these files belong to. */ 1799 protected $step; 1800 1801 /** @var string the field name for these files - which is used to construct the file area name. */ 1802 protected $name; 1803 1804 /** 1805 * @var string the value to stored in the question_attempt_step_data to 1806 * represent these files. 1807 */ 1808 protected $value; 1809 1810 /** @var int the context id that the files belong to. */ 1811 protected $contextid; 1812 1813 /** 1814 * Constuctor. 1815 * @param question_attempt_step $step the step that these files belong to. 1816 * @param string $name string the field name for these files - which is used to construct the file area name. 1817 * @param string $value the value to stored in the question_attempt_step_data to 1818 * represent these files. 1819 * @param int $contextid the context id that the files belong to. 1820 */ 1821 public function __construct(question_attempt_step $step, $name, $value, $contextid) { 1822 $this->step = $step; 1823 $this->name = $name; 1824 $this->value = $value; 1825 $this->contextid = $contextid; 1826 } 1827 1828 public function __toString() { 1829 return $this->value; 1830 } 1831 1832 /** 1833 * Get the files that were submitted. 1834 * @return array of stored_files objects. 1835 */ 1836 public function get_files() { 1837 return $this->step->get_qt_files($this->name, $this->contextid); 1838 } 1839 1840 /** 1841 * Copy these files into a draft area, and return the corresponding 1842 * {@link question_file_saver} that can save them again. 1843 * 1844 * This is used by {@link question_attempt::start_based_on()}, which is used 1845 * (for example) by the quizzes 'Each attempt builds on last' feature. 1846 * 1847 * @return question_file_saver that can re-save these files again. 1848 */ 1849 public function get_question_file_saver() { 1850 1851 // There are three possibilities here for what $value will look like: 1852 // 1) some HTML content followed by an MD5 hash in a HTML comment; 1853 // 2) a plain MD5 hash; 1854 // 3) or some real content, without any hash. 1855 // The problem is that 3) is ambiguous in the case where a student writes 1856 // a response that looks exactly like an MD5 hash. For attempts made now, 1857 // we avoid case 3) by always going for case 1) or 2) (except when the 1858 // response is blank. However, there may be case 3) data in the database 1859 // so we need to handle it as best we can. 1860 if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) { 1861 $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value); 1862 1863 } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) { 1864 $value = null; 1865 1866 } else { 1867 $value = $this->value; 1868 } 1869 1870 list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text( 1871 $this->name, $this->contextid, $value); 1872 return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text); 1873 } 1874 } 1875 1876 1877 /** 1878 * This class represents a restriction on the set of question_usage ids to include 1879 * in a larger database query. Depending of the how you are going to restrict the 1880 * list of usages, construct an appropriate subclass. 1881 * 1882 * If $qubaids is an instance of this class, example usage might be 1883 * 1884 * SELECT qa.id, qa.maxmark 1885 * FROM $qubaids->from_question_attempts('qa') 1886 * WHERE $qubaids->where() AND qa.slot = 1 1887 * 1888 * @copyright 2010 The Open University 1889 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1890 */ 1891 abstract class qubaid_condition { 1892 1893 /** 1894 * Get the SQL fragment to go in a FROM clause. 1895 * 1896 * The SQL that needs to go in the FROM clause when trying 1897 * to select records from the 'question_attempts' table based on this 1898 * qubaid_condition. 1899 * 1900 * @param string $alias 1901 * @return string SQL fragment. 1902 */ 1903 public abstract function from_question_attempts($alias); 1904 1905 /** @return string the SQL that needs to go in the where clause. */ 1906 public abstract function where(); 1907 1908 /** 1909 * @return array the params needed by a query that uses 1910 * {@link from_question_attempts()} and {@link where()}. 1911 */ 1912 public abstract function from_where_params(); 1913 1914 /** 1915 * @return string SQL that can use used in a WHERE qubaid IN (...) query. 1916 * This method returns the "IN (...)" part. 1917 */ 1918 public abstract function usage_id_in(); 1919 1920 /** 1921 * @return array the params needed by a query that uses {@link usage_id_in()}. 1922 */ 1923 public abstract function usage_id_in_params(); 1924 1925 /** 1926 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid 1927 * condition. 1928 */ 1929 public function get_hash_code() { 1930 return sha1(serialize($this)); 1931 } 1932 } 1933 1934 1935 /** 1936 * This class represents a restriction on the set of question_usage ids to include 1937 * in a larger database query based on an explicit list of ids. 1938 * 1939 * @copyright 2010 The Open University 1940 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1941 */ 1942 class qubaid_list extends qubaid_condition { 1943 /** @var array of ids. */ 1944 protected $qubaids; 1945 protected $columntotest = null; 1946 protected $params; 1947 1948 /** 1949 * Constructor. 1950 * @param array $qubaids of question usage ids. 1951 */ 1952 public function __construct(array $qubaids) { 1953 $this->qubaids = $qubaids; 1954 } 1955 1956 public function from_question_attempts($alias) { 1957 $this->columntotest = $alias . '.questionusageid'; 1958 return '{question_attempts} ' . $alias; 1959 } 1960 1961 public function where() { 1962 if (is_null($this->columntotest)) { 1963 throw new coding_exception('Must call from_question_attempts before where().'); 1964 } 1965 if (empty($this->qubaids)) { 1966 $this->params = array(); 1967 return '1 = 0'; 1968 } 1969 1970 return $this->columntotest . ' ' . $this->usage_id_in(); 1971 } 1972 1973 public function from_where_params() { 1974 return $this->params; 1975 } 1976 1977 public function usage_id_in() { 1978 global $DB; 1979 1980 if (empty($this->qubaids)) { 1981 $this->params = array(); 1982 return '= 0'; 1983 } 1984 list($where, $this->params) = $DB->get_in_or_equal( 1985 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid'); 1986 return $where; 1987 } 1988 1989 public function usage_id_in_params() { 1990 return $this->params; 1991 } 1992 } 1993 1994 1995 /** 1996 * This class represents a restriction on the set of question_usage ids to include 1997 * in a larger database query based on JOINing to some other tables. 1998 * 1999 * The general form of the query is something like 2000 * 2001 * SELECT qa.id, qa.maxmark 2002 * FROM $from 2003 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn 2004 * WHERE $where AND qa.slot = 1 2005 * 2006 * where $from, $usageidcolumn and $where are the arguments to the constructor. 2007 * 2008 * @copyright 2010 The Open University 2009 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 2010 */ 2011 class qubaid_join extends qubaid_condition { 2012 public $from; 2013 public $usageidcolumn; 2014 public $where; 2015 public $params; 2016 2017 /** 2018 * Constructor. The meaning of the arguments is explained in the class comment. 2019 * @param string $from SQL fragemnt to go in the FROM clause. 2020 * @param string $usageidcolumn the column in $from that should be 2021 * made equal to the usageid column in the JOIN clause. 2022 * @param string $where SQL fragment to go in the where clause. 2023 * @param array $params required by the SQL. You must use named parameters. 2024 */ 2025 public function __construct($from, $usageidcolumn, $where = '', $params = array()) { 2026 $this->from = $from; 2027 $this->usageidcolumn = $usageidcolumn; 2028 $this->params = $params; 2029 if (empty($where)) { 2030 $where = '1 = 1'; 2031 } 2032 $this->where = $where; 2033 } 2034 2035 public function from_question_attempts($alias) { 2036 return "{$this->from} 2037 JOIN {question_attempts} {$alias} ON " . 2038 "{$alias}.questionusageid = $this->usageidcolumn"; 2039 } 2040 2041 public function where() { 2042 return $this->where; 2043 } 2044 2045 public function from_where_params() { 2046 return $this->params; 2047 } 2048 2049 public function usage_id_in() { 2050 return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})"; 2051 } 2052 2053 public function usage_id_in_params() { 2054 return $this->params; 2055 } 2056 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body