MDL-41924 backup: Reduce questions included in backups

Historically, backups of an activity using questions would include all
questions for the entire context, where any question had been used. This
could lead to very large backup files, and exacerbated problems with
question duplication.

This changes the process for annotating categories to include in the
backup to find specific categories, rather than whole contexts. It
includes 3 groups of categories:
1. Those that belong to the backed up activity's context.
2. Those that contain a question used directly in a question_reference
belonging to the activity.
3. Those that contain a question used via a question_set_reference
belonging to the activity.
It also adds in any parent categories that are not included by the
above, so we have a complete hierarchy.

For group 1, the backup will include all questions belonging to these
categories, as before. For groups 2 and 3, it will only include
questions for question bank entries that have been annotated by the
activity.
This commit is contained in:
Mark Johnson
2025-07-03 14:50:52 +01:00
parent 3e6d669b34
commit 006568435d
5 changed files with 208 additions and 49 deletions
@@ -0,0 +1,17 @@
issueNumber: MDL-41924
notes:
core_question:
- message: >
The question backup API has been improved to only include questions that
are actually used or owned by backed up activities.
Any activities that use question references should be supported
automatically. Activities that use *question set references* (for
example, random quiz questions) need to add a call to
`backup_question_set_reference_trait::annotate_set_reference_bank_entries()`
alongside the call to
`backup_question_set_reference_trait::add_question_set_references()` in
their backup step. See
`backup_quiz_activity_structure_step::define_structure()` for an
example.
type: improved
+98 -1
View File
@@ -27,6 +27,8 @@
defined('MOODLE_INTERNAL') || die();
use core_question\local\bank\random_question_loader;
/**
* Create the temp dir where backup/restore will happen and create temp ids table.
*/
@@ -238,6 +240,8 @@ trait backup_question_reference_data_trait {
'questionarea' => backup_helper::is_sqlparam($questionarea),
'itemid' => backup::VAR_PARENTID
]);
$reference->annotate_ids('question_bank_entry', 'questionbankentryid');
}
}
@@ -271,6 +275,56 @@ trait backup_question_set_reference_trait {
'itemid' => backup::VAR_PARENTID
]);
}
/**
* Find all questions that match set reference conditions used by the activity, and record the question bank entry IDs.
*
* @param int $contextid The context ID of the activity being backed up
* @param string $component The component of the activity
* @param string $questionarea The question area for finding set references
* @param string $backupid The backup ID to annotate question bank entries against
*/
protected function annotate_set_reference_bank_entries(
int $contextid,
string $component,
string $questionarea,
string $backupid,
): void {
global $DB;
$setreferenceconditions = $DB->get_fieldset(
'question_set_references',
'filtercondition',
[
'usingcontextid' => $contextid,
'component' => $component,
'questionarea' => $questionarea,
],
);
if (empty($setreferenceconditions)) {
return;
}
$setreferencequestionids = [];
$randomloader = new random_question_loader(new qubaid_list([]), []);
foreach ($setreferenceconditions as $setreferencecondition) {
$conditions = json_decode($setreferencecondition, true);
$setreferencequestionids += array_keys($randomloader->get_filtered_questions($conditions['filter'], 0));
}
if (empty($setreferencequestionids)) {
return;
}
[$insql, $inparams] = $DB->get_in_or_equal($setreferencequestionids);
$qbeids = $DB->get_fieldset_select(
'question_versions',
'questionbankentryid',
"questionid {$insql}",
$inparams,
);
foreach ($qbeids as $qbeid) {
backup_structure_dbops::insert_backup_ids_record($backupid, 'question_bank_entry', $qbeid);
}
}
}
@@ -2543,6 +2597,23 @@ class backup_annotate_all_question_files extends backup_execution_step {
*/
class backup_questions_structure_step extends backup_structure_step {
#[\Override]
public function execute() {
global $DB;
backup_controller_dbops::create_question_category_temp_tables();
$DB->execute("INSERT INTO {question_category_complete_temp} (backupid, itemid)
SELECT backupid, itemid
FROM {backup_ids_temp}
WHERE itemname = 'question_category_complete'");
$DB->execute("INSERT INTO {question_category_partial_temp} (backupid, itemid)
SELECT backupid, itemid
FROM {backup_ids_temp}
WHERE itemname = 'question_category_partial'");
$results = parent::execute();
backup_controller_dbops::drop_question_category_temp_tables();
return $results;
}
protected function define_structure() {
// Define each element separately.
@@ -2645,7 +2716,33 @@ class backup_questions_structure_step extends backup_structure_step {
WHERE bi.backupid = ?
AND bi.itemname = 'question_categoryfinal'", [backup::VAR_BACKUPID]);
$questionbankentry->set_source_table('question_bank_entries', ['questioncategoryid' => backup::VAR_PARENTID]);
// Add all question bank entries from "complete" categories, plus annotated question bank entires
// from "partial" categories.
$questionbankentry->set_source_sql(
"
SELECT qbe.*
FROM {question_bank_entries} qbe
JOIN {question_category_complete_temp} qcc ON qcc.itemid = qbe.questioncategoryid
WHERE qcc.itemid = ?
AND qcc.backupid = ?
UNION
SELECT qbe.*
FROM {question_bank_entries} qbe
JOIN {question_category_partial_temp} qcp ON qcp.itemid = qbe.questioncategoryid
JOIN {backup_ids_temp} biq ON biq.itemid = qbe.id
WHERE qcp.itemid = ?
AND qcp.backupid = ?
AND biq.backupid = ?
AND biq.itemname = 'question_bank_entry'
",
[
backup::VAR_PARENTID,
backup::VAR_BACKUPID,
backup::VAR_PARENTID,
backup::VAR_BACKUPID,
backup::VAR_BACKUPID,
],
);
$questionverion->set_source_table('question_versions', ['questionbankentryid' => backup::VAR_PARENTID]);
+3
View File
@@ -5494,6 +5494,9 @@ class restore_move_module_questions_categories extends restore_execution_step {
'question',
$originalquestion->id,
);
if (!$backupids) {
continue; // This question was not included in the backup.
}
// Restored question references will point to the restored copy of the question. Select question references
// that point to that restored copy, only if they are within the target course's context, so we can update
// them to point to the original question.
@@ -161,6 +161,28 @@ abstract class backup_controller_dbops extends backup_dbops {
$dbman->create_temp_table($xmldb_table); // And create it
}
/**
* Create temporary tables to store a partial copies of question category data from the backup_ids_temp table.
*
* This is to work around MySQL's restruction on joining the same temporary table multiple times in the same query. It isn't
* necessary on PostgreSQL (which can join temporary tables multiple times) or MSSQL (which doesn't really use temporary tables)
* but doing it this way keeps things database-agnostic.
*/
public static function create_question_category_temp_tables(): void {
global $DB;
$dbman = $DB->get_manager();
foreach (['question_category_complete_temp', 'question_category_partial_temp'] as $tablename) {
$xmldbtable = new xmldb_table($tablename);
$xmldbtable->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE);
$xmldbtable->add_field('backupid', XMLDB_TYPE_CHAR, 32, null, XMLDB_NOTNULL);
$xmldbtable->add_field('itemid', XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL);
$xmldbtable->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
$dbman->create_temp_table($xmldbtable);
}
}
public static function drop_backup_ids_temp_table($backupid) {
global $DB;
$dbman = $DB->get_manager(); // We are going to use database_manager services
@@ -172,6 +194,21 @@ abstract class backup_controller_dbops extends backup_dbops {
}
}
/**
* Dispose of the additional temporary tables.
*/
public static function drop_question_category_temp_tables(): void {
global $DB;
$dbman = $DB->get_manager();
foreach (['question_category_complete_temp', 'question_category_partial_temp'] as $tablename) {
if ($dbman->table_exists($tablename)) {
$xmldbtable = new xmldb_table($tablename);
$dbman->drop_table($xmldbtable);
}
}
}
/**
* Decode the info field from backup_ids_temp or backup_files_temp.
*
@@ -40,61 +40,66 @@ abstract class backup_question_dbops extends backup_dbops {
public static function calculate_question_categories($backupid, $contextid) {
global $DB;
// First step, annotate all the categories for the given context (course/module)
// First step, get all the categories for the given context (course/module)
// i.e. the whole context questions bank
$DB->execute("INSERT INTO {backup_ids_temp} (backupid, itemname, itemid)
SELECT ?, 'question_category', id
FROM {question_categories}
WHERE contextid = ?", array($backupid, $contextid));
$contextcategories = $DB->get_records_menu('question_categories', ['contextid' => $contextid], '', 'id, parent');
// Now, based in the annotated questions, annotate all the categories they
// belong to (whole context question banks too)
// First, get all the contexts we are going to save their question bank (no matter
// where they are in the contexts hierarchy, transversals... whatever)
$contexts = $DB->get_fieldset_sql("SELECT DISTINCT qc2.contextid
FROM {question_categories} qc2
JOIN {question_bank_entries} qbe ON qbe.questioncategoryid = qc2.id
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
JOIN {question} q ON q.id = qv.questionid
JOIN {backup_ids_temp} bi ON bi.itemid = q.id
WHERE bi.backupid = ?
AND bi.itemname = 'question'
AND qc2.contextid != ?", array($backupid, $contextid));
// Now, based in the annotated question bank entries, get all the categories they
// belong to.
$questioncategories = $DB->get_records_sql_menu(
"SELECT DISTINCT qc2.id, qc2.parent
FROM {question_categories} qc2
JOIN {question_bank_entries} qbe ON qbe.questioncategoryid = qc2.id
JOIN {backup_ids_temp} bi ON bi.itemid = qbe.id
WHERE bi.backupid = ?
AND bi.itemname = 'question_bank_entry'
AND qc2.contextid != ?",
[$backupid, $contextid]
);
// Calculate and get the set reference records.
$setreferencecontexts = $DB->get_fieldset_sql("
SELECT DISTINCT qc.contextid
FROM {question_categories} qc
JOIN {question_set_references} qsr ON qsr.questionscontextid = qc.contextid
WHERE qsr.usingcontextid = ?", [$contextid]);
foreach ($setreferencecontexts as $setreferencecontext) {
if (!in_array($setreferencecontext, $contexts) && (int)$setreferencecontext !== $contextid) {
$contexts [] = $setreferencecontext;
// These are all the question categories we want to include in the backup.
$categories = $contextcategories + $questioncategories;
// If we're not already including the parents of a category, add them in.
foreach ($categories as $parentid) {
if (!array_key_exists($parentid, $categories)) {
$categories += self::get_parent_categories($parentid);
}
}
// Insert annotations of the found categories.
foreach (array_keys($categories) as $categoryid) {
backup_structure_dbops::insert_backup_ids_record($backupid, 'question_category', $categoryid);
}
// For these categories, we want to include all questions.
foreach (array_keys($contextcategories) as $categoryid) {
backup_structure_dbops::insert_backup_ids_record($backupid, 'question_category_complete', $categoryid);
}
// For these categories, we only want to include the questions that have been annotated.
// Exclude those where we're already including all questions.
$partialcategories = array_diff(
array_keys($questioncategories),
array_keys($contextcategories),
);
foreach ($partialcategories as $categoryid) {
backup_structure_dbops::insert_backup_ids_record($backupid, 'question_category_partial', $categoryid);
}
}
// Calculate the get the reference records.
$referencecontexts = $DB->get_fieldset_sql("
SELECT DISTINCT qc.contextid
FROM {question_categories} qc
JOIN {question_bank_entries} qbe ON qbe.questioncategoryid = qc.id
JOIN {question_references} qr ON qr.questionbankentryid = qbe.id
WHERE qr.usingcontextid =?", [$contextid]);
foreach ($referencecontexts as $referencecontext) {
if (!in_array($referencecontext, $contexts) && (int)$referencecontext !== $contextid) {
$contexts [] = $referencecontext;
}
}
// And now, simply insert all the question categories (complete question bank)
// for those contexts if we have found any
if ($contexts) {
list($contextssql, $contextparams) = $DB->get_in_or_equal($contexts);
$params = array_merge(array($backupid), $contextparams);
$DB->execute("INSERT INTO {backup_ids_temp} (backupid, itemname, itemid)
SELECT ?, 'question_category', id
FROM {question_categories}
WHERE contextid $contextssql", $params);
/**
* Recursively find the parents and ancestors of the given category
*
* @param int $categoryid The category we want to find parents for.
* @return array id => parentid for each category
*/
protected static function get_parent_categories(int $categoryid): array {
global $DB;
$parentcategories = [];
$parentid = $DB->get_field('question_categories', 'parent', ['id' => $categoryid]);
$parentcategories[$categoryid] = $parentid;
// If this is not a top category, keep going.
if ($parentid > 0) {
array_merge($parentcategories, self::get_parent_categories($parentid));
}
return $parentcategories;
}
/**