@@ -47,6 +47,11 @@ abstract class restore_qtype_plugin extends restore_plugin {
47
47
*/
48
48
private $ questionanswercacheid = null ;
49
49
50
+ /**
51
+ * @var array List of fields to exclude form hashing during restore.
52
+ */
53
+ protected array $ excludedhashfields = [];
54
+
50
55
/**
51
56
* Add to $paths the restore_path_elements needed
52
57
* to handle question_answers for a given question
@@ -62,6 +67,10 @@ protected function add_question_question_answers(&$paths) {
62
67
$ elename = 'question_answer ' ;
63
68
$ elepath = $ this ->get_pathfor ('/answers/answer ' ); // we used get_recommended_name() so this works
64
69
$ paths [] = new restore_path_element ($ elename , $ elepath );
70
+ $ this ->exclude_identity_hash_fields ([
71
+ '/options/answers/id ' ,
72
+ '/options/answers/question ' ,
73
+ ]);
65
74
}
66
75
67
76
/**
@@ -78,6 +87,10 @@ protected function add_question_numerical_units(&$paths) {
78
87
$ elename = 'question_numerical_unit ' ;
79
88
$ elepath = $ this ->get_pathfor ('/numerical_units/numerical_unit ' ); // we used get_recommended_name() so this works
80
89
$ paths [] = new restore_path_element ($ elename , $ elepath );
90
+ $ this ->exclude_identity_hash_fields ([
91
+ '/options/units/id ' ,
92
+ '/options/units/question ' ,
93
+ ]);
81
94
}
82
95
83
96
/**
@@ -94,6 +107,7 @@ protected function add_question_numerical_options(&$paths) {
94
107
$ elename = 'question_numerical_option ' ;
95
108
$ elepath = $ this ->get_pathfor ('/numerical_options/numerical_option ' ); // we used get_recommended_name() so this works
96
109
$ paths [] = new restore_path_element ($ elename , $ elepath );
110
+ $ this ->exclude_identity_hash_fields (['/options/question ' ]);
97
111
}
98
112
99
113
/**
@@ -114,6 +128,21 @@ protected function add_question_datasets(&$paths) {
114
128
$ elename = 'question_dataset_item ' ;
115
129
$ elepath = $ this ->get_pathfor ('/dataset_definitions/dataset_definition/dataset_items/dataset_item ' );
116
130
$ paths [] = new restore_path_element ($ elename , $ elepath );
131
+ $ this ->exclude_identity_hash_fields ([
132
+ '/options/datasets/id ' ,
133
+ '/options/datasets/question ' ,
134
+ '/options/datasets/category ' ,
135
+ '/options/datasets/type ' ,
136
+ '/options/datasets/items/id ' ,
137
+ // The following fields aren't included in the backup or DB structure, but are parsed from the options field.
138
+ '/options/datasets/status ' ,
139
+ '/options/datasets/distribution ' ,
140
+ '/options/datasets/minimum ' ,
141
+ '/options/datasets/maximum ' ,
142
+ '/options/datasets/decimals ' ,
143
+ // This field is set dynamically from the count of items in the dataset, it is not backed up.
144
+ '/options/datasets/number_of_items ' ,
145
+ ]);
117
146
}
118
147
119
148
/**
@@ -395,4 +424,184 @@ public static function define_plugin_decode_contents() {
395
424
396
425
return $ contents ;
397
426
}
427
+
428
+ /**
429
+ * Add fields to the list of fields excluded from hashing.
430
+ *
431
+ * This allows common methods to add fields to the exclusion list.
432
+ *
433
+ * @param array $fields
434
+ * @return void
435
+ */
436
+ private function exclude_identity_hash_fields (array $ fields ): void {
437
+ $ this ->excludedhashfields = array_merge ($ this ->excludedhashfields , $ fields );
438
+ }
439
+
440
+ /**
441
+ * Return fields to be excluded from hashing during restores.
442
+ *
443
+ * @return array
444
+ */
445
+ final public function get_excluded_identity_hash_fields (): array {
446
+ return array_unique (array_merge (
447
+ $ this ->excludedhashfields ,
448
+ $ this ->define_excluded_identity_hash_fields (),
449
+ ));
450
+ }
451
+
452
+ /**
453
+ * Return a list of paths to fields to be removed from questiondata before creating an identity hash.
454
+ *
455
+ * Fields that should be excluded from common elements such as answers or numerical units that are used by the plugin will
456
+ * be excluded automatically. This method just needs to define any specific to this plugin, such as foreign keys used in the
457
+ * plugin's tables.
458
+ *
459
+ * The returned array should be a list of slash-delimited paths to locate the fields to be removed from the questiondata object.
460
+ * For example, if you want to remove the field `$questiondata->options->questionid`, the path would be '/options/questionid'.
461
+ * If a field in the path is an array, the rest of the path will be applied to each object in the array. So if you have
462
+ * `$questiondata->options->answers[]`, the path '/options/answers/id' will remove the 'id' field from each element of the
463
+ * 'answers' array.
464
+ *
465
+ * @return array
466
+ */
467
+ protected function define_excluded_identity_hash_fields (): array {
468
+ return [];
469
+ }
470
+
471
+ /**
472
+ * Convert the backup structure of this question type into a structure matching its question data
473
+ *
474
+ * This should take the hierarchical array of tags from the question's backup structure, and return a structure that matches
475
+ * that returned when calling {@see get_question_options()} for this question type.
476
+ * See https://docs.moodle.org/dev/Question_data_structures#Representation_1:_%24questiondata for an explanation of this
477
+ * structure.
478
+ *
479
+ * This data will then be used to produce an identity hash for comparison with questions in the database.
480
+ *
481
+ * This base implementation deals with all common backup elements created by the add_question_*_options() methods in this class,
482
+ * plus elements added by ::define_question_plugin_structure() named for the qtype. The question type will need to extend
483
+ * this function if ::define_question_plugin_structure() adds any other elements to the backup.
484
+ *
485
+ * @param array $tags The array of tags from the backup.
486
+ * @return \stdClass The questiondata object.
487
+ */
488
+ public static function convert_backup_to_questiondata (array $ tags ): \stdClass {
489
+ $ questiondata = (object ) array_filter ($ tags , fn ($ tag ) => !is_array ($ tag )); // Create an object from the top-level fields.
490
+ $ qtype = $ questiondata ->qtype ;
491
+ $ questiondata ->options = new stdClass ();
492
+ if (isset ($ tags ["plugin_qtype_ {$ qtype }_question " ][$ qtype ])) {
493
+ $ questiondata ->options = (object ) $ tags ["plugin_qtype_ {$ qtype }_question " ][$ qtype ][0 ];
494
+ }
495
+ if (isset ($ tags ["plugin_qtype_ {$ qtype }_question " ]['answers ' ])) {
496
+ $ questiondata ->options ->answers = array_map (
497
+ fn ($ answer ) => (object ) $ answer ,
498
+ $ tags ["plugin_qtype_ {$ qtype }_question " ]['answers ' ]['answer ' ],
499
+ );
500
+ }
501
+ if (isset ($ tags ["plugin_qtype_ {$ qtype }_question " ]['numerical_options ' ])) {
502
+ $ questiondata ->options = (object ) array_merge (
503
+ (array ) $ questiondata ->options ,
504
+ $ tags ["plugin_qtype_ {$ qtype }_question " ]['numerical_options ' ]['numerical_option ' ][0 ],
505
+ );
506
+ }
507
+ if (isset ($ tags ["plugin_qtype_ {$ qtype }_question " ]['numerical_units ' ])) {
508
+ $ questiondata ->options ->units = array_map (
509
+ fn ($ unit ) => (object ) $ unit ,
510
+ $ tags ["plugin_qtype_ {$ qtype }_question " ]['numerical_units ' ]['numerical_unit ' ],
511
+ );
512
+ }
513
+ if (isset ($ tags ["plugin_qtype_ {$ qtype }_question " ]['dataset_definitions ' ])) {
514
+ $ questiondata ->options ->datasets = array_map (
515
+ fn ($ dataset ) => (object ) $ dataset ,
516
+ $ tags ["plugin_qtype_ {$ qtype }_question " ]['dataset_definitions ' ]['dataset_definition ' ],
517
+ );
518
+ }
519
+ if (isset ($ questiondata ->options ->datasets )) {
520
+ foreach ($ questiondata ->options ->datasets as $ dataset ) {
521
+ if (isset ($ dataset ->dataset_items )) {
522
+ $ dataset ->items = array_map (
523
+ fn ($ item ) => (object ) $ item ,
524
+ $ dataset ->dataset_items ['dataset_item ' ],
525
+ );
526
+ unset($ dataset ->dataset_items );
527
+ }
528
+ }
529
+ }
530
+ if (isset ($ tags ['question_hints ' ])) {
531
+ $ questiondata ->hints = array_map (
532
+ fn ($ hint ) => (object ) $ hint ,
533
+ $ tags ['question_hints ' ]['question_hint ' ],
534
+ );
535
+ }
536
+
537
+ return $ questiondata ;
538
+ }
539
+
540
+ /**
541
+ * Remove excluded fields from the questiondata structure.
542
+ *
543
+ * This removes fields that will not match or not be present in the question data structure produced by
544
+ * {@see self::convert_backup_to_questiondata()} and {@see get_question_options()} (such as IDs), so that the remaining data can
545
+ * be used to produce an identity hash for comparing the two.
546
+ *
547
+ * For plugins, it should be sufficient to override {@see self::define_excluded_identity_hash_fields()} with a list of paths
548
+ * specific to the plugin type. Overriding this method is only necessary if the plugin's
549
+ * {@see question_type::get_question_options()} method adds additional data to the question that is not included in the backup.
550
+ *
551
+ * @param stdClass $questiondata
552
+ * @param array $excludefields Paths to the fields to exclude.
553
+ * @return stdClass The $questiondata with excluded fields removed.
554
+ */
555
+ public static function remove_excluded_question_data (stdClass $ questiondata , array $ excludefields = []): stdClass {
556
+ // All questions will need to exclude 'id' (used by question and other tables), 'questionid' (used by hints and options),
557
+ // 'createdby' and 'modifiedby' (since they won't map between sites).
558
+ $ defaultexcludes = [
559
+ '/id ' ,
560
+ '/createdby ' ,
561
+ '/modifiedby ' ,
562
+ '/hints/id ' ,
563
+ '/hints/questionid ' ,
564
+ '/options/id ' ,
565
+ '/options/questionid ' ,
566
+ ];
567
+ $ excludefields = array_unique (array_merge ($ excludefields , $ defaultexcludes ));
568
+
569
+ foreach ($ excludefields as $ excludefield ) {
570
+ $ pathparts = explode ('/ ' , ltrim ($ excludefield , '/ ' ));
571
+ $ data = $ questiondata ;
572
+ self ::unset_excluded_fields ($ data , $ pathparts );
573
+ }
574
+
575
+ return $ questiondata ;
576
+ }
577
+
578
+ /**
579
+ * Iterate through the elements of path to an excluded field, and unset the final element.
580
+ *
581
+ * If any of the elements in the path is an array, this is called recursively on each element in the array to unset fields
582
+ * in each child of the array.
583
+ *
584
+ * @param stdClass|array $data The questiondata object, or a subsection of it.
585
+ * @param array $pathparts The remaining elements in the path to the excluded field.
586
+ * @return void
587
+ */
588
+ private static function unset_excluded_fields (stdClass |array $ data , array $ pathparts ): void {
589
+ while (!empty ($ pathparts )) {
590
+ $ element = array_shift ($ pathparts );
591
+ if (!isset ($ data ->{$ element })) {
592
+ // This element is not present in the data structure, nothing to unset.
593
+ return ;
594
+ }
595
+ if (is_object ($ data ->{$ element })) {
596
+ $ data = $ data ->{$ element };
597
+ } else if (is_array ($ data ->{$ element })) {
598
+ foreach ($ data ->{$ element } as $ item ) {
599
+ self ::unset_excluded_fields ($ item , $ pathparts );
600
+ }
601
+ } else if (empty ($ pathparts )) {
602
+ // This is the last element of the path and it's a scalar value, unset it.
603
+ unset($ data ->{$ element });
604
+ }
605
+ }
606
+ }
398
607
}
0 commit comments