Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

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