|
| 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