diff --git a/classes/api/attempt_file.php b/classes/api/attempt_file.php index 0afe0092..82f57059 100644 --- a/classes/api/attempt_file.php +++ b/classes/api/attempt_file.php @@ -17,6 +17,7 @@ namespace qtype_questionpy\api; use qtype_questionpy\array_converter\array_converter; +use qtype_questionpy\array_converter\attributes\array_key; use qtype_questionpy\array_converter\converter_config; defined('MOODLE_INTERNAL') || die; @@ -34,6 +35,7 @@ class attempt_file { public string $name; /** @var string|null */ + #[array_key("mime_type")] public ?string $mimetype = null; /** @var string $data */ diff --git a/classes/api/question_response.php b/classes/api/question_response.php index d4819e44..fe827482 100644 --- a/classes/api/question_response.php +++ b/classes/api/question_response.php @@ -16,10 +16,7 @@ namespace qtype_questionpy\api; -use qtype_questionpy\array_converter\array_converter; -use qtype_questionpy\array_converter\converter_config; - -defined('MOODLE_INTERNAL') || die; +use qtype_questionpy\array_converter\attributes\array_key; /** * Response from the server for a created or updated question. @@ -31,33 +28,40 @@ */ class question_response { /** @var string */ + #[array_key("question_state")] public string $state; /** @var string */ + #[array_key("scoring_method")] public string $scoringmethod; /** @var float|int */ + #[array_key("score_min")] public float $scoremin = 0; /** @var float|int */ + #[array_key("score_max")] public float $scoremax = 1; /** @var float|null */ public ?float $penalty = null; /** @var float|null */ + #[array_key("random_guess_score")] public ?float $randomguessscore = null; /** @var bool */ + #[array_key("render_every_view")] public bool $rendereveryview = false; /** @var string|null */ + #[array_key("general_feedback")] public ?string $generalfeedback = null; /** * Initialize a new question response. * - * @param string $state new question state + * @param string $state new question state * @param string $scoringmethod */ public function __construct(string $state, string $scoringmethod) { @@ -65,14 +69,3 @@ public function __construct(string $state, string $scoringmethod) { $this->scoringmethod = $scoringmethod; } } - -array_converter::configure(question_response::class, function (converter_config $config) { - $config - ->rename("state", "question_state") - ->rename("scoringmethod", "scoring_method") - ->rename("scoremin", "score_min") - ->rename("scoremax", "score_max") - ->rename("randomguessscore", "random_guess_score") - ->rename("rendereveryview", "render_every_view") - ->rename("generalfeedback", "general_feedback"); -}); diff --git a/classes/array_converter/array_converter.php b/classes/array_converter/array_converter.php index e75fd945..5607f89c 100644 --- a/classes/array_converter/array_converter.php +++ b/classes/array_converter/array_converter.php @@ -18,6 +18,11 @@ use coding_exception; use moodle_exception; +use qtype_questionpy\array_converter\attributes\array_alias; +use qtype_questionpy\array_converter\attributes\array_element_class; +use qtype_questionpy\array_converter\attributes\array_key; +use qtype_questionpy\array_converter\attributes\array_polymorphic; +use ReflectionAttribute; use ReflectionClass; use ReflectionException; use ReflectionNamedType; @@ -41,7 +46,7 @@ class array_converter { * Configuration is done in hook functions. When converting to or from a class instance, all hook function of it and * its superclasses and used traits are applied to a single {@see converter_config} instance. * - * @param string $class class or trait for whom array conversion should be customized + * @param string $class class or trait for whom array conversion should be customized * @param callable $hook configuration function which takes a {@see converter_config} instance as its argument, * adds its own configuration to it, and returning nothing * @see converter_config for the available options @@ -54,18 +59,24 @@ public static function configure(string $class, callable $hook): void { * Recursively converts an array to an instance of the given class. * * @param string $class target class - * @param array $raw raw array, e.g. one parsed using {@see json_decode()} + * @param array $raw raw array, e.g. one parsed using {@see json_decode()} * @return object an instance of `$class` * @throws moodle_exception */ public static function from_array(string $class, array $raw): object { - $config = self::get_config_for($class); + try { + $reflect = new ReflectionClass($class); + } catch (ReflectionException $e) { + throw new coding_exception($e->getMessage()); + } + + $config = self::get_config_for($reflect); if ($config->discriminator !== null) { $discriminator = $raw[$config->discriminator] ?? null; unset($raw[$config->discriminator]); - /* When a class uses polymorphism with a discriminator, deserialization to specific variants of that class. + /* When a class uses polymorphism with a discriminator, the target may be a specific variant of that class. For example, form_element uses discrimination, but checkbox_group_element->checkboxes knows that it wants checkbox_elements only. In that case, we only want to check that the wrong variant isn't given. */ $expected = array_flip($config->variants)[$class] ?? null; @@ -94,16 +105,16 @@ public static function from_array(string $class, array $raw): object { throw new moodle_exception("cannotgetdata", "error", "", null, $message); } } - } - // Also apply configuration of the variant, if any. - $config = self::get_config_for($class, $config); - } + try { + $reflect = new ReflectionClass($class); + } catch (ReflectionException $e) { + throw new coding_exception($e->getMessage()); + } - try { - $reflect = new ReflectionClass($class); - } catch (ReflectionException $e) { - throw new coding_exception($e->getMessage()); + // Continue with the variant's config. + $config = self::get_config_for($reflect); + } } $instance = self::instantiate($reflect, $config, $raw); @@ -129,18 +140,17 @@ public static function to_array($instance) { return (array)$instance; } - $config = self::get_config_for(get_class($instance)); - try { $reflect = new ReflectionClass($instance); } catch (ReflectionException $e) { throw new coding_exception($e->getMessage()); } + $config = self::get_config_for($reflect); + $result = []; $properties = $reflect->getProperties(); foreach ($properties as $property) { - $property->setAccessible(true); $value = $property->getValue($instance); $result[$config->renames[$property->name] ?? $property->name] = self::to_array($value); } @@ -212,12 +222,12 @@ private static function instantiate(ReflectionClass $reflect, converter_config $ * * @param ReflectionClass $reflect class of the instance * @param converter_config $config {@see converter_config config} for the class - * @param object $instance instance to inject values into + * @param object $instance instance to inject values into * @param array $raw * @throws moodle_exception if a value in the raw array cannot be converted to the type of the matching property */ private static function set_properties(ReflectionClass $reflect, converter_config $config, - object $instance, array &$raw): void { + object $instance, array &$raw): void { $properties = $reflect->getProperties(); foreach ($properties as $property) { if ($property->isStatic()) { @@ -235,7 +245,6 @@ private static function set_properties(ReflectionClass $reflect, converter_confi $value = $raw[$key]; - $property->setAccessible(true); $property->setValue( $instance, self::convert_to_required_type($property->getType(), $config, $property->name, $value) @@ -273,14 +282,14 @@ private static function get_first_present_key(array $array, string ...$possibili * @param ReflectionNamedType|null $type target type if known. Null otherwise, in which case the value will not be * converted * @param converter_config $config - * @param string $propname name of the property the value belongs to, for looking up in + * @param string $propname name of the property the value belongs to, for looking up in * {@see converter_config::$elementclasses} - * @param mixed $value raw value to convert + * @param mixed $value raw value to convert * @return mixed * @throws moodle_exception if the value cannot be converted to the given type */ private static function convert_to_required_type(?ReflectionNamedType $type, converter_config $config, - string $propname, $value) { + string $propname, $value) { if (!is_array($value) || !$type) { // For scalars and untyped properties / parameters, no conversion is done. return $value; @@ -316,31 +325,67 @@ private static function convert_to_required_type(?ReflectionNamedType $type, con /** * Calls all configuration hooks for the given class. * - * @param string $class + * @param ReflectionClass $class * @param converter_config|null $config an existing config to add to or null, in which case a new one will be * created * @return converter_config * @see self::configure() */ - private static function get_config_for(string $class, ?converter_config $config = null): converter_config { + private static function get_config_for(ReflectionClass $class, ?converter_config $config = null): converter_config { if (!$config) { $config = new converter_config(); } - $parent = get_parent_class($class); + $parent = $class->getParentClass(); if ($parent) { $config = self::get_config_for($parent, $config); } - foreach (class_uses($class) as $trait) { + foreach ($class->getTraits() as $trait) { $config = self::get_config_for($trait, $config); } - $hook = self::$hooks[$class] ?? null; + self::configure_from_attributes($class, $config); + + $hook = self::$hooks[$class->getName()] ?? null; if ($hook) { $hook($config); } return $config; } + + /** + * Inspects the attributes on the given class and updates the given config. + * + * @param ReflectionClass $reflect + * @param converter_config $config + * @return void + */ + private static function configure_from_attributes(ReflectionClass $reflect, converter_config $config): void { + $polyattrs = $reflect->getAttributes(array_polymorphic::class); + foreach ($polyattrs as $attr) { + /** @var array_polymorphic $instance */ + $instance = $attr->newInstance(); + $config->discriminator = $instance->discriminator; + $config->variants = $instance->variants; + $config->fallbackvariant = $instance->fallbackvariant; + } + + foreach ($reflect->getProperties() as $property) { + if ($property->isStatic()) { + continue; + } + + foreach ($property->getAttributes(array_key::class) as $attr) { + $config->rename($property->getName(), $attr->newInstance()->key); + } + foreach ($property->getAttributes(array_alias::class) as $attr) { + $config->alias($property->getName(), $attr->newInstance()->alias); + } + foreach ($property->getAttributes(array_element_class::class) as $attr) { + $config->array_elements($property->getName(), $attr->newInstance()->class); + } + } + } } diff --git a/classes/array_converter/attributes/array_alias.php b/classes/array_converter/attributes/array_alias.php new file mode 100644 index 00000000..879491a0 --- /dev/null +++ b/classes/array_converter/attributes/array_alias.php @@ -0,0 +1,44 @@ +. + +namespace qtype_questionpy\array_converter\attributes; + +use Attribute; + +/** + * Adds an alias for the given property. + * + * Aliases differ from renames in that they only apply to deserialization, and are tried in addition to the original + * property name (or rename, if any). + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[Attribute(Attribute::TARGET_PROPERTY)] +class array_alias { + /** + * Initializes a new attribute instance. + * + * @param string $alias + */ + public function __construct( + /** @var string $alias */ + public readonly string $alias + ) { + } +} diff --git a/classes/array_converter/attributes/array_element_class.php b/classes/array_converter/attributes/array_element_class.php new file mode 100644 index 00000000..06fcadd2 --- /dev/null +++ b/classes/array_converter/attributes/array_element_class.php @@ -0,0 +1,41 @@ +. + +namespace qtype_questionpy\array_converter\attributes; + +use Attribute; + +/** + * For an array-typed property with the given name, sets the class to use for the deserialization of its elements. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[Attribute(Attribute::TARGET_PROPERTY)] +class array_element_class { + /** + * Initializes a new attribute instance. + * + * @param class-string $class + */ + public function __construct( + /** @var class-string $class */ + public readonly string $class + ) { + } +} diff --git a/classes/array_converter/attributes/array_key.php b/classes/array_converter/attributes/array_key.php new file mode 100644 index 00000000..4198ca1d --- /dev/null +++ b/classes/array_converter/attributes/array_key.php @@ -0,0 +1,45 @@ +. + +namespace qtype_questionpy\array_converter\attributes; + +use Attribute; +use qtype_questionpy\array_converter\converter_property_attribute; + +/** + * Changes the name under which the value of a property appears in arrays. + * + * Renames differ from aliases in that they apply to both serialization and deserialization, and replace the original + * property name. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[Attribute(Attribute::TARGET_PROPERTY)] +class array_key { + /** + * Initializes a new attribute instance. + * + * @param string $key + */ + public function __construct( + /** @var string $key */ + public readonly string $key + ) { + } +} diff --git a/classes/array_converter/attributes/array_polymorphic.php b/classes/array_converter/attributes/array_polymorphic.php new file mode 100644 index 00000000..12361975 --- /dev/null +++ b/classes/array_converter/attributes/array_polymorphic.php @@ -0,0 +1,52 @@ +. + +namespace qtype_questionpy\array_converter\attributes; + +use Attribute; + +/** + * Enables polymorphic deserialization for this class, using the given key as a discriminator. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[Attribute(Attribute::TARGET_CLASS)] +class array_polymorphic { + /** + * Initializes a new attribute instance. + * + * @param string $discriminator The array key whose value decides the concrete class used for deserialization. + * @param array $variants An array of discriminator values to concrete class-strings. + * @param string|null $fallbackvariant The fallback variant for polymorphic deserialization. + */ + public function __construct( + /** @var string $discriminator The array key whose value decides the concrete class used for deserialization. */ + public readonly string $discriminator, + /** @var array $variants An array of discriminator values to concrete class-strings. */ + public readonly array $variants, + /** + * @var class-string|null $fallbackvariant The fallback variant for polymorphic deserialization. + * + * When a discriminator is encountered which isn't registered, the default behaviour is to throw an exception. + * Instead, you can register a fallback class to be used. A debugging message will still be emitted. + */ + public readonly ?string $fallbackvariant = null + ) { + } +} diff --git a/tests/array_converter/array_converter_test.php b/tests/array_converter/array_converter_test.php new file mode 100644 index 00000000..a4102401 --- /dev/null +++ b/tests/array_converter/array_converter_test.php @@ -0,0 +1,108 @@ +. + +namespace qtype_questionpy\array_converter; + +use qtype_questionpy\array_converter\test_classes\polymorphic; +use qtype_questionpy\array_converter\test_classes\simple; +use qtype_questionpy\array_converter\test_classes\uses_element_class; +use qtype_questionpy\array_converter\test_classes\uses_rename_and_alias; +use qtype_questionpy\array_converter\test_classes\variant2; + +/** + * Tests of {@see array_converter}. + * + * @covers \qtype_questionpy\array_converter + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class array_converter_test extends \advanced_testcase { + public function test_should_deserialize_from_rename(): void { + require_once(__DIR__ . "/test_classes/uses_rename_and_alias.php"); + + $result = array_converter::from_array(uses_rename_and_alias::class, ["my_prop_1" => "value1", "my_prop_2" => "value2"]); + + $this->assertEquals(uses_rename_and_alias::class, get_class($result)); + $this->assertEquals("value1", $result->myprop1); + $this->assertEquals("value2", $result->myprop2); + } + + public function test_should_serialize_to_rename(): void { + require_once(__DIR__ . "/test_classes/uses_rename_and_alias.php"); + + $instance = new uses_rename_and_alias("value2"); + $instance->myprop1 = "value1"; + $result = array_converter::to_array($instance); + + $this->assertEquals($result, [ + "my_prop_1" => "value1", + "my_prop_2" => "value2", + ]); + } + + public function test_should_deserialize_from_alias(): void { + require_once(__DIR__ . "/test_classes/uses_rename_and_alias.php"); + + $result = array_converter::from_array(uses_rename_and_alias::class, ["my_alias_1" => "value1", "my_alias_2" => "value2"]); + + $this->assertEquals(uses_rename_and_alias::class, get_class($result)); + $this->assertEquals("value1", $result->myprop1); + $this->assertEquals("value2", $result->myprop2); + } + + public function test_should_deserialize_array_elements(): void { + require_once(__DIR__ . "/test_classes/uses_element_class.php"); + + $result = array_converter::from_array(uses_element_class::class, ["myarray" => [ + ["prop" => "value1"], + ["prop" => "value2"], + ]]); + + $this->assertEquals(uses_element_class::class, get_class($result)); + $this->assertEquals([new simple("value1"), new simple("value2")], $result->myarray); + } + + public function test_should_deserialize_polymorphic(): void { + require_once(__DIR__ . "/test_classes/polymorphic.php"); + require_once(__DIR__ . "/test_classes/variant2.php"); + + $result = array_converter::from_array(polymorphic::class, [ + "discriminator" => "var2", + "prop" => "value1", + ]); + + $this->assertEquals(variant2::class, get_class($result)); + $this->assertEquals("value1", $result->prop); + } + + public function test_should_deserialize_polymorphic_fallback(): void { + require_once(__DIR__ . "/test_classes/polymorphic.php"); + + $result = array_converter::from_array(polymorphic::class, [ + "discriminator" => "abcdefg", + "prop" => "value2", + ]); + + $this->assertEquals(simple::class, get_class($result)); + $this->assertEquals("value2", $result->prop); + + $this->assertDebuggingCalled("Unknown value for discriminator 'discriminator': 'abcdefg'. Using fallback " + . "variant 'qtype_questionpy\\array_converter\\test_classes\\simple'."); + } +} diff --git a/tests/array_converter/test_classes/polymorphic.php b/tests/array_converter/test_classes/polymorphic.php new file mode 100644 index 00000000..179de1ee --- /dev/null +++ b/tests/array_converter/test_classes/polymorphic.php @@ -0,0 +1,35 @@ +. + +namespace qtype_questionpy\array_converter\test_classes; + +use qtype_questionpy\array_converter\attributes\array_polymorphic; + +defined('MOODLE_INTERNAL') || die; + +require_once(__DIR__ . "/simple.php"); + +/** + * Test class using {@see array_polymorphic}. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[array_polymorphic("discriminator", ["var1" => variant1::class, "var2" => variant2::class], simple::class)] +class polymorphic extends simple { +} diff --git a/tests/array_converter/test_classes/simple.php b/tests/array_converter/test_classes/simple.php new file mode 100644 index 00000000..ef48a75b --- /dev/null +++ b/tests/array_converter/test_classes/simple.php @@ -0,0 +1,38 @@ +. + +namespace qtype_questionpy\array_converter\test_classes; + +/** + * Simple test class. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class simple { + /** + * Initializes a new instance. + * + * @param string $prop + */ + public function __construct( + /** @var string $prop */ + public string $prop + ) { + } +} diff --git a/tests/array_converter/test_classes/uses_element_class.php b/tests/array_converter/test_classes/uses_element_class.php new file mode 100644 index 00000000..b1bee436 --- /dev/null +++ b/tests/array_converter/test_classes/uses_element_class.php @@ -0,0 +1,37 @@ +. + +namespace qtype_questionpy\array_converter\test_classes; + +use qtype_questionpy\array_converter\attributes\array_element_class; + +defined('MOODLE_INTERNAL') || die; + +require_once(__DIR__ . "/simple.php"); + +/** + * Test class using {@see array_element_class}. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class uses_element_class { + /** @var array $myarray */ + #[array_element_class(simple::class)] + public array $myarray; +} diff --git a/tests/array_converter/test_classes/uses_rename_and_alias.php b/tests/array_converter/test_classes/uses_rename_and_alias.php new file mode 100644 index 00000000..5fab7c99 --- /dev/null +++ b/tests/array_converter/test_classes/uses_rename_and_alias.php @@ -0,0 +1,48 @@ +. + +namespace qtype_questionpy\array_converter\test_classes; + +use qtype_questionpy\array_converter\attributes\array_alias; +use qtype_questionpy\array_converter\attributes\array_key; + +/** + * Test class using {@see array_key} and {@see array_alias}. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class uses_rename_and_alias { + /** @var string $myprop1 */ + #[array_key("my_prop_1")] + #[array_alias("my_alias_1")] + public string $myprop1; + + /** + * Initializes a new instance. + * + * @param string $myprop2 + */ + public function __construct( + /** @var string $myprop2 */ + #[array_key("my_prop_2")] + #[array_alias("my_alias_2")] + public string $myprop2 + ) { + } +} diff --git a/tests/array_converter/test_classes/variant1.php b/tests/array_converter/test_classes/variant1.php new file mode 100644 index 00000000..d103a35c --- /dev/null +++ b/tests/array_converter/test_classes/variant1.php @@ -0,0 +1,32 @@ +. + +namespace qtype_questionpy\array_converter\test_classes; + +defined('MOODLE_INTERNAL') || die; + +require_once(__DIR__ . "/polymorphic.php"); + +/** + * A variant of {@see polymorphic}. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class variant1 extends polymorphic { +} diff --git a/tests/array_converter/test_classes/variant2.php b/tests/array_converter/test_classes/variant2.php new file mode 100644 index 00000000..7df01a9e --- /dev/null +++ b/tests/array_converter/test_classes/variant2.php @@ -0,0 +1,32 @@ +. + +namespace qtype_questionpy\array_converter\test_classes; + +defined('MOODLE_INTERNAL') || die; + +require_once(__DIR__ . "/polymorphic.php"); + +/** + * A variant of {@see polymorphic}. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class variant2 extends polymorphic { +}