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:
@@ -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
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user