Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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  }