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.
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 theprecision
orscale
arguments based on theORM\Column
specifications. This is the same case asDateTime
.
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.
$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).
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();
}