Skip to content

Commit e75d180

Browse files
committed
MDL-83541 Add qtype restore code documentation
1 parent f297f41 commit e75d180

File tree

2 files changed

+129
-0
lines changed

2 files changed

+129
-0
lines changed

docs/apis/plugintypes/qtype/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ Question types have to do many things:
2424
11. ... check access to files for the file API.
2525
12. `renderer.php` - to display the key bits of this question types for the `core_question_renderer` to combine into the overall question display.
2626
13. Implements Backup and restore, and all the other standard parts of a Moodle plugin like DB tables.
27+
- [Restore code](restore.md) requires some special considerations to avoid question duplication.
2728
14. Track [users preferences for the settings used for newly created questions](./qtype/newquestiondefaults).
+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
---
2+
title: Question type plugin restore code
3+
tags:
4+
- Plugins
5+
- Question
6+
- qtype
7+
description: Question type plugins must implement special restore code to avoid duplicated questions
8+
---
9+
10+
<Since
11+
version="4.4.6"
12+
issueNumber="MDL-83541"
13+
/>
14+
15+
## What has changed?
16+
17+
The backup and restore process has a long history of issues with shared questions, resulting in duplicates of questions being
18+
created, or errors upon restore. [MDL-83541](https://tracker.moodle.org/browse/MDL-83541) put in place a mechanism to resolve this by accurately matching questions being
19+
restored with those already in the target context, but it requires additional information from the question type plugins to ensure
20+
this matching works.
21+
22+
## How does the matching work?
23+
24+
When a question is restored, a SHA1 hash of the question data is generated, and compared against a SHA1 hash of each question in
25+
the category it is being restored to. If it finds a matching hash, the question is not restored, and any references to that
26+
question in the backup will point to the existing question instead. If no match is found, a new question will be created from
27+
the backup data.
28+
29+
This process uses the [questiondata](https://docs.moodle.org/dev/Question_data_structures#Representation_1:_%24questiondata) structure as the common format for comparing the question from the backup with the
30+
question in the database. For this to work, it must first convert the XML data structure into the questiondata structure,
31+
which may require help from the plugin. It must also remove any data from the structure which will not be consistent, such as
32+
database IDs.
33+
34+
Once this structure has been produced, it is flattened and concatenated into a string, which is then hashed.
35+
36+
## How can I tell if this affects my plugin?
37+
38+
`mod/quiz/tests/backup/repeated_restore_test.php` contains a set of unit tests that will run against all installed qtypes, to
39+
ensure that existing questions are correctly matched with restored questions. If you run this test class on a development site with
40+
your qtype plugin installed, it will fail if your plugin is missing the required information.
41+
42+
### These tests say my plugin was skipped!
43+
44+
In order to test a plugin, you must have a test question defined in your `tests/helper.php` file that has a `form_data` method, so
45+
that it can be generated with the `create_question()` data generator.
46+
47+
If your question type supports multiple tries, include a hint in your test question to ensure this is covered.
48+
49+
If your question type does not use the standard question_answers table, some tests may still be skipped. This is OK as this cannot
50+
be tested by the standard test, although you may wish to add your own test to check that a question where the question type's
51+
custom data has been edited results in different hashes.
52+
53+
## What is the impact if I don't do anything?
54+
55+
If your plugin does not provide the information, then each time a question is restored from a backup, a new copy will be created.
56+
This means that duplicating quizzes that use questions from a shared question bank will result in that question bank containing
57+
lots of copies of the same questions.
58+
59+
## How do I tell what changes I need to make?
60+
61+
If your plugin just uses the standard question tables, there is a chance you don't need to do anything. Running the unit test
62+
mentioned above will confirm this.
63+
64+
Check your plugin's `restore_qtype_$name_plugin` class in `backup/moodle2/restore_qtype_$name_plugin.class.php`. If the
65+
`define_question_plugin_structure()` method only calls `$this->add_question_*()` functions, these fields will be handled
66+
automatically so there is nothing more needed here. If it is adding additional paths to the `$paths[]` array, you will need to
67+
account for these.
68+
69+
Add an override of the `convert_backup_to_questiondata` method, which starts by calling
70+
71+
```php
72+
$questiondata = parent::convert_backup_to_questiondata($backupdata);
73+
```
74+
75+
Now, find the plugin-specific paths in `$backupdata`, and add them to the `$questiondata` object at the appropriate points.
76+
The result should match that returned by the `qtype_$name::get_question_options()` method in your plugin's `questiontype.php` file.
77+
78+
Now review your plugin's `db/install.xml` file. If your plugin defines any additional tables, you will need to define the primary
79+
and foreign key fields (any field that contains an ID) to be excluded from the data before hashing.
80+
81+
In your `restore_qtype_$name_plugin` class, add an override of the `define_excluded_identity_hash_fields()` method, which returns
82+
an array of fields to remove from the `$questiondata` structure. For example, if you have a table of `qtype_name_extradata`
83+
records with the fields `id`, `questionid` and `data`, you might need to define the following:
84+
85+
```php title="question/type/example/backup/moodle2/restore_qtype_example_plugin.class.php"
86+
protected function define_excluded_identity_hash_fields(): array {
87+
return [
88+
'/options/extradata/id',
89+
'/options/extradata/questionid',
90+
];
91+
}
92+
```
93+
94+
The exact paths required depend on where these extra records are added in the plugin's `get_question_options()` and
95+
`convert_backup_to_questiondata($backupdata)` methods.
96+
97+
Finally, check through your `get_question_options()` method to see if there is any other data attached to the `$questiondata`
98+
structure that is not included in the backup, it will need to be removed before hashing takes place.
99+
100+
In your `restore_qtype_$name_plugin` class, add an override of the `remove_excluded_question_data()` method, which removes this
101+
additional data, then passes `$questiondata` on to the parent method. For example, if `get_question_options()` adds a config
102+
setting at `$questiondata->options->pluginconfig`, you might need to define the following:
103+
104+
```php title="question/type/example/backup/moodle2/restore_qtype_example_plugin.class.php"
105+
public static function remove_excluded_question_data(stdClass $questiondata, array $excludefields = []): stdClass {
106+
if (isset($questiondata->options->pluginconfig)) {
107+
unset($questiondata->options->pluginconfig);
108+
}
109+
return parent::remove_excluded_question_data($questiondata, $excludefields);
110+
}
111+
```
112+
113+
### I did all that and the tests still fail!
114+
115+
If something is still failing and you can't tell why, the best option is to run the unit test with XDebug, and put a breakpoint on
116+
the `return` line of `restore_questions_parser_processor::generate_question_identity_hash()`. Most tests will hit this 3 times for
117+
each question: Once when reading the backup the first time and creating a restored question, again when reading it the second time,
118+
and finally when hashing the restored question for comparison. Comparing the contents of `$questiondata` between those last two
119+
samples should help you find why they are matching incorrectly, or not matching correctly, depending on the test.
120+
121+
## Where can I see some examples?
122+
123+
- `qtype_calculated` has an example of overriding `restore_qtype_plugin::convert_backup_to_questiondata()`, to add additional
124+
fields from the backup to the `$questiondata` structure, to add plugin-specific options and answer fields.
125+
- `qtype_truefalse` has an example of overriding `restore_qtype_plugin::define_excluded_identity_hash_fields()` to remove the
126+
ID fields used to reference the "trueanswer" and "falseanswer" answer records.
127+
- `qtype_multianswer` has an example of overriding `restore_qtype_plugin::remove_excluded_question_data()` to remove the array
128+
of subquestions, which are defined and restored separately in the backup so will not be part of the backup data for the question.

0 commit comments

Comments
 (0)