1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data;
6:
7: use Atk4\Core\DiContainerTrait;
8: use Atk4\Core\ReadableCaptionTrait;
9: use Atk4\Core\TrackableTrait;
10: use Atk4\Data\Model\Scope;
11: use Atk4\Data\Persistence\Sql\Expression;
12: use Atk4\Data\Persistence\Sql\Expressionable;
13: use Doctrine\DBAL\Types\Type;
14:
15: /**
16: * @method Model getOwner()
17: */
18: class Field implements Expressionable
19: {
20: use DiContainerTrait {
21: setDefaults as private _setDefaults;
22: }
23: use Model\FieldPropertiesTrait;
24: use Model\JoinLinkTrait;
25: use ReadableCaptionTrait;
26: use TrackableTrait {
27: setOwner as private _setOwner;
28: }
29:
30: // {{{ Core functionality
31:
32: /**
33: * @param array<string, mixed> $defaults
34: */
35: public function __construct(array $defaults = [])
36: {
37: $this->setDefaults($defaults);
38:
39: if (!(new \ReflectionProperty($this, 'type'))->isInitialized($this)) {
40: $this->type = 'string';
41: }
42: }
43:
44: /**
45: * @param Model $owner
46: *
47: * @return $this
48: */
49: public function setOwner(object $owner)
50: {
51: $owner->assertIsModel();
52:
53: return $this->_setOwner($owner);
54: }
55:
56: /**
57: * @param array<string, mixed> $properties
58: */
59: public function setDefaults(array $properties, bool $passively = false): self
60: {
61: $this->_setDefaults($properties, $passively);
62:
63: // assert type exists
64: if (isset($properties['type'])) {
65: Type::getType($this->type);
66: }
67:
68: return $this;
69: }
70:
71: /**
72: * @template T of Model
73: *
74: * @param \Closure(T, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): mixed $fx
75: * @param array<int, mixed> $args
76: */
77: protected function onHookToOwnerEntity(string $spot, \Closure $fx, array $args = [], int $priority = 5): int
78: {
79: $name = $this->shortName; // use static function to allow this object to be GCed
80:
81: return $this->getOwner()->onHookDynamic(
82: $spot,
83: static function (Model $entity) use ($name): self {
84: $obj = $entity->getModel()->getField($name);
85: $entity->assertIsEntity($obj->getOwner());
86:
87: return $obj;
88: },
89: $fx,
90: $args,
91: $priority
92: );
93: }
94:
95: /**
96: * @param mixed $value
97: *
98: * @return mixed
99: */
100: private function normalizeUsingTypecast($value)
101: {
102: $persistence = $this->issetOwner() && $this->getOwner()->issetPersistence()
103: ? $this->getOwner()->getPersistence()
104: : new class() extends Persistence {
105: public function __construct() {}
106: };
107:
108: $persistenceSetSkipNormalizeFx = \Closure::bind(static function (bool $value) use ($persistence) {
109: $persistence->typecastSaveSkipNormalize = $value;
110: }, null, Persistence::class);
111:
112: $persistenceSetSkipNormalizeFx(true); // prevent recursion
113: try {
114: $value = $persistence->typecastSaveField($this, $value);
115: } finally {
116: $persistenceSetSkipNormalizeFx(false);
117: }
118: $value = $persistence->typecastLoadField($this, $value);
119:
120: return $value;
121: }
122:
123: /**
124: * Depending on the type of a current field, this will perform
125: * some normalization for strict types. This method must also make
126: * sure that $f->required is respected when setting the value, e.g.
127: * you can't set value to '' if type=string and required=true.
128: *
129: * @param mixed $value
130: *
131: * @return mixed
132: */
133: public function normalize($value)
134: {
135: try {
136: if ($this->issetOwner() && $this->getOwner()->hook(Model::HOOK_NORMALIZE, [$this, $value]) === false) {
137: return $value;
138: }
139:
140: if (is_string($value)) {
141: switch ($this->type) {
142: case 'string':
143: $value = trim(preg_replace('~\r?\n|\r|\s~', ' ', $value)); // remove all line-ends and trim
144:
145: break;
146: case 'text':
147: $value = rtrim(preg_replace('~\r?\n|\r~', "\n", $value)); // normalize line-ends to LF and rtrim
148:
149: break;
150: }
151: }
152:
153: $value = $this->normalizeUsingTypecast($value);
154:
155: if ($value === null) {
156: if ($this->required) {
157: throw new Exception('Must not be empty');
158: } elseif (!$this->nullable) {
159: throw new Exception('Must not be null');
160: }
161:
162: return null;
163: }
164:
165: if ($value === '' && $this->required) {
166: throw new Exception('Must not be empty');
167: }
168:
169: switch ($this->type) {
170: case 'string':
171: case 'text':
172: if ($this->required && !$value) {
173: throw new Exception('Must not be empty');
174: }
175:
176: break;
177: case 'boolean':
178: if ($this->required && !$value) {
179: throw new Exception('Must be true');
180: }
181:
182: break;
183: case 'integer':
184: case 'float':
185: case 'decimal':
186: case 'atk4_money':
187: if ($this->required && !$value) {
188: throw new Exception('Must not be a zero');
189: }
190:
191: break;
192: case 'date':
193: case 'datetime':
194: case 'time':
195: if (!$value instanceof \DateTimeInterface) {
196: throw new Exception('Must be an instance of DateTimeInterface');
197: }
198:
199: break;
200: case 'json':
201: if (!is_array($value)) {
202: throw new Exception('Must be an array');
203: }
204:
205: break;
206: case 'object':
207: if (!is_object($value)) {
208: throw new Exception('Must be an object');
209: }
210:
211: break;
212: }
213:
214: if ($this->enum) {
215: if ($value === '') {
216: $value = null;
217: } elseif (!in_array($value, $this->enum, true)) {
218: throw new Exception('Value is not one of the allowed values: ' . implode(', ', $this->enum));
219: }
220: } elseif ($this->values) {
221: if ($value === '') {
222: $value = null;
223: } elseif ((!is_string($value) && !is_int($value)) || !isset($this->values[$value])) {
224: throw new Exception('Value is not one of the allowed values: ' . implode(', ', array_keys($this->values)));
225: }
226: }
227:
228: return $value;
229: } catch (\Exception $e) {
230: if ($e instanceof \ErrorException) {
231: throw $e;
232: }
233:
234: $messages = [];
235: do {
236: $messages[] = $e->getMessage();
237: } while ($e = $e->getPrevious());
238:
239: if (count($messages) >= 2 && $messages[0] === 'Typecast save error') {
240: array_shift($messages);
241: }
242:
243: throw (new ValidationException([$this->shortName => implode(': ', $messages)], $this->issetOwner() ? $this->getOwner() : null))
244: ->addMoreInfo('field', $this);
245: }
246: }
247:
248: /**
249: * Returns field value.
250: *
251: * @return mixed
252: */
253: final public function get(Model $entity)
254: {
255: $entity->assertIsEntity($this->getOwner());
256:
257: return $entity->get($this->shortName);
258: }
259:
260: /**
261: * Sets field value.
262: *
263: * @param mixed $value
264: */
265: final public function set(Model $entity, $value): self
266: {
267: $entity->assertIsEntity($this->getOwner());
268:
269: $entity->set($this->shortName, $value);
270:
271: return $this;
272: }
273:
274: /**
275: * Unset field value even if null value is not allowed.
276: */
277: final public function setNull(Model $entity): self
278: {
279: $entity->assertIsEntity($this->getOwner());
280:
281: $entity->setNull($this->shortName);
282:
283: return $this;
284: }
285:
286: /**
287: * @param mixed $value
288: *
289: * @return mixed
290: */
291: private function typecastSaveField($value, bool $allowGenericPersistence = false)
292: {
293: if (!$this->getOwner()->issetPersistence() && $allowGenericPersistence) {
294: $persistence = new class() extends Persistence {
295: public function __construct() {}
296: };
297: } else {
298: $this->getOwner()->assertHasPersistence();
299: $persistence = $this->getOwner()->getPersistence();
300: }
301:
302: return $persistence->typecastSaveField($this, $value);
303: }
304:
305: /**
306: * @param mixed $value
307: */
308: private function getValueForCompare($value): ?string
309: {
310: if ($value === null) {
311: return null;
312: }
313:
314: $res = $this->typecastSaveField($value, true);
315: if (is_float($res)) {
316: return Expression::castFloatToString($res);
317: }
318:
319: return (string) $res;
320: }
321:
322: /**
323: * Compare new value of the field with existing one without retrieving.
324: *
325: * @param mixed $value
326: * @param mixed $value2
327: */
328: public function compare($value, $value2): bool
329: {
330: if ($value === $value2) { // optimization only
331: return true;
332: }
333:
334: // TODO, see https://stackoverflow.com/questions/48382457/mysql-json-column-change-array-order-after-saving
335: // at least MySQL sorts the JSON keys if stored natively
336: return $this->getValueForCompare($value) === $this->getValueForCompare($value2);
337: }
338:
339: public function hasReference(): bool
340: {
341: return $this->referenceLink !== null;
342: }
343:
344: public function getReference(): Reference
345: {
346: return $this->getOwner()->getReference($this->referenceLink);
347: }
348:
349: public function getPersistenceName(): string
350: {
351: return $this->actual ?? $this->shortName;
352: }
353:
354: /**
355: * Should this field use alias?
356: */
357: public function useAlias(): bool
358: {
359: return $this->actual !== null;
360: }
361:
362: // }}}
363:
364: // {{{ Scope condition
365:
366: /**
367: * Returns arguments to be used for query on this field based on the condition.
368: *
369: * @param string|null $operator one of Scope\Condition operators
370: * @param mixed $value the condition value to be handled
371: *
372: * @return array{$this, string, mixed}
373: */
374: public function getQueryArguments($operator, $value): array
375: {
376: $typecastField = $this;
377: if (in_array($operator, [
378: Scope\Condition::OPERATOR_LIKE,
379: Scope\Condition::OPERATOR_NOT_LIKE,
380: Scope\Condition::OPERATOR_REGEXP,
381: Scope\Condition::OPERATOR_NOT_REGEXP,
382: ], true)) {
383: $typecastField = new self(['type' => 'string']);
384: $typecastField->setOwner(new Model($this->getOwner()->getPersistence(), ['table' => false]));
385: $typecastField->shortName = $this->shortName;
386: }
387:
388: if ($value instanceof Persistence\Array_\Action) { // needed to pass hintable tests
389: $v = $value;
390: } elseif (is_array($value)) {
391: $v = array_map(static fn ($value) => $typecastField->typecastSaveField($value), $value);
392: } else {
393: $v = $typecastField->typecastSaveField($value);
394: }
395:
396: return [$this, $operator, $v];
397: }
398:
399: // }}}
400:
401: // {{{ Handy methods used by UI
402:
403: /**
404: * Returns if field should be editable in UI.
405: */
406: public function isEditable(): bool
407: {
408: return $this->ui['editable'] ?? !$this->readOnly && !$this->neverPersist && !$this->system;
409: }
410:
411: /**
412: * Returns if field should be visible in UI.
413: */
414: public function isVisible(): bool
415: {
416: return $this->ui['visible'] ?? !$this->system;
417: }
418:
419: /**
420: * Returns if field should be hidden in UI.
421: */
422: public function isHidden(): bool
423: {
424: return $this->ui['hidden'] ?? false;
425: }
426:
427: /**
428: * Returns field caption for use in UI.
429: */
430: public function getCaption(): string
431: {
432: return $this->caption ?? $this->ui['caption'] ?? $this->readableCaption($this->shortName);
433: }
434:
435: // }}}
436:
437: /**
438: * When field is used as expression, this method will be called.
439: *
440: * Off-load implementation into persistence.
441: */
442: #[\Override]
443: public function getDsqlExpression(Expression $expression): Expression
444: {
445: $this->getOwner()->assertHasPersistence();
446: if (!$this->getOwner()->getPersistence() instanceof Persistence\Sql) {
447: throw (new Exception('Field must have SQL persistence if it is used as part of expression'))
448: ->addMoreInfo('persistence', $this->getOwner()->getPersistence());
449: }
450:
451: return $this->getOwner()->getPersistence()->getFieldSqlExpression($this, $expression);
452: }
453:
454: /**
455: * @return array<string, mixed>
456: */
457: public function __debugInfo(): array
458: {
459: $arr = [
460: 'ownerClass' => $this->issetOwner() ? get_class($this->getOwner()) : null,
461: 'shortName' => $this->shortName,
462: 'type' => $this->type,
463: ];
464:
465: foreach ([
466: 'actual', 'neverPersist', 'neverSave', 'system', 'readOnly', 'ui', 'joinName',
467: ] as $key) {
468: if ($this->{$key} !== null) {
469: $arr[$key] = $this->{$key};
470: }
471: }
472:
473: return $arr;
474: }
475: }
476: