Skip to content

Commit

Permalink
refactor: use PHP 8 attributes to configure array_converter
Browse files Browse the repository at this point in the history
  • Loading branch information
MHajoha committed Oct 22, 2024
1 parent 7608d9b commit c6c6d82
Show file tree
Hide file tree
Showing 14 changed files with 594 additions and 42 deletions.
2 changes: 2 additions & 0 deletions classes/api/attempt_file.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +35,7 @@ class attempt_file {
public string $name;

/** @var string|null */
#[array_key("mime_type")]
public ?string $mimetype = null;

/** @var string $data */
Expand Down
25 changes: 9 additions & 16 deletions classes/api/question_response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -31,48 +28,44 @@
*/
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) {
$this->state = $state;
$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");
});
97 changes: 71 additions & 26 deletions classes/array_converter/array_converter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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()) {
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
}
44 changes: 44 additions & 0 deletions classes/array_converter/attributes/array_alias.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
// This file is part of the QuestionPy Moodle plugin - https://questionpy.org
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

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
) {
}
}
41 changes: 41 additions & 0 deletions classes/array_converter/attributes/array_element_class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
// This file is part of the QuestionPy Moodle plugin - https://questionpy.org
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

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
) {
}
}
Loading

0 comments on commit c6c6d82

Please sign in to comment.