diff --git a/manifest.php b/manifest.php index 1c12cb82da..d7c1c55096 100755 --- a/manifest.php +++ b/manifest.php @@ -30,6 +30,7 @@ use oat\tao\model\import\ServiceProvider\ImportServiceProvider; use oat\tao\model\metadata\ServiceProvider\MetadataServiceProvider; use oat\tao\model\Observer\ServiceProvider\ObserverServiceProvider; +use oat\tao\model\RdfObjectMapper\ServiceProvider\RdfObjectMapperServiceProvider; use oat\tao\model\resources\ResourcesServiceProvider; use oat\tao\model\featureFlag\FeatureFlagServiceProvider; use oat\tao\helpers\form\ServiceProvider\FormServiceProvider; @@ -297,5 +298,6 @@ AccessControlServiceProvider::class, MetadataServiceProvider::class, ObserverServiceProvider::class, + RdfObjectMapperServiceProvider::class, ], ]; diff --git a/models/classes/RdfObjectMapper/Annotation/RdfAttributeMapping.php b/models/classes/RdfObjectMapper/Annotation/RdfAttributeMapping.php new file mode 100644 index 0000000000..5f61d13b54 --- /dev/null +++ b/models/classes/RdfObjectMapper/Annotation/RdfAttributeMapping.php @@ -0,0 +1,103 @@ +propertyUri = $propertyUri; + $this->attributeType = $attributeType; + $this->mappedField = $mappedField; + }*/ + + /*public function __construct() //($data, $b, $c) + { + //$this->attributeType = $attributeType; + + //echo "Called RdfAttributeMapping ctor with ".var_export($data,true); + }*/ + + // You may see keeping hydrate() here in the annotation class as a good + // thing (as in "higher cohesion", keeping the value initialization in the + // annotation implementation) or bad thing (as in "higher coupling", + // preventing having more than one behaviour (implementation) for a given + // annotation). + // + // Maybe we'll want to allow overriding the behaviour for a given annotation + // somehow in the future (for example, from extensions)). + // + public function hydrate( + LoggerInterface $logger, + ReflectionProperty $property, + core_kernel_classes_Resource $src, + object $targetObject + ): void + { + $logger->debug(__CLASS__ . " maps a value to the property"); + + $values = $src->getPropertyValues( + new core_kernel_classes_Property($this->propertyUri) + ); + + if(count($values) == 0) { + $logger->warning(__CLASS__ . " no value to map"); + return; + } + + if(count($values) > 1) { + $logger->warning(__CLASS__ . "too many values to map"); + return; + } + + if(count($values) == 1) { + $value = current($values); + $logger->info( + sprintf("%s Mapping value %s into %s", + __CLASS__, + $value, + $property->getName() + ) + ); + + $property->setAccessible(true); + $property->setValue($targetObject, $value); + } + } +} diff --git a/models/classes/RdfObjectMapper/Annotation/RdfResourceAttributeMapping.php b/models/classes/RdfObjectMapper/Annotation/RdfResourceAttributeMapping.php new file mode 100644 index 0000000000..b245f43ff4 --- /dev/null +++ b/models/classes/RdfObjectMapper/Annotation/RdfResourceAttributeMapping.php @@ -0,0 +1,90 @@ +attributeType = $attributeType; + + //echo "Called RdfResourceAttributeMapping ctor with ".var_export($data,true); + }*/ + + public function hydrate( + LoggerInterface $logger, + ReflectionProperty $property, + core_kernel_classes_Resource $src, + object $targetObject + ): void + { + $logger->debug( + __CLASS__ . + ' maps a (direct) value from the resource class to the property' + ); + + switch ($this->type) + { + case RdfResourceAttributeType::LABEL: + $value = $src->getLabel(); + break; + case RdfResourceAttributeType::COMMENT: + $value = $src->getComment(); + break; + case RdfResourceAttributeType::URI: + $value = $src->getUri(); + break; + default: + throw new LogicException( + "Unknown ".__CLASS__."::type value: ". + $this->type + ); + } + + $logger->info( + sprintf("%s Mapping value %s into %s", + __CLASS__, + $value, + $property->getName() + ) + ); + + if (version_compare(PHP_VERSION, '8.1.0', '<')) { + // Not needed starting PHP 8.1 (it has become a no-op since then) + $property->setAccessible(true); + } + + $property->setValue($targetObject, $value); + } +} diff --git a/models/classes/RdfObjectMapper/Annotation/RdfResourceAttributeType.php b/models/classes/RdfObjectMapper/Annotation/RdfResourceAttributeType.php new file mode 100644 index 0000000000..c4f04adf2e --- /dev/null +++ b/models/classes/RdfObjectMapper/Annotation/RdfResourceAttributeType.php @@ -0,0 +1,28 @@ +getRange()) + // - 'resource' (complete resource instance) + // - 'uri' (resource URI as string) + 'uri' + )]*/ + + /** @RdfAttributeMapping( + * propertyUri = GenerisRdf::PROPERTY_USER_UILG, + * attributeType = "resource", + * mappedField = "uri") + */ + private /*?string*/ $uiLanguageCode; + + /** + * @RdfAttributeMapping( + * propertyUri = GenerisRdf::PROPERTY_USER_DEFLG, + * attributeType = "resource", + * mappedField = "uri") + */ + private /*?string*/ $dataLanguage; + + /** + * property returned as a core_kernel_classes_Literal instance, + * will be converted to string + * @todo Use RdfResourceAttributeType (or a new class) constants for + * attributeType + * @RdfAttributeMapping( + * GenerisRdf::PROPERTY_USER_TIMEZONE, + * attributeType = "literal") + */ + private /*string*/ $timezone; + + public function getUri(): string + { + return $this->userUri; + } + + public function getLabel(): string + { + return $this->userLabel; + } + + public function getComment(): string + { + return $this->userComment; + } + + public function getUILanguageCode(): ?string + { + return $this->uiLanguageCode; + } + + public function getDataLanguageCode(): ?string + { + return $this->dataLanguage; + } + + public function getTimezone(): string + { + return $this->timezone; + } +} diff --git a/models/classes/RdfObjectMapper/NOTES.md b/models/classes/RdfObjectMapper/NOTES.md new file mode 100644 index 0000000000..606f12e541 --- /dev/null +++ b/models/classes/RdfObjectMapper/NOTES.md @@ -0,0 +1,47 @@ +**WiP** + +## Notes + +- As Generis explicitly depends on `doctrine/annotations ~1.6.0`, the current + implementation uses Doctrine annotations instead without needing additional + deps. + +- taoDockerize uses PHP 7.2 and Generis is supporting `"php": "^7.1"`: We cannot + use native PHP annotations (yet?). + +- This draft provides an interface for an object mapper, and a single + implementation that uses PHPDoc annotations. + +- Right now only reading RDF data has been considered (i.e. not *writing* new + data or updating existing data). + +## Things to consider + +- Maybe this should be in Generis instead? + +- Another possibility would be to have a "root" object mapper that just + delegates the mapping to a "child" mapper class (a chain of responsibility), + so we can have a mapper based on Doctrine annotations while also having the + possibility to implement a different one using PHP native annotations in the + future (without needing to convert existing classes using Doctrine annotations + to the PHP syntax all at once). + + - For that, we may use an approach similar to Doctrine's: if the object has a + `@Whatever` annotation inside a PHPDoc block, we map using Doctrine + annotations; if it has a native annotation (like + `#[RdfResourceAttributeMapping(RdfResourceAttributeMapping::URI)]`), map + +- May be worth considering how this fits with other architectural patterns + currently under discussion. + +- Performance: Right now this always hydrates values for all properties (one by + one) by calling getPropertyValues for each of them. This is done at + RdfAttributeMapping (which may not be the best place to do so) by calling + Resource::getPropertyValues($uri) for each property. + +# TODO + +- Test object inheritance: Having, say, a user class defined somewhere and + another user class extending that one (for example, a class in an extension + that extends that one) should allow having all RDF data for that resource + mapped into the object. \ No newline at end of file diff --git a/models/classes/RdfObjectMapper/Service/RdfObjectMapper.php b/models/classes/RdfObjectMapper/Service/RdfObjectMapper.php new file mode 100644 index 0000000000..f3cf7b4756 --- /dev/null +++ b/models/classes/RdfObjectMapper/Service/RdfObjectMapper.php @@ -0,0 +1,78 @@ +logger = $logger; + $this->hydrator = $hydrator; + } + + /** + * @throws ReflectionException + */ + public function mapResource( + core_kernel_classes_Resource $resource, + string $targetClass + ): object + { + $reflector = new ReflectionClass($targetClass); + $instance = $reflector->newInstanceWithoutConstructor(); + + $this->hydrator->hydrateInstance($reflector, $resource, $instance); + + $this->callConstructorIfPresent($reflector, $instance); + + return $instance; + } + + private function callConstructorIfPresent( + ReflectionClass $reflector, + object $instance + ) + { + if($reflector->getConstructor() != null) + { + $closure = $reflector->getConstructor()->getClosure(); + $closure->call($instance); + } + } +} diff --git a/models/classes/RdfObjectMapper/Service/ResourceHydrator.php b/models/classes/RdfObjectMapper/Service/ResourceHydrator.php new file mode 100644 index 0000000000..6cea1efe4c --- /dev/null +++ b/models/classes/RdfObjectMapper/Service/ResourceHydrator.php @@ -0,0 +1,89 @@ +logger = $logger; + $this->reader = new AnnotationReader(); + + // Too bad this pollutes the global annotation reader state + AnnotationRegistry::loadAnnotationClass(RdfResourceAttributeMapping::class); + AnnotationRegistry::loadAnnotationClass(RdfAttributeMapping::class); + } + + public function hydrateInstance( + ReflectionClass $reflector, + core_kernel_classes_Resource $src, + object &$targetObject + ) + { + foreach($reflector->getProperties() as $property) { + $this->logger->info("Handling property: {$property->getName()}"); + + $propertyAnnotations = $this->reader->getPropertyAnnotations( + $property + ); + + $this->logger->debug("Property {$property->getName()} annotations"); + + foreach ($propertyAnnotations as $annotation) + { + if($annotation instanceof RdfResourceAttributeMapping) + { + $this->logger->debug('-> Using RdfResourceAttributeMapping'); + $annotation->hydrate($this->logger, $property, $src, $targetObject); + continue; + } + + if($annotation instanceof RdfAttributeMapping) + { + $this->logger->debug('-> Using RdfAttributeMapping'); + $annotation->hydrate($this->logger, $property, $src, $targetObject); + continue; + } + + throw new Exception( + "Unknown class property type: " . get_class($annotation) + ); + } + } + } +} diff --git a/models/classes/RdfObjectMapper/ServiceProvider/RdfObjectMapperServiceProvider.php b/models/classes/RdfObjectMapper/ServiceProvider/RdfObjectMapperServiceProvider.php new file mode 100644 index 0000000000..6b3fbe052a --- /dev/null +++ b/models/classes/RdfObjectMapper/ServiceProvider/RdfObjectMapperServiceProvider.php @@ -0,0 +1,54 @@ +services(); + + $services + ->set(ResourceHydrator::class, ResourceHydrator::class) + ->args( + [ + service(LoggerService::SERVICE_ID), + ] + ); + + $services + ->set(RdfObjectMapper::class, RdfObjectMapper::class) + ->args( + [ + service(LoggerService::SERVICE_ID), + service(ResourceHydrator::class), + ] + ) + ->public(); + } +} diff --git a/models/classes/user/UserSettingsServiceProvider.php b/models/classes/user/UserSettingsServiceProvider.php index 87aae5eb13..f23612dfcb 100644 --- a/models/classes/user/UserSettingsServiceProvider.php +++ b/models/classes/user/UserSettingsServiceProvider.php @@ -25,6 +25,7 @@ use oat\generis\model\data\Ontology; use oat\generis\model\DependencyInjection\ContainerServiceProviderInterface; use oat\oatbox\user\UserTimezoneServiceInterface; +use oat\tao\model\RdfObjectMapper\Service\RdfObjectMapper; use oat\tao\model\user\implementation\UserSettingsService; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use tao_models_classes_LanguageService; @@ -44,6 +45,7 @@ public function __invoke(ContainerConfigurator $configurator): void [ service(UserTimezoneServiceInterface::SERVICE_ID), service(Ontology::SERVICE_ID), + service(RdfObjectMapper::class) ] ); diff --git a/models/classes/user/implementation/UserSettingsService.php b/models/classes/user/implementation/UserSettingsService.php index 2424a8ece7..f78c27aabb 100644 --- a/models/classes/user/implementation/UserSettingsService.php +++ b/models/classes/user/implementation/UserSettingsService.php @@ -25,6 +25,9 @@ use oat\generis\model\data\Ontology; use oat\generis\model\GenerisRdf; use oat\oatbox\user\UserTimezoneServiceInterface; +use oat\tao\model\RdfObjectMapper\Contract\RdfObjectMapperInterface; +use oat\tao\model\RdfObjectMapper\RdfObjectMapper; +use oat\tao\model\RdfObjectMapper\Example\UserSettingsMappedType; use oat\tao\model\user\UserSettingsInterface; use oat\tao\model\user\UserSettingsServiceInterface; use core_kernel_classes_Resource; @@ -37,13 +40,25 @@ class UserSettingsService implements UserSettingsServiceInterface /** @var string */ private $defaultTimeZone; - public function __construct(UserTimezoneServiceInterface $userTimezoneService, Ontology $ontology) - { + /** @var RdfObjectMapperInterface */ + private $objectMapper; + + public function __construct( + UserTimezoneServiceInterface $userTimezoneService, + Ontology $ontology, + RdfObjectMapperInterface $objectMapper + ) { $this->defaultTimeZone = $userTimezoneService->getDefaultTimezone(); $this->ontology = $ontology; + $this->objectMapper = $objectMapper; } public function get(core_kernel_classes_Resource $user): UserSettingsInterface + { + return $this->objectMapper->mapResource($user, UserSettingsMappedType::class); + } + + public function previousGetImplementation(core_kernel_classes_Resource $user): UserSettingsInterface { $props = $user->getPropertiesValues( [