Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize creation from constructor #186

Merged
merged 1 commit into from
Oct 5, 2024

Conversation

MrMeshok
Copy link
Contributor

@MrMeshok MrMeshok commented Sep 13, 2024

When object gets created from constructor, there are always checks for constructor arguments in context. This eats some of performance, especially if you don't use context. When looking at the generated code, I noticed that it can be optimized a little.
I didn't benchmark it a lot, but I did see around 10% more performance with this optimization

Comparisons

Argument from source

Before:

if (\AutoMapper\MapperContext::hasConstructorArgument($context, 'Dto', 'propertyName')) {
    $constructarg = $source->propertyName ?? \AutoMapper\MapperContext::getConstructorArgument($context, 'Dto', 'propertyName');
} else {
    $constructarg = $source->propertyName ?? throw new \AutoMapper\Exception\MissingConstructorArgumentsException('Cannot create an instance ...');
}

After:

$constructarg = $source->propertyName ?? (
    \AutoMapper\MapperContext::hasConstructorArgument($context, 'Dto', 'propertyName')
        ? \AutoMapper\MapperContext::getConstructorArgument($context, 'Dto', 'propertyName')
        : throw new \AutoMapper\Exception\MissingConstructorArgumentsException('Cannot create an instance ...')
);

Argument from source with array transformer

Before:

if (\AutoMapper\MapperContext::hasConstructorArgument($context, 'Dto', 'propertyName')) {
    $values = [];
    foreach ($value['propertyName'] ?? [] as $key => $value) {
        $values[$key] = $value;
    }
    $constructarg = count($values) > 0 ? $values : \AutoMapper\MapperContext::getConstructorArgument($context, 'Dto', 'propertyName');
} else {
    $values = [];
    foreach ($value['propertyName'] ?? [] as $key => $value) {
        $values[$key] = $value;
    }
    $constructarg = count($values) > 0 ? $values : throw new \AutoMapper\Exception\MissingConstructorArgumentsException('Cannot create an instance');
}

After:

$values = [];
foreach ($value['propertyName'] ?? [] as $key => $value) {
    $values[$key] = $value;
}
$constructarg = count($values) > 0
    ? $values
    : (\AutoMapper\MapperContext::hasConstructorArgument($context, 'Dto', 'propertyName')
        ? \AutoMapper\MapperContext::getConstructorArgument($context, 'Dto', 'propertyName')
        : throw new \AutoMapper\Exception\MissingConstructorArgumentsException('Cannot create an instance')
    );

Argument without source

Before and After here logically the same, I just changed it so code would be consistent with arguments from source.

Before:

if (\AutoMapper\MapperContext::hasConstructorArgument($context, 'Dto', 'propertyName')) {
    $constructarg = \AutoMapper\MapperContext::getConstructorArgument($context, 'Dto', 'propertyName');
} else {
    throw new \AutoMapper\Exception\MissingConstructorArgumentsException('Cannot create an instance');
}

After:

$constructarg = \AutoMapper\MapperContext::hasConstructorArgument($context, 'Dto', 'propertyName')
    ? \AutoMapper\MapperContext::getConstructorArgument($context, 'Dto', 'propertyName')
    : throw new \AutoMapper\Exception\MissingConstructorArgumentsException('Cannot create an instance');

@MrMeshok
Copy link
Contributor Author

I also thought about removing hasConstructorArgument check because just calling getConstructorArgument would be faster, but that's technically changing this behavior:

readonly class Dto
{
    public function __construct(
        public ?string $optionalNullableString = 'default'
    ) {}
}

$data = [];
$object = $autoMapper->map($data, Dto::class, [MapperContext::CONSTRUCTOR_ARGUMENTS => [
    Dto::class => ['optionalNullableString' => null]
]]);

$object->optionalNullableString === null; // with hasConstructorArgument check
$object->optionalNullableString === 'default'; // without hasConstructorArgument check

Maybe there should be a test for this behavior🤔. I can add it to this PR

@MrMeshok MrMeshok force-pushed the optimize-creation-from-constructor branch from f425123 to 94377bf Compare September 13, 2024 12:11
@nikophil
Copy link
Collaborator

Hi @MrMeshok

nice PR, very good idea!

I also thought about removing hasConstructorArgument check because just calling getConstructorArgument would be faster, but that's technically changing this behavior

won't that even create an error if you have this kind of Dto, and using null as a constructor argument?

readonly class Dto
{
    public function __construct(
        public ?string $optionalNullableString
    ) {}
}

if we want to allow null as a constructor argument, I think we need to keep the call to array_key_exists(), WDYT?

Maybe there should be a test for this behavior

this should definitely be tested, maybe in the version without the default value in the argument

@MrMeshok
Copy link
Contributor Author

won't that even create an error if you have this kind of Dto, and using null as a constructor argument?

readonly class Dto
{
    public function __construct(
        public ?string $optionalNullableString
    ) {}
}

Just tested it, it's just creates Dto with optionalNullableString as null, because if there's no default argument and argument is nullable, AutoMapper will assign null as fallback value. So it's kinda looks like this

$constructarg = $source['propertyName'] 
    ?? \AutoMapper\MapperContext::getConstructorArgument($context, 'Dto', 'propertyName') // returns null
    ?? null;

You can emulate that happens if hasConstructorArgument check gets removed by replacing array_key_exists by isset in method implementation

public static function hasConstructorArgument(array $context, string $class, string $key): bool
{
    return isset($context[self::CONSTRUCTOR_ARGUMENTS][$class][$key]);
    // return \array_key_exists($key, $context[self::CONSTRUCTOR_ARGUMENTS][$class] ?? []);
}

I think we need to keep the call to array_key_exists(), WDYT?

I personally think that's a very niche feature, and I had to think very hard to come up with an example where it could matter.

@MrMeshok
Copy link
Contributor Author

I thought about it over the weekend, there is a way to use array_key_exists and isset/?? simultaneously.
When creating a mapper, we can check whether the property accepts null, if it does, we use array_key_exists, if not isset/??.
Thus we preserve null values where it matters and use more performant code where it doesn't. I will work on it in another PR when I have time 

@Korbeil Korbeil merged commit 33fa4aa into jolicode:main Oct 5, 2024
5 checks passed
@MrMeshok MrMeshok deleted the optimize-creation-from-constructor branch October 7, 2024 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants