1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data;
6:
7: use Atk4\Core\CollectionTrait;
8: use Atk4\Core\ContainerTrait;
9: use Atk4\Core\DiContainerTrait;
10: use Atk4\Core\DynamicMethodTrait;
11: use Atk4\Core\Exception as CoreException;
12: use Atk4\Core\Factory;
13: use Atk4\Core\HookBreaker;
14: use Atk4\Core\HookTrait;
15: use Atk4\Core\InitializerTrait;
16: use Atk4\Core\ReadableCaptionTrait;
17: use Atk4\Data\Field\CallbackField;
18: use Atk4\Data\Field\SqlExpressionField;
19: use Atk4\Data\Model\Scope\AbstractScope;
20: use Atk4\Data\Model\Scope\RootScope;
21: use Mvorisek\Atk4\Hintable\Data\HintableModelTrait;
22:
23: /**
24: * @property int $id @Atk4\Field() Contains ID of the current record.
25: * If the value is null then the record is considered to be new.
26: * @property array<string, Field|Reference|Model\Join> $elements
27: *
28: * @phpstan-implements \IteratorAggregate<static>
29: */
30: class Model implements \IteratorAggregate
31: {
32: use CollectionTrait {
33: _addIntoCollection as private __addIntoCollection;
34: }
35: use ContainerTrait {
36: add as private _add;
37: }
38: use DiContainerTrait {
39: DiContainerTrait::__isset as private __di_isset;
40: DiContainerTrait::__get as private __di_get;
41: DiContainerTrait::__set as private __di_set;
42: DiContainerTrait::__unset as private __di_unset;
43: }
44: use DynamicMethodTrait;
45: use HintableModelTrait {
46: HintableModelTrait::assertIsInitialized as private __hintable_assertIsInitialized;
47: HintableModelTrait::__isset as private __hintable_isset;
48: HintableModelTrait::__get as private __hintable_get;
49: HintableModelTrait::__set as private __hintable_set;
50: HintableModelTrait::__unset as private __hintable_unset;
51: }
52: use HookTrait;
53: use InitializerTrait {
54: init as private _init;
55: InitializerTrait::assertIsInitialized as private _assertIsInitialized;
56: }
57: use Model\JoinsTrait;
58: use Model\ReferencesTrait;
59: use Model\UserActionsTrait;
60: use ReadableCaptionTrait;
61:
62: public const HOOK_BEFORE_LOAD = self::class . '@beforeLoad';
63: public const HOOK_AFTER_LOAD = self::class . '@afterLoad';
64: public const HOOK_BEFORE_UNLOAD = self::class . '@beforeUnload';
65: public const HOOK_AFTER_UNLOAD = self::class . '@afterUnload';
66:
67: public const HOOK_BEFORE_INSERT = self::class . '@beforeInsert';
68: public const HOOK_AFTER_INSERT = self::class . '@afterInsert';
69: public const HOOK_BEFORE_UPDATE = self::class . '@beforeUpdate';
70: public const HOOK_AFTER_UPDATE = self::class . '@afterUpdate';
71: public const HOOK_BEFORE_DELETE = self::class . '@beforeDelete';
72: public const HOOK_AFTER_DELETE = self::class . '@afterDelete';
73:
74: public const HOOK_BEFORE_SAVE = self::class . '@beforeSave';
75: public const HOOK_AFTER_SAVE = self::class . '@afterSave';
76:
77: /** Executed when execution of self::atomic() failed. */
78: public const HOOK_ROLLBACK = self::class . '@rollback';
79:
80: /** Executed for every field set using self::set() method. */
81: public const HOOK_NORMALIZE = self::class . '@normalize';
82: /** Executed when self::validate() method is called. */
83: public const HOOK_VALIDATE = self::class . '@validate';
84: /** Executed when self::setOnlyFields() method is called. */
85: public const HOOK_ONLY_FIELDS = self::class . '@onlyFields';
86:
87: protected const ID_LOAD_ONE = self::class . '@idLoadOne-h7axmDNBB3qVXjVv';
88: protected const ID_LOAD_ANY = self::class . '@idLoadAny-h7axmDNBB3qVXjVv';
89:
90: /** @var static|null not-null if and only if this instance is an entity */
91: private ?self $_model = null;
92:
93: /** @var mixed once set, loading a different ID will result in an error */
94: private $_entityId;
95:
96: /** @var array<string, true> */
97: private static $_modelOnlyProperties;
98:
99: /** @var array<mixed> The seed used by addField() method. */
100: protected $_defaultSeedAddField = [Field::class];
101:
102: /** @var array<mixed> The seed used by addExpression() method. */
103: protected $_defaultSeedAddExpression = [CallbackField::class];
104:
105: /** @var array<string, Field> */
106: protected array $fields = [];
107:
108: /**
109: * Contains name of table, session key, collection or file where this
110: * model normally lives. The interpretation of the table will be decoded
111: * by persistence driver.
112: *
113: * @var string|self|false
114: */
115: public $table;
116:
117: /** @var string|null */
118: public $tableAlias;
119:
120: /** @var Persistence|null */
121: private $_persistence;
122:
123: /** @var array<string, mixed>|null Persistence store some custom information in here that may be useful for them. */
124: public ?array $persistenceData = null;
125:
126: /** @var RootScope */
127: private $scope;
128:
129: /** @var array{int|null, int} */
130: public array $limit = [null, 0];
131:
132: /** @var array<int, array{string|Persistence\Sql\Expressionable, 'asc'|'desc'}> */
133: public array $order = [];
134:
135: /** @var array<string, array{'model': Model, 'recursive': bool}> */
136: public array $cteModels = [];
137:
138: /**
139: * Currently loaded record data. This record is associative array
140: * that contain field => data pairs. It may contain data for un-defined
141: * fields only if $onlyFields mode is false.
142: *
143: * Avoid accessing $data directly, use set() / get() instead.
144: *
145: * @var array<string, mixed>
146: */
147: private array $data = [];
148:
149: /**
150: * After loading an entity the data will be stored in
151: * $data property and you can access them using get(). If you use
152: * set() to change any of the data, the original value will be copied
153: * here.
154: *
155: * If the value you set equal to the original value, then the key
156: * in this array will be removed.
157: *
158: * @var array<string, mixed>
159: */
160: private array $dirty = [];
161:
162: /**
163: * Setting model as readOnly will protect you from accidentally
164: * updating the model. This property is intended for UI and other code
165: * detecting read-only models and acting accordingly.
166: */
167: public bool $readOnly = false;
168:
169: /**
170: * While in most cases your id field will be called 'id', sometimes
171: * you would want to use a different one or maybe don't create field
172: * at all.
173: *
174: * @var string|false
175: */
176: public $idField = 'id';
177:
178: /**
179: * Title field is used typically by UI components for a simple human
180: * readable row title/description.
181: */
182: public ?string $titleField = 'name';
183:
184: /**
185: * Caption of the model. Can be used in UI components, for example.
186: * Should be in plain English and ready for proper localization.
187: *
188: * @var string|null
189: */
190: public $caption;
191:
192: /**
193: * When using setOnlyFields() this property will contain list of desired
194: * fields.
195: *
196: * If you set setOnlyFields() before loading the data for this model, then
197: * only that set of fields will be available. Attempt to access any other
198: * field will result in exception. This is to ensure that you do not
199: * accidentally access field that you have explicitly excluded.
200: *
201: * The default behavior is to return NULL and allow you to set new
202: * fields even if addField() was not used to set the field.
203: *
204: * setOnlyFields() always allows to access fields with system = true.
205: *
206: * @var array<int, string>|null
207: */
208: public ?array $onlyFields = null;
209:
210: /**
211: * Models that contain expressions will automatically reload after save.
212: * This is to ensure that any SQL-based calculation are executed and
213: * updated correctly after you have performed any modifications to
214: * the fields.
215: */
216: public bool $reloadAfterSave = true;
217:
218: /**
219: * If this model is "contained into" another entity by using ContainsOne
220: * or ContainsMany reference, then this property will contain reference
221: * to owning entity.
222: */
223: public ?self $containedInEntity = null;
224:
225: /** Only for Reference class */
226: public ?Reference $ownerReference = null;
227:
228: // {{{ Basic Functionality, field definition, set() and get()
229:
230: /**
231: * Creation of the new model can be done in two ways:.
232: *
233: * $m = new Model($db);
234: * or
235: * $m = new Model();
236: * $m->setPersistence($db);
237: *
238: * The second use actually calls add() but is preferred usage because:
239: * - it's shorter
240: * - type hinting will work;
241: * - you can specify string for a table
242: *
243: * @param array<string, mixed> $defaults
244: */
245: public function __construct(Persistence $persistence = null, array $defaults = [])
246: {
247: $this->scope = \Closure::bind(static function () {
248: return new RootScope();
249: }, null, RootScope::class)()
250: ->setModel($this);
251:
252: $this->setDefaults($defaults);
253:
254: if ($persistence !== null) {
255: $this->setPersistence($persistence);
256: }
257: }
258:
259: public function isEntity(): bool
260: {
261: return $this->_model !== null;
262: }
263:
264: public function assertIsModel(self $expectedModelInstance = null): void
265: {
266: if ($this->_model !== null) {
267: throw new \TypeError('Expected model, but instance is an entity');
268: }
269:
270: if ($expectedModelInstance !== null && $expectedModelInstance !== $this) {
271: $expectedModelInstance->assertIsModel();
272:
273: throw new \TypeError('Model instance does not match');
274: }
275: }
276:
277: public function assertIsEntity(self $expectedModelInstance = null): void
278: {
279: if ($this->_model === null) {
280: throw new \TypeError('Expected entity, but instance is a model');
281: }
282:
283: if ($expectedModelInstance !== null) {
284: $this->getModel()->assertIsModel($expectedModelInstance);
285: }
286: }
287:
288: /**
289: * @return static
290: */
291: public function getModel(bool $allowOnModel = false): self
292: {
293: if ($this->_model !== null) {
294: return $this->_model;
295: }
296:
297: if (!$allowOnModel) {
298: $this->assertIsEntity();
299: }
300:
301: return $this;
302: }
303:
304: public function __clone()
305: {
306: if (!$this->isEntity()) {
307: $this->scope = (clone $this->scope)->setModel($this);
308: $this->_cloneCollection('fields');
309: $this->_cloneCollection('elements');
310: }
311: $this->_cloneCollection('userActions');
312:
313: // check for clone errors immediately, otherwise not strictly needed
314: $this->_rebindHooksIfCloned();
315: }
316:
317: /**
318: * @return array<string, true>
319: */
320: protected function getModelOnlyProperties(): array
321: {
322: $this->assertIsModel();
323:
324: if (self::$_modelOnlyProperties === null) {
325: $modelOnlyProperties = [];
326: foreach ((new \ReflectionClass(self::class))->getProperties() as $prop) {
327: if (!$prop->isStatic()) {
328: $modelOnlyProperties[$prop->getName()] = true;
329: }
330: }
331:
332: $modelOnlyProperties = array_diff_key($modelOnlyProperties, array_flip([
333: '_model',
334: '_entityId',
335: 'data',
336: 'dirty',
337:
338: 'hooks',
339: '_hookIndexCounter',
340: '_hookOrigThis',
341:
342: 'ownerReference', // should be removed once references are non-entity
343: 'userActions', // should be removed once user actions are non-entity
344:
345: 'containedInEntity',
346: ]));
347:
348: self::$_modelOnlyProperties = $modelOnlyProperties;
349: }
350:
351: return self::$_modelOnlyProperties;
352: }
353:
354: /**
355: * @return static
356: */
357: public function createEntity(): self
358: {
359: $this->assertIsModel();
360:
361: $userActionsBackup = $this->userActions;
362: try {
363: $this->_model = $this;
364: $this->userActions = [];
365: $entity = clone $this;
366: } finally {
367: $this->_model = null;
368: $this->userActions = $userActionsBackup;
369: }
370: $entity->_entityId = null;
371:
372: // unset non-entity properties, they are magically remapped to the model when accessed
373: foreach (array_keys($this->getModelOnlyProperties()) as $name) {
374: unset($entity->{$name});
375: }
376:
377: return $entity;
378: }
379:
380: /**
381: * Extend this method to define fields of your choice.
382: */
383: protected function init(): void
384: {
385: $this->assertIsModel();
386:
387: $this->_init();
388:
389: if ($this->idField) {
390: $this->addField($this->idField, ['type' => 'integer', 'required' => true, 'system' => true]);
391:
392: $this->initEntityIdHooks();
393:
394: if (!$this->readOnly) {
395: $this->initUserActions();
396: }
397: }
398: }
399:
400: public function assertIsInitialized(): void
401: {
402: $this->_assertIsInitialized();
403: $this->__hintable_assertIsInitialized();
404: }
405:
406: private function initEntityIdAndAssertUnchanged(): void
407: {
408: $id = $this->getId();
409: if ($id === null) { // allow unload
410: return;
411: }
412:
413: if ($this->_entityId === null) {
414: // set entity ID to the first seen ID
415: $this->_entityId = $id;
416: } elseif ($this->_entityId !== $id && !$this->compare($this->idField, $this->_entityId)) {
417: $this->unload(); // data for different ID were loaded, make sure to discard them
418:
419: throw (new Exception('Model instance is an entity, ID cannot be changed to a different one'))
420: ->addMoreInfo('entityId', $this->_entityId)
421: ->addMoreInfo('newId', $id);
422: }
423: }
424:
425: private function initEntityIdHooks(): void
426: {
427: $fx = function () {
428: $this->initEntityIdAndAssertUnchanged();
429: };
430:
431: $this->onHookShort(self::HOOK_BEFORE_LOAD, $fx, [], 10);
432: $this->onHookShort(self::HOOK_AFTER_LOAD, $fx, [], -10);
433: $this->onHookShort(self::HOOK_BEFORE_DELETE, $fx, [], 10);
434: $this->onHookShort(self::HOOK_AFTER_DELETE, $fx, [], -10);
435: $this->onHookShort(self::HOOK_BEFORE_SAVE, $fx, [], 10);
436: $this->onHookShort(self::HOOK_AFTER_SAVE, $fx, [], -10);
437: }
438:
439: /**
440: * @param Field|Reference|Model\Join $obj
441: * @param array<string, mixed> $defaults
442: */
443: public function add(object $obj, array $defaults = []): void
444: {
445: $this->assertIsModel();
446:
447: if ($obj instanceof Field) {
448: throw new Exception('Field can be added using addField() method only');
449: }
450:
451: $this->_add($obj, $defaults);
452: }
453:
454: public function _addIntoCollection(string $name, object $item, string $collection): object
455: {
456: // TODO $this->assertIsModel();
457:
458: return $this->__addIntoCollection($name, $item, $collection);
459: }
460:
461: /**
462: * @return array<string, mixed>
463: *
464: * @internal should be not used outside atk4/data
465: */
466: public function &getDataRef(): array
467: {
468: $this->assertIsEntity();
469:
470: return $this->data;
471: }
472:
473: /**
474: * @return array<string, mixed>
475: *
476: * @internal should be not used outside atk4/data
477: */
478: public function &getDirtyRef(): array
479: {
480: $this->assertIsEntity();
481:
482: return $this->dirty;
483: }
484:
485: /**
486: * Perform validation on a currently loaded values, must return Array in format:
487: * ['field' => 'must be 4 digits exactly'] or empty array if no errors were present.
488: *
489: * You may also use format:
490: * ['field' => ['must not have character [ch]', 'ch' => $badCharacter]] for better localization of error message.
491: *
492: * Always use
493: * return array_merge(parent::validate($intent), $errors);
494: *
495: * @param string $intent by default only 'save' is used (from beforeSave) but you can use other intents yourself
496: *
497: * @return array<string, string> [field => err_spec]
498: */
499: public function validate(string $intent = null): array
500: {
501: $errors = [];
502: foreach ($this->hook(self::HOOK_VALIDATE, [$intent]) as $error) {
503: if ($error) {
504: $errors = array_merge($errors, $error);
505: }
506: }
507:
508: return $errors;
509: }
510:
511: /** @var array<string, array<mixed>> */
512: protected array $fieldSeedByType = [];
513:
514: /**
515: * Given a field seed, return a field object.
516: *
517: * @param array<mixed> $seed
518: */
519: protected function fieldFactory(array $seed = []): Field
520: {
521: $seed = Factory::mergeSeeds(
522: $seed,
523: isset($seed['type']) ? ($this->fieldSeedByType[$seed['type']] ?? null) : null,
524: $this->_defaultSeedAddField
525: );
526:
527: return Field::fromSeed($seed);
528: }
529:
530: /**
531: * Adds new field into model.
532: *
533: * @param array<mixed>|object $seed
534: */
535: public function addField(string $name, $seed = []): Field
536: {
537: $this->assertIsModel();
538:
539: if (is_object($seed)) {
540: $field = $seed;
541: } else {
542: $field = $this->fieldFactory($seed);
543: }
544:
545: return $this->_addIntoCollection($name, $field, 'fields');
546: }
547:
548: /**
549: * Adds multiple fields into model.
550: *
551: * @param array<string, array<mixed>|object>|array<int, string> $fields
552: * @param array<mixed> $seed
553: *
554: * @return $this
555: */
556: public function addFields(array $fields, array $seed = [])
557: {
558: foreach ($fields as $k => $v) {
559: if (is_int($k)) {
560: $k = $v;
561: $v = [];
562: }
563:
564: $this->addField($k, Factory::mergeSeeds($v, $seed));
565: }
566:
567: return $this;
568: }
569:
570: /**
571: * Remove field that was added previously.
572: *
573: * @return $this
574: */
575: public function removeField(string $name)
576: {
577: $this->assertIsModel();
578:
579: $this->getField($name); // better exception if field does not exist
580:
581: $this->_removeFromCollection($name, 'fields');
582:
583: return $this;
584: }
585:
586: public function hasField(string $name): bool
587: {
588: if ($this->isEntity()) {
589: return $this->getModel()->hasField($name);
590: }
591:
592: return $this->_hasInCollection($name, 'fields');
593: }
594:
595: public function getField(string $name): Field
596: {
597: if ($this->isEntity()) {
598: return $this->getModel()->getField($name);
599: }
600:
601: try {
602: return $this->_getFromCollection($name, 'fields');
603: } catch (CoreException $e) {
604: throw (new Exception('Field is not defined'))
605: ->addMoreInfo('model', $this)
606: ->addMoreInfo('field', $name);
607: }
608: }
609:
610: /**
611: * Sets which fields we will select.
612: *
613: * @param array<int, string>|null $fields
614: *
615: * @return $this
616: */
617: public function setOnlyFields(?array $fields)
618: {
619: $this->assertIsModel();
620:
621: $this->hook(self::HOOK_ONLY_FIELDS, [&$fields]);
622: $this->onlyFields = $fields;
623:
624: return $this;
625: }
626:
627: private function assertOnlyField(string $field): void
628: {
629: $this->assertIsModel();
630:
631: $this->getField($field); // assert field exists
632:
633: if ($this->onlyFields !== null) {
634: if (!in_array($field, $this->onlyFields, true) && !$this->getField($field)->system) {
635: throw (new Exception('Attempt to use field outside of those set by setOnlyFields'))
636: ->addMoreInfo('field', $field)
637: ->addMoreInfo('onlyFields', $this->onlyFields);
638: }
639: }
640: }
641:
642: /**
643: * Will return true if specified field is dirty.
644: */
645: public function isDirty(string $field): bool
646: {
647: $this->getModel()->assertOnlyField($field);
648:
649: $dirtyRef = &$this->getDirtyRef();
650: if (array_key_exists($field, $dirtyRef)) {
651: return true;
652: }
653:
654: return false;
655: }
656:
657: /**
658: * @param string|array<int, string>|null $filter
659: *
660: * @return array<string, Field>
661: */
662: public function getFields($filter = null): array
663: {
664: if ($this->isEntity()) {
665: return $this->getModel()->getFields($filter);
666: }
667:
668: if ($filter === null) {
669: return $this->fields;
670: } elseif (is_string($filter)) {
671: $filter = [$filter];
672: }
673:
674: return array_filter($this->fields, function (Field $field, $name) use ($filter) {
675: // do not return fields outside of "onlyFields" scope
676: if ($this->onlyFields !== null && !in_array($name, $this->onlyFields, true)) { // TODO also without filter?
677: return false;
678: }
679: foreach ($filter as $f) {
680: if (($f === 'system' && $field->system)
681: || ($f === 'not system' && !$field->system)
682: || ($f === 'editable' && $field->isEditable())
683: || ($f === 'visible' && $field->isVisible())
684: ) {
685: return true;
686: } elseif (!in_array($f, ['system', 'not system', 'editable', 'visible'], true)) {
687: throw (new Exception('Field filter is not supported'))
688: ->addMoreInfo('filter', $f);
689: }
690: }
691:
692: return false;
693: }, \ARRAY_FILTER_USE_BOTH);
694: }
695:
696: /**
697: * Set field value.
698: *
699: * @param mixed $value
700: *
701: * @return $this
702: */
703: public function set(string $field, $value)
704: {
705: $this->getModel()->assertOnlyField($field);
706:
707: $f = $this->getField($field);
708:
709: if (!$value instanceof Persistence\Sql\Expressionable) {
710: try {
711: $value = $f->normalize($value);
712: } catch (Exception $e) {
713: $e->addMoreInfo('field', $f);
714: $e->addMoreInfo('value', $value);
715:
716: throw $e;
717: }
718: }
719:
720: // do nothing when value has not changed
721: $dataRef = &$this->getDataRef();
722: $dirtyRef = &$this->getDirtyRef();
723: $currentValue = array_key_exists($field, $dataRef)
724: ? $dataRef[$field]
725: : (array_key_exists($field, $dirtyRef) ? $dirtyRef[$field] : $f->default);
726: if (!$value instanceof Persistence\Sql\Expressionable && $f->compare($value, $currentValue)) {
727: return $this;
728: }
729:
730: if ($f->readOnly) {
731: throw (new Exception('Attempting to change read-only field'))
732: ->addMoreInfo('field', $field)
733: ->addMoreInfo('model', $this);
734: }
735:
736: if (array_key_exists($field, $dirtyRef) && $f->compare($dirtyRef[$field], $value)) {
737: unset($dirtyRef[$field]);
738: } elseif (!array_key_exists($field, $dirtyRef)) {
739: $dirtyRef[$field] = array_key_exists($field, $dataRef) ? $dataRef[$field] : $f->default;
740: }
741: $dataRef[$field] = $value;
742:
743: return $this;
744: }
745:
746: /**
747: * Unset field value even if null value is not allowed.
748: *
749: * @return $this
750: */
751: public function setNull(string $field)
752: {
753: // set temporary hook to disable any normalization (null validation)
754: $hookIndex = $this->getModel()->onHookShort(self::HOOK_NORMALIZE, static function () {
755: throw new HookBreaker(false);
756: }, [], \PHP_INT_MIN);
757: try {
758: return $this->set($field, null);
759: } finally {
760: $this->getModel()->removeHook(self::HOOK_NORMALIZE, $hookIndex, true);
761: }
762: }
763:
764: /**
765: * Helper method to call self::set() for each input array element.
766: *
767: * This method does not revert the data when an exception is thrown.
768: *
769: * @param array<string, mixed> $fields
770: *
771: * @return $this
772: */
773: public function setMulti(array $fields)
774: {
775: foreach ($fields as $field => $value) {
776: $this->set($field, $value);
777: }
778:
779: return $this;
780: }
781:
782: /**
783: * Returns field value.
784: * If no field is passed, then returns array of all field values.
785: *
786: * @return ($field is null ? array<string, mixed> : mixed)
787: */
788: public function get(string $field = null)
789: {
790: if ($field === null) {
791: $this->assertIsEntity();
792:
793: $data = [];
794: foreach ($this->onlyFields ?? array_keys($this->getFields()) as $k) {
795: $data[$k] = $this->get($k);
796: }
797:
798: return $data;
799: }
800:
801: $this->getModel()->assertOnlyField($field);
802:
803: $data = $this->getDataRef();
804: if (array_key_exists($field, $data)) {
805: return $data[$field];
806: }
807:
808: return $this->getField($field)->default;
809: }
810:
811: private function assertHasIdField(): void
812: {
813: if (!is_string($this->idField) || !$this->hasField($this->idField)) {
814: throw new Exception('ID field is not defined');
815: }
816: }
817:
818: /**
819: * @return mixed
820: */
821: public function getId()
822: {
823: try {
824: return $this->get($this->getModel()->idField);
825: } catch (\Throwable $e) {
826: $this->assertHasIdField();
827:
828: throw $e;
829: }
830: }
831:
832: /**
833: * @param mixed $value
834: *
835: * @return $this
836: */
837: public function setId($value, bool $allowNull = true)
838: {
839: try {
840: if ($value === null && $allowNull) {
841: $this->setNull($this->getModel()->idField);
842: } else {
843: $this->set($this->getModel()->idField, $value);
844: }
845:
846: $this->initEntityIdAndAssertUnchanged();
847:
848: return $this;
849: } catch (\Throwable $e) {
850: $this->assertHasIdField();
851:
852: throw $e;
853: }
854: }
855:
856: /**
857: * Return (possibly localized) $model->caption.
858: * If caption is not set, then generate it from model class name.
859: */
860: public function getModelCaption(): string
861: {
862: return $this->caption ?? $this->readableCaption(get_debug_type($this));
863: }
864:
865: /**
866: * Return value of $model->get($model->titleField). If not set, returns id value.
867: *
868: * @return mixed
869: */
870: public function getTitle()
871: {
872: if ($this->titleField && $this->hasField($this->titleField)) {
873: return $this->get($this->titleField);
874: }
875:
876: return $this->getId();
877: }
878:
879: /**
880: * Returns array of model record titles [id => title].
881: *
882: * @return array<int|string, mixed>
883: */
884: public function getTitles(): array
885: {
886: $this->assertIsModel();
887:
888: $field = $this->titleField && $this->hasField($this->titleField) ? $this->titleField : $this->idField;
889:
890: return array_map(static function (array $row) use ($field) {
891: return $row[$field];
892: }, $this->export([$field], $this->idField));
893: }
894:
895: /**
896: * @param mixed $value
897: */
898: public function compare(string $name, $value): bool
899: {
900: $value2 = $this->get($name);
901:
902: if ($value === $value2) { // optimization only
903: return true;
904: }
905:
906: return $this->getField($name)->compare($value, $value2);
907: }
908:
909: /**
910: * Does field exist?
911: */
912: public function _isset(string $name): bool
913: {
914: $this->getModel()->assertOnlyField($name);
915:
916: $dirtyRef = &$this->getDirtyRef();
917:
918: return array_key_exists($name, $dirtyRef);
919: }
920:
921: /**
922: * Remove current field value and use default.
923: *
924: * @return $this
925: */
926: public function _unset(string $name)
927: {
928: $this->getModel()->assertOnlyField($name);
929:
930: $dataRef = &$this->getDataRef();
931: $dirtyRef = &$this->getDirtyRef();
932: if (array_key_exists($name, $dirtyRef)) {
933: $dataRef[$name] = $dirtyRef[$name];
934: unset($dirtyRef[$name]);
935: }
936:
937: return $this;
938: }
939:
940: // }}}
941:
942: // {{{ Model logic
943:
944: /**
945: * Get the scope object of the Model.
946: */
947: public function scope(): RootScope
948: {
949: $this->assertIsModel();
950:
951: return $this->scope;
952: }
953:
954: /**
955: * Narrow down data-set of the current model by applying
956: * additional condition. There is no way to remove
957: * condition once added, so if you need - clone model.
958: *
959: * This is the most basic for defining condition:
960: * ->addCondition('my_field', $value);
961: *
962: * This condition will work across all persistence drivers universally.
963: *
964: * In some cases a more complex logic can be used:
965: * ->addCondition('my_field', '>', $value);
966: * ->addCondition('my_field', '!=', $value);
967: * ->addCondition('my_field', 'in', [$value1, $value2]);
968: *
969: * Second argument could be '=', '>', '<', '>=', '<=', '!=', 'in', 'like' or 'regexp'.
970: * Those conditions are still supported by most of persistence drivers.
971: *
972: * There are also vendor-specific expression support:
973: * ->addCondition('my_field', $expr);
974: * ->addCondition($expr);
975: *
976: * Conditions on referenced models are also supported:
977: * $contact->addCondition('company/country', 'US');
978: * where 'company' is the name of the reference
979: * This will limit scope of $contact model to contacts whose company country is set to 'US'
980: *
981: * Using # in conditions on referenced model will apply the condition on the number of records:
982: * $contact->addCondition('tickets/#', '>', 5);
983: * This will limit scope of $contact model to contacts that have more than 5 tickets
984: *
985: * To use those, you should consult with documentation of your
986: * persistence driver.
987: *
988: * @param AbstractScope|array<int, AbstractScope|Persistence\Sql\Expressionable|array{string|Persistence\Sql\Expressionable, 1?: mixed, 2?: mixed}>|string|Persistence\Sql\Expressionable $field
989: * @param ($field is string|Persistence\Sql\Expressionable ? ($value is null ? mixed : string) : never) $operator
990: * @param ($operator is string ? mixed : never) $value
991: *
992: * @return $this
993: */
994: public function addCondition($field, $operator = null, $value = null)
995: {
996: $this->scope()->addCondition(...'func_get_args'());
997:
998: return $this;
999: }
1000:
1001: /**
1002: * Adds WITH/CTE model.
1003: *
1004: * @return $this
1005: */
1006: public function addCteModel(string $name, self $model, bool $recursive = false)
1007: {
1008: if ($name === $this->table || $name === $this->tableAlias || isset($this->cteModels[$name])) {
1009: throw (new Exception('CTE model with given name already exist'))
1010: ->addMoreInfo('name', $name);
1011: }
1012:
1013: $this->cteModels[$name] = [
1014: 'model' => $model,
1015: 'recursive' => $recursive,
1016: ];
1017:
1018: return $this;
1019: }
1020:
1021: /**
1022: * Set order for model records. Multiple calls are allowed.
1023: *
1024: * @param string|array<int, string|array{string, 1?: 'asc'|'desc'}>|array<string, 'asc'|'desc'> $field
1025: * @param ($field is array ? never : 'asc'|'desc') $direction
1026: *
1027: * @return $this
1028: */
1029: public function setOrder($field, string $direction = 'asc')
1030: {
1031: $this->assertIsModel();
1032:
1033: // fields passed as array
1034: if (is_array($field)) {
1035: if ('func_num_args'() > 1) {
1036: throw (new Exception('If first argument is array, second argument must not be used'))
1037: ->addMoreInfo('arg1', $field)
1038: ->addMoreInfo('arg2', $direction);
1039: }
1040:
1041: foreach (array_reverse($field) as $k => $v) {
1042: if (is_int($k)) {
1043: if (is_array($v)) {
1044: // format [field, direction]
1045: $this->setOrder(...$v);
1046: } else {
1047: // format "field"
1048: $this->setOrder($v);
1049: }
1050: } else {
1051: // format "field" => direction
1052: $this->setOrder($k, $v);
1053: }
1054: }
1055:
1056: return $this;
1057: }
1058:
1059: $direction = strtolower($direction);
1060: if (!in_array($direction, ['asc', 'desc'], true)) {
1061: throw (new Exception('Invalid order direction, direction can be only "asc" or "desc"'))
1062: ->addMoreInfo('field', $field)
1063: ->addMoreInfo('direction', $direction);
1064: }
1065:
1066: $this->order[] = [$field, $direction];
1067:
1068: return $this;
1069: }
1070:
1071: /**
1072: * Set limit of DataSet.
1073: *
1074: * @return $this
1075: */
1076: public function setLimit(int $count = null, int $offset = 0)
1077: {
1078: $this->assertIsModel();
1079:
1080: $this->limit = [$count, $offset];
1081:
1082: return $this;
1083: }
1084:
1085: // }}}
1086:
1087: // {{{ Persistence-related logic
1088:
1089: public function issetPersistence(): bool
1090: {
1091: $this->assertIsModel();
1092:
1093: return $this->_persistence !== null;
1094: }
1095:
1096: public function getPersistence(): Persistence
1097: {
1098: $this->assertIsModel();
1099:
1100: return $this->_persistence;
1101: }
1102:
1103: /**
1104: * @return $this
1105: */
1106: public function setPersistence(Persistence $persistence)
1107: {
1108: if ($this->issetPersistence()) {
1109: throw new Exception('Persistence is already set');
1110: }
1111:
1112: if ($this->persistenceData === []) {
1113: $this->_persistence = $persistence;
1114: } else {
1115: $this->persistenceData = [];
1116: $persistence->add($this);
1117: }
1118:
1119: $this->getPersistence(); // assert persistence is set
1120:
1121: return $this;
1122: }
1123:
1124: public function assertHasPersistence(string $methodName = null): void
1125: {
1126: if (!$this->issetPersistence()) {
1127: throw new Exception('Model is not associated with a persistence');
1128: }
1129:
1130: if ($methodName !== null && !$this->getPersistence()->hasMethod($methodName)) {
1131: throw new Exception('Persistence does not support "' . $methodName . '" method');
1132: }
1133: }
1134:
1135: /**
1136: * Is entity loaded?
1137: */
1138: public function isLoaded(): bool
1139: {
1140: return $this->getModel()->idField && $this->getId() !== null && $this->_entityId !== null;
1141: }
1142:
1143: public function assertIsLoaded(): void
1144: {
1145: if (!$this->isLoaded()) {
1146: throw new Exception('Expected loaded entity');
1147: }
1148: }
1149:
1150: /**
1151: * @return $this
1152: */
1153: public function unload()
1154: {
1155: $this->assertIsEntity();
1156:
1157: $this->hook(self::HOOK_BEFORE_UNLOAD);
1158: $dataRef = &$this->getDataRef();
1159: $dirtyRef = &$this->getDirtyRef();
1160: $dataRef = [];
1161: if ($this->idField) {
1162: $this->setId(null);
1163: }
1164: $dirtyRef = [];
1165: $this->hook(self::HOOK_AFTER_UNLOAD);
1166:
1167: return $this;
1168: }
1169:
1170: /**
1171: * @param mixed $id
1172: *
1173: * @return mixed
1174: */
1175: private function remapIdLoadToPersistence($id)
1176: {
1177: if ($id === self::ID_LOAD_ONE) {
1178: return Persistence::ID_LOAD_ONE;
1179: } elseif ($id === self::ID_LOAD_ANY) {
1180: return Persistence::ID_LOAD_ANY;
1181: }
1182:
1183: return $id;
1184: }
1185:
1186: /**
1187: * @param ($fromTryLoad is true ? false : bool) $fromReload
1188: * @param mixed $id
1189: *
1190: * @return ($fromTryLoad is true ? static|null : static)
1191: */
1192: private function _load(bool $fromReload, bool $fromTryLoad, $id)
1193: {
1194: $this->getModel()->assertHasPersistence();
1195: if ($this->isLoaded()) {
1196: throw new Exception('Entity must be unloaded');
1197: }
1198:
1199: $noId = $id === self::ID_LOAD_ONE || $id === self::ID_LOAD_ANY;
1200: $res = $this->hook(self::HOOK_BEFORE_LOAD, [$noId ? null : $id]);
1201: if ($res === false) {
1202: if ($fromReload) {
1203: $this->unload();
1204:
1205: return $this;
1206: }
1207:
1208: return null;
1209: } elseif (is_object($res)) {
1210: $res = (static::class)::assertInstanceOf($res);
1211: $res->assertIsEntity();
1212:
1213: return $res;
1214: }
1215:
1216: $data = $this->getModel()->getPersistence()->{$fromTryLoad ? 'tryLoad' : 'load'}($this->getModel(), $this->remapIdLoadToPersistence($id));
1217: if ($data === null) {
1218: return null; // $fromTryLoad is always true here
1219: }
1220:
1221: $dataRef = &$this->getDataRef();
1222: $dataRef = $data;
1223:
1224: if ($this->idField) {
1225: $this->setId($data[$this->idField], false);
1226: }
1227:
1228: $res = $this->hook(self::HOOK_AFTER_LOAD);
1229: if ($res === false) {
1230: if ($fromReload) {
1231: $this->unload();
1232:
1233: return $this;
1234: }
1235:
1236: return null;
1237: } elseif (is_object($res)) {
1238: $res = (static::class)::assertInstanceOf($res);
1239: $res->assertIsEntity();
1240:
1241: return $res;
1242: }
1243:
1244: return $this;
1245: }
1246:
1247: /**
1248: * Try to load record. Will not throw an exception if record does not exist.
1249: *
1250: * @param mixed $id
1251: *
1252: * @return static|null
1253: */
1254: public function tryLoad($id)
1255: {
1256: $this->assertIsModel();
1257:
1258: return $this->createEntity()->_load(false, true, $id);
1259: }
1260:
1261: /**
1262: * Load one record by an ID.
1263: *
1264: * @param mixed $id
1265: *
1266: * @return static
1267: */
1268: public function load($id)
1269: {
1270: $this->assertIsModel();
1271:
1272: return $this->createEntity()->_load(false, false, $id);
1273: }
1274:
1275: /**
1276: * Try to load one record. Will throw if more than one record exists, but not if there is no record.
1277: *
1278: * @return static|null
1279: */
1280: public function tryLoadOne()
1281: {
1282: return $this->tryLoad(self::ID_LOAD_ONE);
1283: }
1284:
1285: /**
1286: * Load one record. Will throw if more than one record exists.
1287: *
1288: * @return static
1289: */
1290: public function loadOne()
1291: {
1292: return $this->load(self::ID_LOAD_ONE);
1293: }
1294:
1295: /**
1296: * Try to load any record. Will not throw an exception if record does not exist.
1297: *
1298: * If only one record should match, use checked "tryLoadOne" method.
1299: *
1300: * @return static|null
1301: */
1302: public function tryLoadAny()
1303: {
1304: return $this->tryLoad(self::ID_LOAD_ANY);
1305: }
1306:
1307: /**
1308: * Load any record.
1309: *
1310: * If only one record should match, use checked "loadOne" method.
1311: *
1312: * @return static
1313: */
1314: public function loadAny()
1315: {
1316: return $this->load(self::ID_LOAD_ANY);
1317: }
1318:
1319: /**
1320: * Reload model by taking its current ID.
1321: *
1322: * @return $this
1323: */
1324: public function reload()
1325: {
1326: $id = $this->getId();
1327: $data = $this->getDataRef(); // keep weakly persisted objects referenced
1328: $this->unload();
1329:
1330: $res = $this->_load(true, false, $id);
1331: if ($res !== $this) {
1332: throw new Exception('Entity instance does not match');
1333: }
1334:
1335: return $this;
1336: }
1337:
1338: /**
1339: * Keeps the model data, but wipes out the ID so
1340: * when you save it next time, it ends up as a new
1341: * record in the database.
1342: *
1343: * @return static
1344: */
1345: public function duplicate()
1346: {
1347: $this->assertIsEntity();
1348:
1349: $duplicate = clone $this;
1350: $duplicate->_entityId = null;
1351: $data = $this->getDataRef();
1352: $duplicateDirtyRef = &$duplicate->getDirtyRef();
1353: $duplicateDirtyRef = $data;
1354: $duplicate->setId(null);
1355:
1356: return $duplicate;
1357: }
1358:
1359: /**
1360: * Store the data into database, but will never attempt to
1361: * reload the data. Additionally any data will be unloaded.
1362: * Use this instead of save() if you want to squeeze a
1363: * little more performance out.
1364: *
1365: * @param array<string, mixed> $data
1366: *
1367: * @return $this
1368: */
1369: public function saveAndUnload(array $data = [])
1370: {
1371: $reloadAfterSaveBackup = $this->reloadAfterSave;
1372: try {
1373: $this->getModel()->reloadAfterSave = false;
1374: $this->save($data);
1375: } finally {
1376: $this->getModel()->reloadAfterSave = $reloadAfterSaveBackup;
1377: }
1378:
1379: $this->unload();
1380:
1381: return $this;
1382: }
1383:
1384: /**
1385: * Create new model from the same base class as $this.
1386: *
1387: * See https://github.com/atk4/data/issues/111 for use-case examples.
1388: *
1389: * @return static
1390: */
1391: public function withPersistence(Persistence $persistence)
1392: {
1393: $this->assertIsModel();
1394:
1395: $model = new static($persistence, ['table' => $this->table]);
1396:
1397: // include any fields defined inline
1398: foreach ($this->fields as $fieldName => $field) {
1399: if (!$model->hasField($fieldName)) {
1400: $model->addField($fieldName, clone $field);
1401: }
1402: }
1403:
1404: $model->limit = $this->limit;
1405: $model->order = $this->order;
1406: $model->scope = (clone $this->scope)->setModel($model);
1407:
1408: return $model;
1409: }
1410:
1411: /**
1412: * TODO https://github.com/atk4/data/issues/662.
1413: *
1414: * @return array<string, array{bool, mixed}>
1415: */
1416: private function temporaryMutateScopeFieldsBackup(): array
1417: {
1418: $res = [];
1419: $fields = $this->getFields();
1420: foreach ($fields as $k => $v) {
1421: $res[$k] = [$v->system, $v->default];
1422: }
1423:
1424: return $res;
1425: }
1426:
1427: /**
1428: * @param array<string, array{bool, mixed}> $backup
1429: */
1430: private function temporaryMutateScopeFieldsRestore(array $backup): void
1431: {
1432: $fields = $this->getFields();
1433: foreach ($fields as $k => $v) {
1434: [$v->system, $v->default] = $backup[$k];
1435: }
1436: }
1437:
1438: /**
1439: * @param AbstractScope|array<int, AbstractScope|Persistence\Sql\Expressionable|array{string|Persistence\Sql\Expressionable, 1?: mixed, 2?: mixed}>|string|Persistence\Sql\Expressionable $field
1440: * @param ($field is string|Persistence\Sql\Expressionable ? ($value is null ? mixed : string) : never) $operator
1441: * @param ($operator is string ? mixed : never) $value
1442: *
1443: * @return ($fromTryLoad is true ? static|null : static)
1444: */
1445: private function _loadBy(bool $fromTryLoad, $field, $operator = null, $value = null)
1446: {
1447: $this->assertIsModel();
1448:
1449: $scopeOrig = $this->scope;
1450: $fieldsBackup = $this->temporaryMutateScopeFieldsBackup();
1451: $this->scope = clone $this->scope;
1452: try {
1453: $this->addCondition(...array_slice('func_get_args'(), 1));
1454:
1455: return $this->{$fromTryLoad ? 'tryLoadOne' : 'loadOne'}();
1456: } finally {
1457: $this->scope = $scopeOrig;
1458: $this->temporaryMutateScopeFieldsRestore($fieldsBackup);
1459: }
1460: }
1461:
1462: /**
1463: * Load one record by additional condition. Will throw if more than one record exists.
1464: *
1465: * @param AbstractScope|array<int, AbstractScope|Persistence\Sql\Expressionable|array{string|Persistence\Sql\Expressionable, 1?: mixed, 2?: mixed}>|string|Persistence\Sql\Expressionable $field
1466: * @param ($field is string|Persistence\Sql\Expressionable ? ($value is null ? mixed : string) : never) $operator
1467: * @param ($operator is string ? mixed : never) $value
1468: *
1469: * @return static
1470: */
1471: public function loadBy($field, $operator = null, $value = null)
1472: {
1473: return $this->_loadBy(false, ...'func_get_args'());
1474: }
1475:
1476: /**
1477: * Try to load one record by additional condition. Will throw if more than one record exists, but not if there is no record.
1478: *
1479: * @param AbstractScope|array<int, AbstractScope|Persistence\Sql\Expressionable|array{string|Persistence\Sql\Expressionable, 1?: mixed, 2?: mixed}>|string|Persistence\Sql\Expressionable $field
1480: * @param ($field is string|Persistence\Sql\Expressionable ? ($value is null ? mixed : string) : never) $operator
1481: * @param ($operator is string ? mixed : never) $value
1482: *
1483: * @return static|null
1484: */
1485: public function tryLoadBy($field, $operator = null, $value = null)
1486: {
1487: return $this->_loadBy(true, ...'func_get_args'());
1488: }
1489:
1490: protected function validateEntityScope(): void
1491: {
1492: if (!$this->getModel()->scope()->isEmpty()) {
1493: $this->getModel()->getPersistence()->load($this->getModel(), $this->getId());
1494: }
1495: }
1496:
1497: private function assertIsWriteable(): void
1498: {
1499: if ($this->readOnly) {
1500: throw new Exception('Model is read-only');
1501: }
1502: }
1503:
1504: /**
1505: * Save record.
1506: *
1507: * @param array<string, mixed> $data
1508: *
1509: * @return $this
1510: */
1511: public function save(array $data = [])
1512: {
1513: $this->getModel()->assertIsWriteable();
1514: $this->getModel()->assertHasPersistence();
1515:
1516: $this->setMulti($data);
1517:
1518: return $this->atomic(function () {
1519: $errors = $this->validate('save');
1520: if ($errors !== []) {
1521: throw new ValidationException($errors, $this);
1522: }
1523: $isUpdate = $this->isLoaded();
1524: if ($this->hook(self::HOOK_BEFORE_SAVE, [$isUpdate]) === false) {
1525: return $this;
1526: }
1527:
1528: if (!$isUpdate) {
1529: $data = [];
1530: foreach ($this->get() as $name => $value) {
1531: $field = $this->getField($name);
1532: if ($field->readOnly || $field->neverPersist || $field->neverSave) {
1533: continue;
1534: }
1535:
1536: if (!$field->hasJoin()) {
1537: $data[$name] = $value;
1538: }
1539: }
1540:
1541: if ($this->hook(self::HOOK_BEFORE_INSERT, [&$data]) === false) {
1542: return $this;
1543: }
1544:
1545: $id = $this->getModel()->getPersistence()->insert($this->getModel(), $data);
1546: if ($this->idField) {
1547: $this->setId($id, false);
1548: }
1549:
1550: $this->hook(self::HOOK_AFTER_INSERT);
1551: } else {
1552: $data = [];
1553: $dirtyJoin = false;
1554: foreach ($this->get() as $name => $value) {
1555: if (!array_key_exists($name, $this->getDirtyRef())) {
1556: continue;
1557: }
1558:
1559: $field = $this->getField($name);
1560: if ($field->readOnly || $field->neverPersist || $field->neverSave) {
1561: continue;
1562: }
1563:
1564: if ($field->hasJoin()) {
1565: $dirtyJoin = true;
1566: } else {
1567: $data[$name] = $value;
1568: }
1569: }
1570:
1571: // no save needed, nothing was changed
1572: if (count($data) === 0 && !$dirtyJoin) {
1573: return $this;
1574: }
1575:
1576: if ($this->hook(self::HOOK_BEFORE_UPDATE, [&$data]) === false) {
1577: return $this;
1578: }
1579: $this->validateEntityScope();
1580: $this->getModel()->getPersistence()->update($this->getModel(), $this->getId(), $data);
1581: $this->hook(self::HOOK_AFTER_UPDATE, [&$data]);
1582: }
1583:
1584: $dirtyRef = &$this->getDirtyRef();
1585: $dirtyRef = [];
1586:
1587: if ($this->idField && $this->reloadAfterSave) {
1588: $this->reload();
1589: }
1590:
1591: $this->hook(self::HOOK_AFTER_SAVE, [$isUpdate]);
1592:
1593: if ($this->idField) {
1594: $this->validateEntityScope();
1595: }
1596:
1597: return $this;
1598: });
1599: }
1600:
1601: /**
1602: * @param array<string, mixed> $row
1603: */
1604: protected function _insert(array $row): void
1605: {
1606: // find any row values that do not correspond to fields, they may correspond to references instead
1607: $refs = [];
1608: foreach ($row as $key => $value) {
1609: if (!is_array($value) || !$this->hasReference($key)) {
1610: continue;
1611: }
1612:
1613: // then we move value for later
1614: $refs[$key] = $value;
1615: unset($row[$key]);
1616: }
1617:
1618: // save data fields
1619: $reloadAfterSaveBackup = $this->reloadAfterSave;
1620: try {
1621: $this->getModel()->reloadAfterSave = false;
1622: $this->save($row);
1623: } finally {
1624: $this->getModel()->reloadAfterSave = $reloadAfterSaveBackup;
1625: }
1626:
1627: // if there was referenced data, then import it
1628: foreach ($refs as $key => $value) {
1629: $this->ref($key)->import($value);
1630: }
1631: }
1632:
1633: /**
1634: * @param array<string, mixed> $row
1635: *
1636: * @return mixed
1637: */
1638: public function insert(array $row)
1639: {
1640: $entity = $this->createEntity();
1641:
1642: $hasRefs = false;
1643: foreach ($row as $v) {
1644: if (is_array($v)) {
1645: $hasRefs = true;
1646:
1647: break;
1648: }
1649: }
1650:
1651: if (!$hasRefs) {
1652: $entity->_insert($row);
1653: } else {
1654: $this->atomic(static function () use ($entity, $row) {
1655: $entity->_insert($row);
1656: });
1657: }
1658:
1659: return $this->idField ? $entity->getId() : null;
1660: }
1661:
1662: /**
1663: * @param array<int, array<string, mixed>> $rows
1664: *
1665: * @return $this
1666: */
1667: public function import(array $rows)
1668: {
1669: if (count($rows) === 1) {
1670: $this->insert(reset($rows));
1671: } elseif (count($rows) !== 0) {
1672: $this->atomic(function () use ($rows) {
1673: foreach ($rows as $row) {
1674: $this->insert($row);
1675: }
1676: });
1677: }
1678:
1679: return $this;
1680: }
1681:
1682: /**
1683: * Export DataSet as array of hashes.
1684: *
1685: * @param array<int, string>|null $fields Names of fields to export
1686: * @param string $keyField Optional name of field which value we will use as array key
1687: * @param bool $typecast Should we typecast exported data
1688: *
1689: * @return ($keyField is string ? array<mixed, array<string, mixed>> : array<int, array<string, mixed>>)
1690: */
1691: public function export(array $fields = null, string $keyField = null, bool $typecast = true): array
1692: {
1693: $this->assertHasPersistence('export');
1694:
1695: // no key field - then just do export
1696: if ($keyField === null) {
1697: // TODO this optimization should be removed in favor of one Persistence::export call and php calculated fields should be exported as well
1698: return $this->getPersistence()->export($this, $fields, $typecast);
1699: }
1700:
1701: // do we have added key field in fields list?
1702: // if so, then will have to remove it afterwards
1703: $keyFieldAdded = false;
1704:
1705: // prepare array with field names
1706: if ($fields === null) {
1707: $fields = [];
1708:
1709: if ($this->onlyFields !== null) {
1710: // add requested fields first
1711: foreach ($this->onlyFields as $field) {
1712: $fObject = $this->getField($field);
1713: if ($fObject->neverPersist) {
1714: continue;
1715: }
1716: $fields[$field] = true;
1717: }
1718:
1719: // now add system fields, if they were not added
1720: foreach ($this->getFields() as $field => $fObject) {
1721: if ($fObject->neverPersist) {
1722: continue;
1723: }
1724: if ($fObject->system && !isset($fields[$field])) {
1725: $fields[$field] = true;
1726: }
1727: }
1728:
1729: $fields = array_keys($fields);
1730: } else {
1731: // add all model fields
1732: foreach ($this->getFields() as $field => $fObject) {
1733: if ($fObject->neverPersist) {
1734: continue;
1735: }
1736: $fields[] = $field;
1737: }
1738: }
1739: }
1740:
1741: // add $keyField to array if it's not there
1742: if (!in_array($keyField, $fields, true)) {
1743: $fields[] = $keyField;
1744: $keyFieldAdded = true;
1745: }
1746:
1747: // export
1748: $data = $this->getPersistence()->export($this, $fields, $typecast);
1749:
1750: // prepare resulting array
1751: $res = [];
1752: foreach ($data as $row) {
1753: $key = $row[$keyField];
1754: if ($keyFieldAdded) {
1755: unset($row[$keyField]);
1756: }
1757: $res[$key] = $row;
1758: }
1759:
1760: return $res;
1761: }
1762:
1763: /**
1764: * Create iterator (yield values).
1765: *
1766: * You can return false in afterLoad hook to prevent to yield this data row, example:
1767: * $model->onHook(self::HOOK_AFTER_LOAD, static function (Model $m) {
1768: * if ($m->get('date') < $m->dateFrom) {
1769: * $m->breakHook(false);
1770: * }
1771: * })
1772: *
1773: * You can also use breakHook() with specific object which will then be returned
1774: * as a next iterator value.
1775: *
1776: * @return \Traversable<static>
1777: */
1778: #[\Override]
1779: final public function getIterator(): \Traversable
1780: {
1781: return $this->createIteratorBy([]);
1782: }
1783:
1784: /**
1785: * Create iterator (yield values) by additional condition.
1786: *
1787: * @param AbstractScope|array<int, AbstractScope|Persistence\Sql\Expressionable|array{string|Persistence\Sql\Expressionable, 1?: mixed, 2?: mixed}>|string|Persistence\Sql\Expressionable $field
1788: * @param ($field is string|Persistence\Sql\Expressionable ? ($value is null ? mixed : string) : never) $operator
1789: * @param ($operator is string ? mixed : never) $value
1790: *
1791: * @return \Traversable<static>
1792: */
1793: public function createIteratorBy($field, $operator = null, $value = null): \Traversable
1794: {
1795: $this->assertIsModel();
1796:
1797: $scopeOrig = null;
1798: if ((!is_array($field) || count($field) > 0) || $operator !== null || $value !== null) {
1799: $scopeOrig = $this->scope;
1800: $fieldsBackup = $this->temporaryMutateScopeFieldsBackup();
1801: $this->scope = clone $this->scope;
1802: }
1803: try {
1804: if ($scopeOrig !== null) {
1805: $this->addCondition(...'func_get_args'());
1806: }
1807:
1808: foreach ($this->getPersistence()->prepareIterator($this) as $data) {
1809: if ($scopeOrig !== null) {
1810: $this->scope = $scopeOrig;
1811: $scopeOrig = null;
1812: $this->temporaryMutateScopeFieldsRestore($fieldsBackup); // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/9685
1813: }
1814:
1815: $entity = $this->createEntity();
1816:
1817: $dataRef = &$entity->getDataRef();
1818: $dataRef = $this->getPersistence()->typecastLoadRow($this, $data);
1819: if ($this->idField) {
1820: $entity->setId($dataRef[$this->idField], false);
1821: }
1822:
1823: $res = $entity->hook(self::HOOK_AFTER_LOAD);
1824: if ($res === false) {
1825: continue;
1826: } elseif (is_object($res)) {
1827: $res = (static::class)::assertInstanceOf($res);
1828: $res->assertIsEntity();
1829: } else {
1830: $res = $entity;
1831: }
1832:
1833: if ($res->getModel()->idField) {
1834: yield $res->getId() => $res;
1835: } else {
1836: yield $res;
1837: }
1838: }
1839: } finally {
1840: if ($scopeOrig !== null) {
1841: $this->scope = $scopeOrig;
1842: $scopeOrig = null;
1843: $this->temporaryMutateScopeFieldsRestore($fieldsBackup); // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/9685
1844: }
1845: }
1846: }
1847:
1848: /**
1849: * Delete record with a specified id. If no ID is specified
1850: * then current record is deleted.
1851: *
1852: * @param mixed $id
1853: *
1854: * @return static
1855: */
1856: public function delete($id = null)
1857: {
1858: if ($id !== null) {
1859: $this->assertIsModel();
1860:
1861: $this->load($id)->delete();
1862:
1863: return $this;
1864: }
1865:
1866: $this->getModel()->assertIsWriteable();
1867: $this->getModel()->assertHasPersistence();
1868: $this->assertIsLoaded();
1869:
1870: $this->atomic(function () {
1871: if ($this->hook(self::HOOK_BEFORE_DELETE) === false) {
1872: return;
1873: }
1874: $this->validateEntityScope();
1875: $this->getModel()->getPersistence()->delete($this->getModel(), $this->getId());
1876: $this->hook(self::HOOK_AFTER_DELETE);
1877: });
1878: $this->unload();
1879:
1880: return $this;
1881: }
1882:
1883: /**
1884: * Atomic executes operations within one begin/end transaction, so if
1885: * the code inside callback will fail, then all of the transaction
1886: * will be also rolled back.
1887: *
1888: * @template T
1889: *
1890: * @param \Closure(): T $fx
1891: *
1892: * @return T
1893: */
1894: public function atomic(\Closure $fx)
1895: {
1896: try {
1897: return $this->getModel(true)->getPersistence()->atomic($fx);
1898: } catch (\Throwable $e) {
1899: if ($this->hook(self::HOOK_ROLLBACK, [$e]) === false) {
1900: return false;
1901: }
1902:
1903: throw $e;
1904: }
1905: }
1906:
1907: // }}}
1908:
1909: // {{{ Support for actions
1910:
1911: /**
1912: * Create persistence action.
1913: *
1914: * TODO Rename this method to stress this method should not be used
1915: * for anything else then reading records as insert/update/delete hooks
1916: * will not be called.
1917: *
1918: * @param array<mixed> $args
1919: *
1920: * @return Persistence\Sql\Query
1921: */
1922: public function action(string $mode, array $args = [])
1923: {
1924: $this->getModel(true)->assertHasPersistence('action');
1925:
1926: return $this->getModel(true)->getPersistence()->action($this, $mode, $args);
1927: }
1928:
1929: public function executeCountQuery(): int
1930: {
1931: $this->assertIsModel();
1932:
1933: $res = $this->action('count')->getOne();
1934: if (is_string($res) && $res === (string) (int) $res) {
1935: $res = (int) $res;
1936: }
1937:
1938: return $res;
1939: }
1940:
1941: /**
1942: * Add expression field.
1943: *
1944: * @param array{'expr': mixed} $seed
1945: *
1946: * @return CallbackField|SqlExpressionField
1947: */
1948: public function addExpression(string $name, $seed)
1949: {
1950: /** @var CallbackField|SqlExpressionField */
1951: $field = Field::fromSeed($this->_defaultSeedAddExpression, $seed);
1952:
1953: $this->addField($name, $field);
1954:
1955: return $field;
1956: }
1957:
1958: /**
1959: * Add expression field which will calculate its value by using callback.
1960: *
1961: * @template T of self
1962: *
1963: * @param array{'expr': \Closure(T): mixed} $seed
1964: *
1965: * @return CallbackField
1966: */
1967: public function addCalculatedField(string $name, $seed)
1968: {
1969: $field = new CallbackField($seed);
1970:
1971: $this->addField($name, $field);
1972:
1973: return $field;
1974: }
1975:
1976: public function __isset(string $name): bool
1977: {
1978: $model = $this->getModel(true);
1979:
1980: if (isset($model->getHintableProps()[$name])) {
1981: return $this->__hintable_isset($name);
1982: }
1983:
1984: if ($this->isEntity() && isset($model->getModelOnlyProperties()[$name])) {
1985: return isset($model->{$name});
1986: }
1987:
1988: return $this->__di_isset($name);
1989: }
1990:
1991: /**
1992: * @return mixed
1993: */
1994: public function &__get(string $name)
1995: {
1996: $model = $this->getModel(true);
1997:
1998: if (isset($model->getHintableProps()[$name])) {
1999: return $this->__hintable_get($name);
2000: }
2001:
2002: if ($this->isEntity() && isset($model->getModelOnlyProperties()[$name])) {
2003: return $model->{$name};
2004: }
2005:
2006: return $this->__di_get($name);
2007: }
2008:
2009: /**
2010: * @param mixed $value
2011: */
2012: public function __set(string $name, $value): void
2013: {
2014: $model = $this->getModel(true);
2015:
2016: if (isset($model->getHintableProps()[$name])) {
2017: $this->__hintable_set($name, $value);
2018:
2019: return;
2020: }
2021:
2022: if ($this->isEntity() && isset($model->getModelOnlyProperties()[$name])) {
2023: $this->assertIsModel();
2024: }
2025:
2026: $this->__di_set($name, $value);
2027: }
2028:
2029: public function __unset(string $name): void
2030: {
2031: $model = $this->getModel(true);
2032:
2033: if (isset($model->getHintableProps()[$name])) {
2034: $this->__hintable_unset($name);
2035:
2036: return;
2037: }
2038:
2039: if ($this->isEntity() && isset($model->getModelOnlyProperties()[$name])) {
2040: $this->assertIsModel();
2041: }
2042:
2043: $this->__di_unset($name);
2044: }
2045:
2046: /**
2047: * @return array<string, mixed>
2048: */
2049: public function __debugInfo(): array
2050: {
2051: if ($this->isEntity()) {
2052: return [
2053: 'entityId' => $this->idField && $this->hasField($this->idField)
2054: ? ($this->_entityId !== null ? $this->_entityId . ($this->getId() !== null ? '' : ' (unloaded)') : 'null')
2055: : 'no id field',
2056: 'model' => $this->getModel()->__debugInfo(),
2057: ];
2058: }
2059:
2060: return [
2061: 'table' => $this->table,
2062: 'scope' => $this->scope()->toWords(),
2063: ];
2064: }
2065: }
2066: