Story #12862

As Roger, I want a clean Property Mapper

Added by Sebastian Kurfuerst almost 11 years ago. Updated over 10 years ago.

Status:
Resolved
Priority:
Should have
Category:
-
Target version:
Start date:
Due date:
% Done:

67%

Estimated time:
(Total: 0.00 h)

Description

Goal

The property mapper maps simple types to Objects. (Hence, we (for now) drop the Object to Object support, although this could be added later)

The Property Mapper maps all values present in $source to $target (or a subset thereof) (right now, it requires a list of properties to be mapped, and if $source[$propertyName] does not exist, an error is thrown)

  • We try to strip down the Property Mapper, and make it extensible instead.

Wanted Features

  • Support for Object Converters:
    • array / ArrayObject / SplObjectStorage
    • DateTime use case
    • File Upload (Resource objects)
  • Partial Validation should be supported
  • Property Merging should be supported (pass in existing object as base-object)
  • Better error handling in case target property is not found (right now, NULL returned)
  • Mapping should be configurable
  • Secure by default

Basic Interface

map(mixed $source, string $targetType, PropertyMappingConfigurationInterface $propertyMappingConfiguration = NULL)
  • $source can be:
    • all simple values (integer, string, ...)
    • ARRAY
    • an Object implementing ArrayAccess
    • We will drop support for arbitary objects through ObjectAccess::isPropertyGettable (for now)
    • else EXCEPTION
  • $target is the classname on which the value should be mapped to (or a simple type)

Pseudocode:

map(...) {
    if ($propertyMappingConfiguration === NULL) {
        $propertyMappingConfiguration = $this->buildDefaultPropertyMappingConfiguration();
    }
    $propertyMappingConfiguration->setTargetType($targetType); // Just a convenience shorthand method
    $this->doMapping(....);
}

protected function doMapping(mixed $source, $propertyMappingConfiguration) {
    $typeConverter = $this->somehowDetermineTypeConverter(); // TODO, see below

    $object = $typeConverter->convertFrom($source, $propertyMappingConfiguration);

    foreach ($typeConverter->getPropertyNames($source) as $sourcePropertyName) {
        $targetPropertyName = $propertyMappingConfiguration->getTargetPropertyName($sourcePropertyName);
        if (!$propertyMappingConfiguration->shouldMap($targetPropertyName)) continue;

        $mappedPropertyValue = $this->doMapping($source[$sourcePropertyName], $propertyMappingConfiguration->getConfigurationFor($targetPropertyName));

        $typeConverter->setResultInTarget($target, $targetPropertyName, $mappedPropertyValue);
    }
    return $object;
}

  • The property mapper only delegates the actual mapping to Type Converters.
  • Type converters expose the following information:
    • Applicable source types
    • The target type the converter can convert to
    • A priority
  • The used type converter is determined as follows:
    • If a TypeConverter is set inside the current propertyMappingConfiguration, this one is taken.
    • Else, we do the following:
      $typeConvertersWhichCanHandleSourceType = // first, we pick all TypeConverters
              // which can handle $sourceType, and put it into an
              // associative array where KEY is the target class name of the converter
      $classNamesInInheritanceHierarchy = // $targetType combined with all superclasses
              // and interfaces, ordered from the inside out (so the $targetType
              // is the first element in the list)
      
      foreach ($classNamesInInheritanceHierarchy as $singleTargetClassName) {
          if (isset($typeConvertersWhichCanHandleSourceType[$singleTargetClassName])) {
              return the type converter from $typeConvertersWhichCanHandleSourceType[$singleTargetClassName] with highest priority
          }
      }
      // Exception if no type converter found
      
  • Type Converters have to adhere to the following interface:
    public function convertFrom($source, $propertyMappingConfiguration);
    public function getPropertyNames($source);
    public function setMappingResultInTarget($target, $propertyName, $mappingResult);
    
  • In case a type converter does not want to handle a certain element (for whatever reason), he can throw an DontWantToHandleThisInputException inside convertFrom. Then, the next Type converter according to the above rules is taken.
  • Furthermore, the Property Mapper can handle renaming of properties, see the method getTargetPropertyName in the PropertyMappingConfigurationInterface

PropertyMappingConfiguration

interface PropertyMappingConfigurationInterface {

    /**
     * @return string
     */
    public function getTargetType();

    /**
     * @return TRUE if the given propertyName should be mapped
     */
    public function shouldMap($propertyName);

    /**
     * @return PropertyMappingConfigurationInterface the property mapping configuration for the given PropertyName.
     */
    public function getConfigurationFor($propertyName);

    /**
     * @return string property name of target; can be used to rename properties from source to target.
     */
    public function getTargetPropertyName($sourcePropertyName);

    /**
     * @return mixed configuration value for the specific $typeConverterClassName. Can be used by Type Converters to fetch converter-specific configuration
     */
    public function getConfigurationValue($typeConverterClassName, $key);
}

This interface will have an implementing class "PropertyMappingConfiguration", looking like the following:

class PropertyMappingConfiguration implements PropertyMappingConfigurationInterface {
    public function setTargetType($type);
    public function setMapping($sourcePropertyName, $targetPropertyName);
    public function setConfigurationValue($typeConverter, $key, $value);

    /**
     * Returns the default configuration for all sub-objects, ready to be modified
     */
    public function defaultSubConfiguration();

    /**
     * Returns the configuration for the specific property path, ready to be modified
     */
    public function at($propertyPath);

}

This API can then be used as follows:

  • To globally disable the modification of entities by the property mapper:
    $propertyMappingConfiguration->setConfigurationValue('F3....\EntityConverter', 'modificationAllowed', FALSE);
  • To disable the modification of sub-entities of the root element:
    $propertyMappingConfiguration->defaultSubConfiguration()->setConfigurationValue('F3....\EntityConverter', 'modificationAllowed', FALSE);
  • To enable the creation of sub-objects inside a certain element:
    $propertyMappingConfiguration->at('subobject1.subsubobject2')->setConfigurationValue('F3....\EntityConverter', 'creationAllowed', TRUE);

Furthermore, there will be a subclass ControllerPropertyMappingConfiguration, which has some convenience methods implemented such that people can program against a type-safe API:

class ControllerPropertyMappingConfiguration extends PropertyMappingConfiguration {
    public function allowCreationOfSubObjectsAt($position) {
        $this->at($position)->setConfigurationValue('F3....\EntityConverter', 'creationAllowed', TRUE);
    }
    public function allowModificationOfSubObjectsAt($position) {
        $this->at($position)->setConfigurationValue('F3....\EntityConverter', 'modificationAllowed', TRUE);
    }
}

Default Property Mapping Configuration

  • The default property mapping configuration for the top level should configure the Entity Type Converter to modify and create new objects
  • The property mapping configuration further down the hierarchy should configure the Entity Type Converter to not modify and not create objects.
  • The above two things are safety precautions for the user.
  • This boils down to the following code:
 $propertyMappingConfiguration->setConfigurationValue('F3....\EntityConverter', 'modificationAllowed', TRUE);
 $propertyMappingConfiguration->defaultSubConfiguration()->setConfigurationValue('F3....\EntityConverter', 'modificationAllowed', FALSE);

Type Converter examples

Here follow some examples of type converters, to see the concept more easily:

Entity Type Converter

  • Input Type: String and Array
  • Output Type: Object
  • Priority: low
public function convertFrom($source, $propertyMappingConfiguration) {
    if (is_string($source)) {
        if (/* is UUID */) {
            // create and return object
        } else {
            // Exception
        }
    }

    if (isset($source['__identity'])) {
        $target = // fetch from persistence
        if (count($source) > 1) {
            if ($propertyMappingConfiguration->getConfigurationValue('F3\....\EntityConverter', 'modificationAllowed') !== TRUE) {
                // throw exception
            }
            $target = clone $target;
        }
    } else {
        if ($propertyMappingConfiguration->getConfigurationValue('F3\....\EntityConverter', 'creationAllowed') !== TRUE) {
            // throw exception
        }
        $target = // new
    }

    return $target;
}

public function getPropertyNames($source) {
        if (is_string($source)) return array();
        return all $source property names, *except* __identity;
}

public function setMappingResultInTarget($target, $propertyName, $mappingResult) {
        // Set Property through Object Access
}

Date Type Converter

  • Input Type: String and Array
  • Output Type: DateTime
  • Priority: low
public function convertFrom($source, $propertyMappingConfiguration) {
    new DateTime($source); // or more advanced...
};
public function getPropertyNames() {
    return array();
}
public function setMappingResultInTarget($target, $propertyName, $mappingResult) {
    // empty, as it is never called
}

SplObjectStorage/ArrayCollection Type Converter

  • Input Type: Array
  • Output Type: SplObjectStorage
  • Priority: low
public function convertFrom($source, $propertyMappingConfiguration) {
    return new \SplObjectStorage();
};
public function getPropertyNames($source) {
    return array_keys($source);
}

public function setMappingResultInTarget($target, $propertyName, $mappingResult) {
    $target->attach($value);
}

Error Handling

  • Instead of outputting human-readable error messages, the property mapper will throw exceptions in case of errors. For things like mandatory properties, specific validators should be used.

Validation / Partial Validation

  • The property mapper will not execute any validators for the given objects.
  • However, it should return a list of mapped properties. This list can be used later, such that the ObjectValidator only needs to validate changed properties.

Naming

Object Converters will be superseded by this concept, and thrown away.

We suggest to change the naming in the following way:

  • PropertyMapper -> TypeMapper / ObjectMapper?
  • Object Converter -> Type Converter

Further work

  • This concept could be extended lateron to work with arbitrary input objects, not just simple types. However, one would then need a way to deal with object inheritance hierarchies on the source side as well.
  • Extend the concept such that Half-ready objects as $target are also supported; but we would use an explicit new API function for this:
    merge(mixed $source, object $targetObject, PropertyMappingConfigurationInterface $propertyMappingConfiguration = NULL)
    

Subtasks

Task #13943: Restructured MVC Error Handling ResolvedSebastian Kurfuerst

Actions
Task #13942: Restructured Property MapperResolvedSebastian Kurfuerst

Actions
Task #13161: We also need some better Error / Warning / Notice handlingResolved

Actions
#1

Updated by Sebastian Kurfuerst almost 11 years ago

  • translation missing: en.field_position deleted (1)
  • translation missing: en.field_position set to 15
#2

Updated by Sebastian Kurfuerst almost 11 years ago

  • Assignee set to Sebastian Kurfuerst
#3

Updated by Peter Niederlag almost 11 years ago

Date Type Converter

  • As Mr. Glue I want it to call the constructor of my Extension class if I have a property that has a type that extends DateTime()

Float Type Converter

  • As Mr. Glue I want to influence what format the input must have
#4

Updated by Sebastian Kurfuerst almost 11 years ago

Hey Peter,

thanks for your input; both cases would be possible with the new concept:

Date Type Converter

  • As Mr. Glue I want it to call the constructor of my Extension class if I have a property that has a type that extends DateTime()

This would be done differently with the new concept: You'd create your custom TypeConverter, which can convert to your extended DateTime object. This converter is then automatically used.

Float Type Converter

  • As Mr. Glue I want to influence what format the input must have

A Float Type Converter could be configurable through the PropertyMappingConfiguration, so this should also be possible.

#5

Updated by Sebastian Kurfuerst almost 11 years ago

I just updated the concept after discussions with Andi and Christian.

#6

Updated by Karsten Dambekalns almost 11 years ago

Some comments:
  • seeing object-to-object go makes me uneasy, even if it's only "for now".
  • map() without a target object is actually convert()
    • make map() accept a target object, allow NULL to indicate "convert" mode as a special case
    • or call it convert() and add merge() as suggested
  • the pseudo code to determine the matching type converter ignores the priority
  • there seems to be no way to configure the mapping in a way like "allow mapping for all properties except ..."
  • what does PropertyMappingConfiguration::setMapping() do?
  • setMappingResultInTarget() would use a domain model instance as a transfer object for infrastructure metadata...
    • no, it is actually badly named and would put the conversion result in the new object, aha...
    • the SplObjectStorage-example is bad then, because it only ever attaches one object.

On the naming of PropertyMapper - if anything, it now is an ObjectCreator, eyh? If merge() is added, it suddenly becomes a TypeConverter though, thus we have a name clash already. In the end, though, that's what it is: it converts stuff from array to SomeObject according to some configuration...

#7

Updated by Bastian Waidelich almost 11 years ago

- Improve error messages
- unschön: dass man für new/edit action auch eine initialize*Action braucht
- Idee mit Robert diskutiert:
- Parameter bei "newAction" weglassen (können)
- Momentaner Ablauf
Validierungsfehler / Property Mapping Fehler
errorAction
- > packt argument errors in request
- > forward zur letzten Action, ABER mit Argumenten der aktuellen Action (HACK) -- dabei bleiben Errors erhalten (HACK!)
letzte Action (die das Formular anzeigt) aus referrer
- > Argumente werden nochmal gemappt, und "invalides" Domänen-Objekt gebaut
- > ABER dontvalidate verhindert, dass Property Validatoren gebaut werden (UNSCHÖN, greift nicht für Property Mapping errors)
- > "invalides" domänenobjekt geht zum View -- > Basis für Formular
- > Form ViewHelper geht in REQUEST- >getErrors(), um herauszufinden dass es Fehler gab (HACK)
- Vorgeschlagener Ablauf
Validierungsfehler / Property Mapping Fehler
errorAction
- > Fehler die es gab müssen irgendwo gespeichert werden (Error\Result) (im AbstractRequest); außerdem muss der "Original"-Request gespeichert werden
- > forward zur letzten Action, mit Argumenten der letzten Action (die auch im Referrer gespeichert sein müssen)
letzte Action (die das Formular anzeigt) aus referrer
- > Genau gleicher Kontrollfluss wie im "nicht-fehler-Fall"
- > View wird "normal" aufgerufen
- > Form ViewHelper nutzt Request->originalRequest->arguments zum Vor-Ausfüllen des Formulares (mit Fallback zum übergebenen Objekt)
- > Form ViewHelper nutzt Request->originalRequest->errors um herauszufinden, welche Formularfelder fehlerhaft sind (und kann die dann umrahmen / Fehler darstellen, ...)

/// VORALLEM WICHTIG FÜR EXTBASE:
protected function mapRequestArgumentsToControllerArguments() { foreach ($this->arguments as $argument) { $argumentName = $argument->getName(); // in EDITACTION request -- "originale" Request der Edit-Action (unmodifizierte Argumente) // originalRequest -- Request der "update"-Action if (compatible with extbase 1.3 AND $this->request->originalRequest->hasArgument($argumentName) { $argument->setValue($this->request->getArgument($argumentName)); } elseif ($this->request->hasArgument($argumentName)) { $argument->setValue($this->request->getArgument($argumentName)); } elseif ($argument->isRequired()) { throw new \F3\FLOW3\MVC\Exception\RequiredArgumentMissingException('Required argument "' . $argumentName . '" is not set.', 1298012500); } } }

TODO: dontvalidate würde NICHT mehr im ValidatorResolver aufgelöst werden, sondern im ActionController::callActionMethod passieren (wo zwischen ErrorAction und normaler Action unterschieden wird) - > TODO: annotation umbenennen? @ignoreValidationErrors

TODO Berlin: discuss Namespaces // Annotation Processing // Schema
@validation.ignoreErrors

TODO: OriginalRequest in AbstractRequest hinzufügen

TODO: Error\Result sollte für alle Arguments gelten
-- > Arguments::haveErrors kann weggehauen werden; und Arguments::getValidationErrors umbennenen in Arguments::getMappingResult sollte Error\Result OBJEKT zurückgeben (und keinen Array) -- > Arguments::haveErrors() - > Arguments::getMappingResult()->hasErrors()

TODO: Abwärtskompatibilität -- wenn newAction erwartet Post, dann
- > In Extbase Compatibilitätsflg einführen:
- > tx_myext.extbaseCompatibilityVersion = 1.3
- > PropertyMapper & Validierung wie bisher

"neuer" property mapper in Proprety\PropertyMapper
"alter" PM Property\DeprecatedPM

Property\Mapper {
map($1, $2, $3, ...) {
if (Extbase 1.3) {
- > alter PM->map()
} else {
- > neuer PM->map()
}
}

TODO: SubRequest

public function new(Post $post = NULL) { if ($post != NULL) { $post->setBla('foo'); } $this->view->assign('post', $post); } public function create(...Post $post) { } public function edit(Post); public function update(Post) { }
#8

Updated by Robert Lemke over 10 years ago

  • Project changed from 529 to Base Distribution
  • Target version deleted (788)
#9

Updated by Robert Lemke over 10 years ago

  • Target version set to 1228
  • translation missing: en.field_position deleted (17837)
  • translation missing: en.field_position set to 4
#10

Updated by Robert Lemke over 10 years ago

  • Project changed from Base Distribution to TYPO3 Flow Base Distribution
  • Target version deleted (1228)
#11

Updated by Robert Lemke over 10 years ago

  • Target version set to 1.0 beta 1
  • translation missing: en.field_position deleted (4)
  • translation missing: en.field_position set to 3
#12

Updated by Sebastian Kurfuerst over 10 years ago

  • Status changed from New to Resolved

done.

Also available in: Atom PDF