src/Field.php
<?php
declare(strict_types=1);
namespace Atk4\Data;
use Atk4\Core\DiContainerTrait;
use Atk4\Core\ReadableCaptionTrait;
use Atk4\Core\TrackableTrait;
use Atk4\Data\Model\Scope;
use Atk4\Data\Persistence\Sql\Expression;
use Atk4\Data\Persistence\Sql\Expressionable;
use Doctrine\DBAL\Types\Type;
/**
* @method Model getOwner()
*/
class Field implements Expressionable
{
use DiContainerTrait {
setDefaults as private _setDefaults;
}
use Model\FieldPropertiesTrait;
use Model\JoinLinkTrait;
use ReadableCaptionTrait;
use TrackableTrait {
setOwner as private _setOwner;
}
private static Persistence $genericPersistence;
// {{{ Core functionality
/**
* @param array<string, mixed> $defaults
*/
public function __construct(array $defaults = [])
{
$this->setDefaults($defaults);
if (($this->type ?? null) === null) {
$this->type = 'string';
}
}
/**
* @param Model $owner
*
* @return $this
*/
public function setOwner(object $owner)
{
$owner->assertIsModel();
return $this->_setOwner($owner);
}
/**
* @param array<string, mixed> $properties
*/
public function setDefaults(array $properties, bool $passively = false): self
{
$this->_setDefaults($properties, $passively);
// assert type exists
if (isset($properties['type'])) {
Type::getType($this->type);
}
return $this;
}
/**
* @param \Closure<T of Model>(T, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): mixed $fx
* @param array<int, mixed> $args
*/
protected function onHookToOwnerEntity(string $spot, \Closure $fx, array $args = [], int $priority = 5): int
{
$name = $this->shortName; // use static function to allow this object to be GCed
return $this->getOwner()->onHookDynamic(
$spot,
static function (Model $entity) use ($name): self {
$obj = $entity->getModel()->getField($name);
$entity->assertIsEntity($obj->getOwner());
return $obj;
},
$fx,
$args,
$priority
);
}
/**
* @param mixed $value
*
* @return mixed
*/
private function normalizeUsingTypecast($value)
{
$persistence = $this->issetOwner() && $this->getOwner()->issetPersistence()
? $this->getOwner()->getPersistence()
: $this->getGenericPersistence();
$persistenceSetSkipNormalizeFx = \Closure::bind(static function (bool $value) use ($persistence) {
$persistence->typecastSaveSkipNormalize = $value;
}, null, Persistence::class);
$persistenceSetSkipNormalizeFx(true); // prevent recursion
try {
$value = $persistence->typecastSaveField($this, $value);
} finally {
$persistenceSetSkipNormalizeFx(false);
}
$value = $persistence->typecastLoadField($this, $value);
return $value;
}
/**
* Depending on the type of a current field, this will perform
* some normalization for strict types. This method must also make
* sure that $f->required is respected when setting the value, e.g.
* you can't set value to '' if type=string and required=true.
*
* @param mixed $value
*
* @return mixed
*/
public function normalize($value)
{
try {
if ($this->issetOwner() && $this->getOwner()->hook(Model::HOOK_NORMALIZE, [$this, $value]) === false) {
return $value;
}
if (is_string($value)) {
switch ($this->type) {
case 'string':
$value = trim(preg_replace('~\r?\n|\r|\s~', ' ', $value)); // remove all line-ends and trim
break;
case 'text':
$value = rtrim(preg_replace('~\r?\n|\r~', "\n", $value)); // normalize line-ends to LF and rtrim
break;
}
}
$value = $this->normalizeUsingTypecast($value);
if ($value === null) {
if ($this->required) {
throw new Exception('Must not be empty');
} elseif (!$this->nullable) {
throw new Exception('Must not be null');
}
return null;
}
if ($value === '' && $this->required) {
throw new Exception('Must not be empty');
}
switch ($this->type) {
case 'string':
case 'text':
if ($this->required && !$value) {
throw new Exception('Must not be empty');
}
break;
case 'boolean':
if ($this->required && !$value) {
throw new Exception('Must be true');
}
break;
case 'smallint':
case 'integer':
case 'bigint':
case 'float':
case 'decimal':
case 'atk4_money':
if ($this->required && !$value) {
throw new Exception('Must not be a zero');
}
break;
case 'date':
case 'datetime':
case 'time':
if (!$value instanceof \DateTimeInterface) {
throw new Exception('Must be an instance of DateTimeInterface');
}
break;
case 'json':
if (!is_array($value)) {
throw new Exception('Must be an array');
}
break;
case 'object':
if (!is_object($value)) {
throw new Exception('Must be an object');
}
break;
}
if ($this->enum) {
if ($value === '') {
$value = null;
} elseif (!in_array($value, $this->enum, true)) {
throw new Exception('Value is not one of the allowed values: ' . implode(', ', $this->enum));
}
} elseif ($this->values) {
if ($value === '') {
$value = null;
} elseif ((!is_string($value) && !is_int($value)) || !isset($this->values[$value])) {
throw new Exception('Value is not one of the allowed values: ' . implode(', ', array_keys($this->values)));
}
}
return $value;
} catch (\Exception $e) {
if ($e instanceof \ErrorException) {
throw $e;
}
if ($e->getPrevious() !== null && $e instanceof Exception && $e->getMessage() === 'Typecast save error') {
$e = $e->getPrevious();
}
$messages = [];
do {
$messages[] = $e->getMessage();
} while ($e = $e->getPrevious());
throw (new ValidationException([$this->shortName => implode(': ', $messages)], $this->issetOwner() ? $this->getOwner() : null))
->addMoreInfo('field', $this);
}
}
/**
* Returns field value.
*
* @return mixed
*/
final public function get(Model $entity)
{
$entity->assertIsEntity($this->getOwner());
return $entity->get($this->shortName);
}
/**
* Sets field value.
*
* @param mixed $value
*/
final public function set(Model $entity, $value): self
{
$entity->assertIsEntity($this->getOwner());
$entity->set($this->shortName, $value);
return $this;
}
/**
* Unset field value even if null value is not allowed.
*/
final public function setNull(Model $entity): self
{
$entity->assertIsEntity($this->getOwner());
$entity->setNull($this->shortName);
return $this;
}
private function getGenericPersistence(): Persistence
{
if ((self::$genericPersistence ?? null) === null) {
self::$genericPersistence = new class extends Persistence {};
}
return self::$genericPersistence;
}
/**
* @param mixed $value
*
* @return mixed
*/
private function typecastSaveField($value, bool $allowGenericPersistence = false)
{
if (!$this->getOwner()->issetPersistence() && $allowGenericPersistence) {
$persistence = $this->getGenericPersistence();
} else {
$this->getOwner()->assertHasPersistence();
$persistence = $this->getOwner()->getPersistence();
}
return $persistence->typecastSaveField($this, $value);
}
/**
* @param mixed $value
*/
private function getValueForCompare($value): ?string
{
if ($value === null) {
return null;
}
$res = $this->typecastSaveField($value, true);
if (is_float($res)) {
return Expression::castFloatToString($res);
}
return (string) $res;
}
/**
* Compare new value of the field with existing one without retrieving.
*
* @param mixed $value
* @param mixed $value2
*/
public function compare($value, $value2): bool
{
if ($value === $value2) { // optimization only
return true;
}
// TODO, see https://stackoverflow.com/questions/48382457/mysql-json-column-change-array-order-after-saving
// at least MySQL sorts the JSON keys if stored natively
return $this->getValueForCompare($value) === $this->getValueForCompare($value2);
}
public function hasReference(): bool
{
return $this->referenceLink !== null;
}
public function getReference(): Reference
{
return $this->getOwner()->getReference($this->referenceLink);
}
public function getPersistenceName(): string
{
return $this->actual ?? $this->shortName;
}
/**
* Should this field use alias?
*/
public function useAlias(): bool
{
return $this->actual !== null;
}
// }}}
// {{{ Scope condition
/**
* Returns arguments to be used for query on this field based on the condition.
*
* @param string|null $operator one of Scope\Condition operators
* @param mixed $value the condition value to be handled
*
* @return array{$this, string|null, mixed}
*/
public function getQueryArguments($operator, $value): array
{
$typecastField = $this;
if (in_array($operator, [
Scope\Condition::OPERATOR_LIKE,
Scope\Condition::OPERATOR_NOT_LIKE,
Scope\Condition::OPERATOR_REGEXP,
Scope\Condition::OPERATOR_NOT_REGEXP,
], true)) {
$typecastField = new self([
'type' => in_array($this->type, ['binary', 'blob'], true)
? 'blob'
: 'text',
]);
$typecastField->setOwner(new Model($this->getOwner()->getPersistence(), ['table' => false]));
$typecastField->shortName = $this->shortName;
}
if ($value instanceof Persistence\Array_\Action) { // needed to pass hintable tests
$v = $value;
} elseif (is_array($value)) {
$v = array_map(static fn ($value) => $value === null ? null : $typecastField->typecastSaveField($value), $value);
} else {
$v = $value === null ? null : $typecastField->typecastSaveField($value);
}
return [$this, $operator, $v];
}
// }}}
// {{{ Handy methods used by UI
/**
* Returns if field should be editable in UI.
*/
public function isEditable(): bool
{
return $this->ui['editable'] ?? !$this->readOnly && !$this->neverPersist && !$this->system;
}
/**
* Returns if field should be visible in UI.
*/
public function isVisible(): bool
{
return $this->ui['visible'] ?? !$this->system;
}
/**
* Returns if field should be hidden in UI.
*/
public function isHidden(): bool
{
return $this->ui['hidden'] ?? false;
}
/**
* Returns field caption for use in UI.
*/
public function getCaption(): string
{
return $this->caption ?? $this->ui['caption'] ?? $this->readableCaption($this->shortName);
}
// }}}
/**
* When field is used as expression, this method will be called.
*
* Off-load implementation into persistence.
*/
#[\Override]
public function getDsqlExpression(Expression $expression): Expression
{
$this->getOwner()->assertHasPersistence();
if (!$this->getOwner()->getPersistence() instanceof Persistence\Sql) {
throw (new Exception('Field must have SQL persistence if it is used as part of expression'))
->addMoreInfo('persistence', $this->getOwner()->getPersistence());
}
return $this->getOwner()->getPersistence()->getFieldSqlExpression($this, $expression);
}
/**
* @return array<string, mixed>
*/
public function __debugInfo(): array
{
$arr = [
'ownerClass' => $this->issetOwner() ? get_class($this->getOwner()) : null,
'shortName' => $this->shortName,
'type' => $this->type,
];
foreach ([
'actual', 'neverPersist', 'neverSave', 'system', 'readOnly', 'ui', 'joinName',
] as $key) {
if ($this->{$key} !== null) {
$arr[$key] = $this->{$key};
}
}
return $arr;
}
}