diff --git a/bootstrap-types.php b/bootstrap-types.php index 3d32870373..73d9a58e15 100644 --- a/bootstrap-types.php +++ b/bootstrap-types.php @@ -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); diff --git a/src/Field.php b/src/Field.php index 7931db679c..7968575b8e 100644 --- a/src/Field.php +++ b/src/Field.php @@ -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()); } @@ -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); diff --git a/src/Persistence.php b/src/Persistence.php index d66eb7cd28..7ad1d6bf80 100644 --- a/src/Persistence.php +++ b/src/Persistence.php @@ -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)) @@ -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()); } /** @@ -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()); } } diff --git a/tests/ContainsManyTest.php b/tests/ContainsManyTest.php index 7c767df853..9c6030afa8 100644 --- a/tests/ContainsManyTest.php +++ b/tests/ContainsManyTest.php @@ -267,7 +267,7 @@ public function testNestedContainsMany(): void 1 => [ $i->lines->fieldName()->id => 1, $i->lines->fieldName()->vat_rate_id => 1, - $i->lines->fieldName()->price => 10, + $i->lines->fieldName()->price => '10', $i->lines->fieldName()->qty => 2, $i->lines->fieldName()->add_date => $formatDtForCompareFunc(new \DateTime('2019-06-01')), $i->lines->fieldName()->discounts => json_encode([ @@ -286,7 +286,7 @@ public function testNestedContainsMany(): void 2 => [ $i->lines->fieldName()->id => 2, $i->lines->fieldName()->vat_rate_id => 2, - $i->lines->fieldName()->price => 15, + $i->lines->fieldName()->price => '15', $i->lines->fieldName()->qty => 5, $i->lines->fieldName()->add_date => $formatDtForCompareFunc(new \DateTime('2019-07-01')), $i->lines->fieldName()->discounts => json_encode([