Home > Enterprise >  How to get column properties in Doctrine custom type convertToDatabaseValue function?
How to get column properties in Doctrine custom type convertToDatabaseValue function?

Time:04-03

As the title suggests, I am making my own Type in Doctrine and the type has its precision and scale options in getSQLDeclaration() function. I need somehow to access these from convertToDatabaseValue() as well, as I need to round the number with given precision.

<?php

namespace App\DBAL\Types;

use Decimal\Decimal;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\DecimalType as DoctrineDecimalType;

class DecimalType extends DoctrineDecimalType
{
    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
    {
        $column['precision'] = empty($column['precision'])
            ? 10 : $column['precision'];
        $column['scale']     = empty($column['scale'])
            ? 0 : $column['scale'];

        return 'DECIMAL(' . $column['precision'] . ', ' . $column['scale'] . ')' .
            (!empty($column['unsigned']) ? ' UNSIGNED' : '');
    }

    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        if (is_null($value)) {
            return null;
        }

        return new Decimal($value);
    }

    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if ($value instanceof Decimal) {
            // HERE!! This is the line where I need to specify precision based on column declaration
            return $value->toFixed(????);
        }

        return parent::convertToDatabaseValue($value, $platform); // TODO: Change the autogenerated stub
    }
}

So the Entity column then looks like:

    #[ORM\Column(type: 'decimal', precision: 10, scale: 4)]
    private Decimal $subtotal;

And I need to get the scale or precision part in the convertToDatabaseValue() function.

CodePudding user response:

As mentioned in my comments, what you're attempting to do is not supported. While there are methods of forcing a similar type of functionality, such as with lifecycle callbacks, they are considered extremely bad-practice as they point towards persistence issues.
The general principal is that Entities should work without relying on either the ORM or the Database layers, and implementing workarounds to force the behavior may result in breaks in your application.

Object Casting Example

Since you are relying on a separate library package that is not suitable to modify or to switch to a custom object.
Another approach is to utilize the default Doctrine decimal type specifications which works on string values and utilizing casting within the Entity instead. Circumventing the need for a custom DecimalType or workarounds and the entity would more easily be applicable to the rest of the Symfony framework without the need for additional workarounds.

3V4L Example

class Entity
{
    #[ORM\Column(type: 'decimal', precision: 10, scale: 4)]
    private string $subtotal = '0.0';

    // name as desired
    public function getSubtotalDecimal(): Decimal
    {
        return new Decimal($this->subtotal, 4);
    }

    public function getSubtotal(): string
    {
        return $this->subtotal;
    }

    // see Union Types
    public function setSubtotal(string|Decimal $subtotal): self
    {
        if ($subtotal instanceof Decimal) {
            $subtotal = $subtotal->toFixed(4);
        }
        $this->subtotal = $subtotal;

        return $this;
    }
}

Object Specification Formatting

Apply the same approach as other objects being supplied to the Entity, such as DateTime(string $datetime = "now", ?DateTimeZone $timezone = null). The object should contain the rules needed for formatting the value appropriately, so that the object can be converted to the expected string output to be sent to the database. In the case of a DateTime object, the time zone is not retrieved from the schema specification.

Note

This approach has the drawback of of convertToPHPValue() not being able to define the precision or scale arguments based on the ORM\Column specifications. This is the same case as DateTime.

class Decimal
{
    public function __construct(
        private string $value, 
        private int $precision = null,
        private int $scale = null
    ) {
        $this->value = $value; 
        $this->precision = $precision;
        $this->scale = $scale;
    }

    public function getValue(): string
    {
        return $value;
    }

    public function toFixed(): string
    {
        // rough example for adjusting precision - do not use
        return bcadd($this->value, '0', $this->scale);
    }
}
    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if ($value instanceof Decimal) {
            return $value->toFixed();
        }

        return parent::convertToDatabaseValue($value, $platform);
    }
$entity = new Entity();
$entity->setSubtotal(new Decimal('1337.987654321', 10, 4));

Value Specification Formatting

In practice the value supplied to the Entity should be formatted as desired based on the schema rules, exactly as one would when supplying values to a native SQL query.
This ensures the integrity and consistency of the Entity data is maintained throughout the application and the underlying data that each Entity holds is always valid, without requiring the ORM or database layers to "fix" the data.

3V4L example

$entity = new Entity();
$entity->setSubtotal((new Decimal('1337.987654321'))->toFixed(4));

echo $entity->getSubtotal()->getValue(); // 1337.9876
class Decimal 
{
     private string $value;

     public function __construct(string $value)
     {
          $this->value = $value;
     }

     public function getValue(): string
     {
          return $this->value;
     }

     public function toFixed(int $scale): Decimal
     {
         // rough example for adjusting precision - do not use
         return new self(bcadd($this->value, '0', $scale));
     }
}
 public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if ($value instanceof Decimal) {
            return $value->getValue();
        }

        return parent::convertToDatabaseValue($value, $platform);
    }

In contrast to the example above, when relying on the database layer, the data would be invalid until after $em->flush(); was called to "fix" the value format based on the ORM specifications. Whereby calling $entity->getSubtotal()->getValue(); prior to $em->flush() would instead return 1337.987654321 which does not conform to the column specifications, causing business logic relying on the value to become broken.


Entity Level Value Formatting

An alternative approach is to use the Entity setters to apply the desired data formatting rules for the supplied value that aligns with the column schema data-type specifications.

However, using the Entity setters for validating/formatting the data is generally perceived as a bad-practice for a variety of reasons. Instead it is recommended to use a DTO (data-transfer object) to perform validation and formatting of the data prior to being applied to the Entity (see DTO Example below).

3V4L Example

class Entity
{
    #[ORM\Column(type: 'decimal', precision: 10, scale: 4)]
    private Decimal $subtotal;
    
    public function getSubtotal()
    {
        return $this->subtotal;
    }
    
    public function setSubtotal(Decimal $value)
    {
        $this->subtotal = $value->toFixed(4);
        
        return $this;
    }
}
$entity = new Entity();
$entity->setSubtotal(new Decimal('1337.987654321'));

echo $entity->getSubTotal()->getValue(); // 1337.9876

DTO Example

Basic usage of Symfony generally allows for manipulation of the entities directly, such as when using Forms and EntityType fields. However, with the principal that the Entities are always valid, there should not be a need to validate the Entity data that was injected into it by the Form containing user-supplied data.

To prevent invalid data within the Entity and promote separations of concerns, a DTO can be used to structure the data based on the immediate concerns for what that data is being used for.

To make DTOs easier to utilize, the Doctrine ORM supports the generation of DTOs, which can be used as the model in Forms.
It is also important to note that Doctrine has deprecated partial objects support in the future in favor of using a DTO for partial objects [sic].

class OrderSubtotalDTO
{
    private string $company;
    private string $subtotal;

    public function __construct(string $company, string $subtotal)
    {
        $this->company = $company;
        $this->subtotal = $subtotal;
    }

     // ...

    public function setSubtotal(string $value): self
    {
        $this->subtotal = $value;

        return $this;
    }

    public function apply(Entity $entity): self
    {
        // apply the formatting being supplied to the entity
        $entity->setDecimal((new Decimal($this->subtotal))->toFixed(4));

        return $this;
    }
}
// use a Query service to reduce code duplication - this is for demonstration only
$q = $em->createQuery('SELECT NEW OrderSubtotalDTO(e.company, e.subtotal) FROM Entity e WHERE e.id = ?1');
$q->setParameter(1, $entity->getId());
$orderSubtotalDTO = $q->getOneOrNull();

$form = $formFactory->create(OrderSubtotalForm::class, $orderSubtotalDTO);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
    $orderSubtotalDTO->apply($entity);
    $em->flush();
}
  • Related