Skip to content

Commit

Permalink
Simplify field value normalization/typecast
Browse files Browse the repository at this point in the history
  • Loading branch information
mvorisek committed Oct 2, 2021
1 parent b06c578 commit 31b647c
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 175 deletions.
2 changes: 1 addition & 1 deletion bootstrap-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function convertToDatabaseValue($value, AbstractPlatform $platform): ?str
return (string) round((float) $value, 4);
}

public function convertToPHPValue($value, AbstractPlatform $platform): float
public function convertToPHPValue($value, AbstractPlatform $platform): ?float
{
$v = $this->convertToDatabaseValue($value, $platform);

Expand Down
120 changes: 67 additions & 53 deletions src/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,68 +93,96 @@ public function normalize($value)
return $value;
}

if ($value === null) {
if ($this->required/* known bug, see https://github.com/atk4/data/issues/575, fix in https://github.com/atk4/data/issues/576 || $this->mandatory*/) {
throw new ValidationException([$this->name => 'Must not be null'], $this->getOwner());
if (is_string($value)) {
switch ($this->type) {
case null:
case 'string':
$value = trim(str_replace(["\r", "\n"], '', $value)); // remove all line-ends and trim

break;
case 'text':
$value = trim(str_replace(["\r\n", "\r"], "\n", $value)); // normalize line-ends to LF and trim

break;
case 'integer':
$value = preg_replace('/\s+|[,`\']/', '', $value);

break;
case 'float':
case 'atk4_money':
$value = preg_replace('/\s+|[,`\'](?=.*\.)/', '', $value);

break;
}

return null;
switch ($this->type) {
case 'integer':
case 'float':
case 'atk4_money':
if ($value === '') {
$value = null;
} elseif (!is_numeric($value)) {
throw new ValidationException([$this->name => 'Must be numeric'], $this->getOwner());
}

break;
}
} elseif ($value !== null) {
switch ($this->type) {
case null:
case 'string':
case 'text':
case 'integer':
case 'float':
case 'atk4_money':
if (is_scalar($value)) {
$value = (string) $value;
} else {
throw new ValidationException([$this->name => 'Must be scalar'], $this->getOwner());
}

break;
}
}

// normalize using persistence typecasting
$persistence = $this->getOwner()->persistence
?? new class() extends Persistence {
public function __construct()
{
}
};
try {
$value = $persistence->typecastSaveField($this, $value);
$value = $persistence->typecastLoadField($this, $value);
} catch (\Exception $e) {
throw new ValidationException([$this->name => $e->getMessage()], $this->getOwner());
}

// only string type fields can use empty string as legit value, for all
// other field types empty value is the same as no-value, nothing or null
if ($this->type && $this->type !== 'string' && $value === '') {
if ($this->required && empty($value)) {
throw new ValidationException([$this->name => 'Must not be empty'], $this->getOwner());
if ($value === null) {
if ($this->required/* known bug, see https://github.com/atk4/data/issues/575, fix in https://github.com/atk4/data/issues/576 || $this->mandatory*/) {
throw new ValidationException([$this->name => 'Must not be null'], $this->getOwner());
}

return null;
}

// validate scalar values
if (in_array($this->type, [null, 'string', 'text', 'integer', 'float', 'atk4_money'], true)) {
if (!is_scalar($value)) {
throw new ValidationException([$this->name => 'Must use scalar value'], $this->getOwner());
}

$value = (string) $value;
if ($value === '' && $this->required) {
throw new ValidationException([$this->name => 'Must not be empty'], $this->getOwner());
}

// normalize
switch ($this->type) {
case null:
case 'string':
$value = trim(str_replace(["\r", "\n"], '', $value)); // remove all line-ends and trim
if ($this->required && empty($value)) {
throw new ValidationException([$this->name => 'Must not be empty'], $this->getOwner());
}

break;
case 'text':
$value = trim(str_replace(["\r\n", "\r"], "\n", $value)); // normalize line-ends to LF and trim
if ($this->required && empty($value)) {
throw new ValidationException([$this->name => 'Must not be empty'], $this->getOwner());
}

break;
case 'integer':
$value = preg_replace('/\s+|[,`\']/', '', $value);
if (!is_numeric($value)) {
throw new ValidationException([$this->name => 'Must be numeric'], $this->getOwner());
}
$value = (int) $value;
if ($this->required && empty($value)) {
throw new ValidationException([$this->name => 'Must not be a zero'], $this->getOwner());
}

break;
case 'float':
case 'atk4_money':
$value = preg_replace('/\s+|[,`\'](?=.*\.)/', '', $value);
if (!is_numeric($value)) {
throw new ValidationException([$this->name => 'Must be numeric'], $this->getOwner());
}
$value = $this->getTypeObject()->convertToPHPValue($value, new Persistence\GenericPlatform());
if ($this->required && empty($value)) {
throw new ValidationException([$this->name => 'Must not be a zero'], $this->getOwner());
}
Expand Down Expand Up @@ -184,20 +212,6 @@ public function normalize($value)
break;
}

// normalize using DBAL type
$persistence = $this->getOwner()->persistence
?? new class() extends Persistence {
public function __construct()
{
}
};
try {
$value = $persistence->typecastSaveField($this, $value);
$value = $persistence->typecastLoadField($this, $value);
} catch (\Exception $e) {
throw new ValidationException([$this->name => 'Invalid value: ' . $e->getMessage()], $this->getOwner());
}

return $value;
} catch (Exception $e) {
$e->addMoreInfo('field', $this);
Expand Down
187 changes: 68 additions & 119 deletions src/Persistence.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,12 +261,6 @@ public function typecastLoadField(Field $field, $value)
}

try {
// only string type fields can use empty string as legit value, for all
// other field types empty value is the same as no-value, nothing or null
if ($field->type && $field->type !== 'string' && $value === '') {
return null;
}

return $this->_typecastLoadField($field, $value);
} catch (\Exception $e) {
throw (new Exception('Unable to typecast field value on load', 0, $e))
Expand All @@ -284,54 +278,42 @@ public function typecastLoadField(Field $field, $value)
*/
protected function _typecastSaveField(Field $field, $value)
{
// work only on cloned value
$value = is_object($value) ? clone $value : $value;

switch ($field->type) {
case 'boolean':
// if enum is not set, then simply cast value to boolean
if (!is_array($field->enum)) {
$value = (bool) $value;
if (in_array($field->type, ['json', 'object']) && $value === '') { // TODO remove later
return null;
}

break;
}
if ($field->type === 'boolean' && is_array($field->enum)) {
if ($value === $field->enum[1]) {
$value = true;
} elseif ($value === $field->enum[0]) {
$value = false;
}

// if enum is set, first lets see if it matches one of those precisely
if ($value === $field->enum[1]) {
$value = true;
} elseif ($value === $field->enum[0]) {
$value = false;
}
$value = $value ? $field->enum[1] : $field->enum[0];
}

// finally, convert into appropriate value
$value = $value ? $field->enum[1] : $field->enum[0];

break;
case 'date':
case 'datetime':
case 'time':
if ($value instanceof \DateTimeInterface) {
$format = ['date' => 'Y-m-d', 'datetime' => 'Y-m-d H:i:s.u', 'time' => 'H:i:s.u'];
$format = $field->persist_format ?: $format[$field->type];

// datetime only - set to persisting timezone
if ($field->type === 'datetime' && isset($field->persist_timezone)) {
$value = new \DateTime($value->format('Y-m-d H:i:s.u'), $value->getTimezone());
$value->setTimezone(new \DateTimeZone($field->persist_timezone));
}
$value = $value->format($format);
// native DBAL DT types have no microseconds support
if (in_array($field->type, ['datetime', 'date', 'time'])
&& str_starts_with(get_class($field->getTypeObject()), 'Doctrine\DBAL\Types\\')) {
if ($value === '') {
return null;
} elseif (!$value instanceof \DateTimeInterface) {
throw new Exception('Must be instance of DateTimeInterface');
} else {
if ($field->type === 'datetime' && isset($field->persist_timezone)) {
$value = new \DateTime($value->format('Y-m-d H:i:s.u'), $value->getTimezone());
$value->setTimezone(new \DateTimeZone($field->persist_timezone));
}

break;
case 'array':
case 'object':
case 'json':
$value = $field->getTypeObject()->convertToDatabaseValue($value, $this->getDatabasePlatform()); // TODO typecast everything, not only this type
$formats = ['date' => 'Y-m-d', 'datetime' => 'Y-m-d H:i:s.u', 'time' => 'H:i:s.u'];
$format = $field->persist_format ?: $formats[$field->type];
$value = $value->format($format);
}

break;
return $value;
}

return $value;
return $field->getTypeObject()->convertToDatabaseValue($value, $this->getDatabasePlatform());
}

/**
Expand All @@ -344,88 +326,55 @@ protected function _typecastSaveField(Field $field, $value)
*/
protected function _typecastLoadField(Field $field, $value)
{
// work only on cloned value
$value = is_object($value) ? clone $value : $value;

switch ($field->type) {
case 'string':
case 'text':
// do nothing - it's ok as it is
break;
case 'integer':
$value = (int) $value;

break;
case 'float':
case 'atk4_money':
$value = (float) $value;

break;
case 'boolean':
if (is_array($field->enum)) {
if ($value === $field->enum[0]) {
$value = false;
} elseif ($value === $field->enum[1]) {
$value = true;
} else {
$value = null;
}
} elseif ($value === '') {
$value = null;
} else {
$value = (bool) $value;
}
// TODO casting optionally to null should be handled by type itself solely
if ($value === '' && in_array($field->type, ['boolean', 'integer', 'float', 'datetime', 'date', 'time', 'json', 'object'])) {
return null;
}

break;
case 'date':
case 'datetime':
case 'time':
$dt_class = \DateTime::class;
$tz_class = \DateTimeZone::class;
if ($field->type === 'boolean' && is_array($field->enum)) {
if ($value === $field->enum[1]) {
$value = true;
} elseif ($value === $field->enum[0]) {
$value = false;
}
}

// native DBAL DT types have no microseconds support
if (in_array($field->type, ['datetime', 'date', 'time'])
&& str_starts_with(get_class($field->getTypeObject()), 'Doctrine\DBAL\Types\\')) {
if ($field->persist_format) {
$format = $field->persist_format;
} else {
// ! symbol in date format is essential here to remove time part of DateTime - don't remove, this is not a bug
$format = ['date' => '+!Y-m-d', 'datetime' => '+!Y-m-d H:i:s', 'time' => '+!H:i:s'];
if ($field->persist_format) {
$format = $field->persist_format;
} else {
$format = $format[$field->type];
if (strpos($value, '.') !== false) { // time possibly with microseconds, otherwise invalid format
$format = preg_replace('~(?<=H:i:s)(?![. ]*u)~', '.u', $format);
}
}

// datetime only - set from persisting timezone
if ($field->type === 'datetime' && isset($field->persist_timezone)) {
$value = $dt_class::createFromFormat($format, $value, new $tz_class($field->persist_timezone));
if ($value !== false) {
$value->setTimezone(new $tz_class(date_default_timezone_get()));
}
} else {
$value = $dt_class::createFromFormat($format, $value);
}

if ($value === false) {
throw (new Exception('Incorrectly formatted date/time'))
->addMoreInfo('format', $format)
->addMoreInfo('value', $value)
->addMoreInfo('field', $field);
$formats = ['date' => '+!Y-m-d', 'datetime' => '+!Y-m-d H:i:s', 'time' => '+!H:i:s'];
$format = $formats[$field->type];
if (strpos($value, '.') !== false) { // time possibly with microseconds, otherwise invalid format
$format = preg_replace('~(?<=H:i:s)(?![. ]*u)~', '.u', $format);
}
}

// need to cast here because DateTime::createFromFormat returns DateTime object not $dt_class
// this is what Carbon::instance(DateTime $dt) method does for example
if ($dt_class !== \DateTime::class) { // @phpstan-ignore-line
$value = new $dt_class($value->format('Y-m-d H:i:s.u'), $value->getTimezone());
// datetime only - set from persisting timezone
$dtClass = \DateTime::class;
$tzClass = \DateTimeZone::class;
if ($field->type === 'datetime' && isset($field->persist_timezone)) {
$value = $dtClass::createFromFormat($format, $value, new $tzClass($field->persist_timezone));
if ($value !== false) {
$value->setTimezone(new $tzClass(date_default_timezone_get()));
}
} else {
$value = $dtClass::createFromFormat($format, $value);
}

break;
case 'array':
case 'object':
case 'json':
$value = $field->getTypeObject()->convertToPHPValue($value, $this->getDatabasePlatform()); // TODO typecast everything, not only this type
if ($value === false) {
throw (new Exception('Incorrectly formatted date/time'))
->addMoreInfo('format', $format)
->addMoreInfo('value', $value)
->addMoreInfo('field', $field);
}

break;
return $value;
}

return $value;
return $field->getTypeObject()->convertToPHPValue($value, $this->getDatabasePlatform());
}
}
Loading

0 comments on commit 31b647c

Please sign in to comment.