1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Model;
6:
7: use Atk4\Core\DiContainerTrait;
8: use Atk4\Core\Factory;
9: use Atk4\Core\InitializerTrait;
10: use Atk4\Core\TrackableTrait;
11: use Atk4\Data\Exception;
12: use Atk4\Data\Field;
13: use Atk4\Data\Model;
14: use Atk4\Data\Reference;
15:
16: /**
17: * Provides generic functionality for joining data.
18: *
19: * @method Model getOwner()
20: */
21: abstract class Join
22: {
23: use DiContainerTrait;
24: use InitializerTrait {
25: init as private _init;
26: }
27: use JoinLinkTrait;
28: use TrackableTrait {
29: setOwner as private _setOwner;
30: }
31:
32: /** Foreign table or WITH/CTE alias when used with SQL persistence. */
33: protected string $foreignTable;
34:
35: /** Alias for the joined table. */
36: public ?string $foreignAlias = null;
37:
38: /**
39: * By default this will be either "inner" (for strong) or "left" for weak joins.
40: * You can specify your own type of join like "right".
41: */
42: protected ?string $kind = null;
43:
44: /** Weak join does not update foreign table. */
45: public bool $weak = false;
46:
47: /** Foreign table is updated using fake model and thus no regular foreign model hooks are invoked. */
48: public bool $allowDangerousForeignTableUpdate = false;
49:
50: /**
51: * Normally the foreign table is saved first, then it's ID is used in the
52: * primary table. When deleting, the primary table record is deleted first
53: * which is followed by the foreign table record.
54: *
55: * If you are using the following syntax:
56: *
57: * $user->join('contact', 'default_contact_id')
58: *
59: * Then the ID connecting tables is stored in foreign table and the order
60: * of saving and delete needs to be reversed. In this case $reverse
61: * will be set to `true`. You can specify value of this property.
62: */
63: public bool $reverse = false;
64:
65: /**
66: * Field to be used for matching inside master table.
67: * By default it's $foreignTable . '_id'.
68: */
69: public ?string $masterField = null;
70:
71: /**
72: * Field to be used for matching in a foreign table.
73: * By default it's 'id'.
74: */
75: public ?string $foreignField = null;
76:
77: /**
78: * Field to be used as foreign model ID field.
79: * By default it's 'id'.
80: */
81: public ?string $foreignIdField = null;
82:
83: /**
84: * When $prefix is set, then all the fields generated through
85: * our wrappers will be automatically prefixed inside the model.
86: */
87: protected string $prefix = '';
88:
89: /** @var array<int, array<string, mixed>> Data indexed by spl_object_id(entity) which is populated here as the save/insert progresses. */
90: private array $saveBufferByOid = [];
91:
92: public function __construct(string $foreignTable)
93: {
94: $this->foreignTable = $foreignTable;
95:
96: // handle foreign table containing a dot - that will be reverse join
97: // TODO this table split condition makes JoinArrayTest::testForeignFieldNameGuessTableWithSchema test
98: // quite inconsistent - drop it?
99: if (str_contains($this->foreignTable, '.')) {
100: // split by LAST dot in foreignTable name
101: [$this->foreignTable, $this->foreignField] = preg_split('~\.(?=[^.]+$)~', $this->foreignTable);
102: $this->reverse = true;
103: }
104: }
105:
106: /**
107: * @internal should be not used outside atk4/data, for Migrator only
108: */
109: public function getMasterField(): Field
110: {
111: if (!$this->hasJoin()) {
112: return $this->getOwner()->getField($this->masterField);
113: }
114:
115: // TODO this should be not needed in the future
116: $fakeModel = new Model($this->getOwner()->getPersistence(), [
117: 'table' => $this->getJoin()->foreignTable,
118: 'idField' => $this->masterField,
119: ]);
120:
121: return $fakeModel->getField($this->masterField);
122: }
123:
124: /**
125: * Create fake foreign model, in the future, this method should be removed
126: * in favor of always requiring an object model.
127: */
128: protected function createFakeForeignModel(): Model
129: {
130: $fakeModel = new Model($this->getOwner()->getPersistence(), [
131: 'table' => $this->foreignTable,
132: 'idField' => $this->foreignIdField,
133: 'readOnly' => !$this->allowDangerousForeignTableUpdate,
134: ]);
135: foreach ($this->getOwner()->getFields() as $ownerField) {
136: if ($ownerField->hasJoin() && $ownerField->getJoin()->shortName === $this->shortName) {
137: $ownerFieldPersistenceName = $ownerField->getPersistenceName();
138: if ($ownerFieldPersistenceName !== $fakeModel->idField && $ownerFieldPersistenceName !== $this->foreignField) {
139: $fakeModel->addField($ownerFieldPersistenceName, [
140: 'type' => $ownerField->type,
141: ]);
142: }
143: }
144: }
145: if ($fakeModel->idField !== $this->foreignField) {
146: $fakeModel->addField($this->foreignField, ['type' => 'integer']);
147: }
148:
149: return $fakeModel;
150: }
151:
152: public function getForeignModel(): Model
153: {
154: // TODO this should be removed in the future
155: if (!isset($this->getOwner()->cteModels[$this->foreignTable])) {
156: return $this->createFakeForeignModel();
157: }
158:
159: return $this->getOwner()->cteModels[$this->foreignTable]['model'];
160: }
161:
162: /**
163: * @param Model $owner
164: *
165: * @return $this
166: */
167: public function setOwner(object $owner)
168: {
169: $owner->assertIsModel();
170:
171: return $this->_setOwner($owner);
172: }
173:
174: /**
175: * @template T of Model
176: *
177: * @param \Closure(T, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): mixed $fx
178: * @param array<int, mixed> $args
179: */
180: protected function onHookToOwnerBoth(string $spot, \Closure $fx, array $args = [], int $priority = 5): int
181: {
182: $name = $this->shortName; // use static function to allow this object to be GCed
183:
184: return $this->getOwner()->onHookDynamic(
185: $spot,
186: static function (Model $model) use ($name): self {
187: /** @var self */
188: $obj = $model->getModel(true)->getElement($name);
189: $model->getModel(true)->assertIsModel($obj->getOwner());
190:
191: return $obj;
192: },
193: $fx,
194: $args,
195: $priority
196: );
197: }
198:
199: /**
200: * @template T of Model
201: *
202: * @param \Closure(T, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): mixed $fx
203: * @param array<int, mixed> $args
204: */
205: protected function onHookToOwnerEntity(string $spot, \Closure $fx, array $args = [], int $priority = 5): int
206: {
207: $name = $this->shortName; // use static function to allow this object to be GCed
208:
209: return $this->getOwner()->onHookDynamic(
210: $spot,
211: static function (Model $entity) use ($name): self {
212: /** @var self */
213: $obj = $entity->getModel()->getElement($name);
214: $entity->assertIsEntity($obj->getOwner());
215:
216: return $obj;
217: },
218: $fx,
219: $args,
220: $priority
221: );
222: }
223:
224: private function getModelTableString(Model $model): string
225: {
226: if (is_object($model->table)) {
227: return $this->getModelTableString($model->table);
228: }
229:
230: return $model->table;
231: }
232:
233: /**
234: * Will use either foreignAlias or #join-<table>.
235: */
236: public function getDesiredName(): string
237: {
238: return '#join-' . ($this->foreignAlias ?? $this->foreignTable);
239: }
240:
241: protected function init(): void
242: {
243: $this->_init();
244:
245: $idField = $this->getOwner()->idField;
246: if (!is_string($idField)) {
247: throw (new Exception('Join owner model must have idField set'))
248: ->addMoreInfo('model', $this->getOwner());
249: }
250:
251: if ($this->masterField === null) {
252: $this->masterField = $this->reverse
253: ? $idField
254: : $this->foreignTable . '_' . $idField;
255: }
256:
257: if ($this->foreignField === null) {
258: $this->foreignField = $this->reverse
259: ? preg_replace('~^.+\.~s', '', $this->getModelTableString($this->getOwner())) . '_' . $idField
260: : $idField;
261: }
262:
263: if ($this->kind === null) {
264: $this->kind = $this->weak ? 'left' : 'inner';
265: }
266:
267: $this->getForeignModel(); // assert valid foreignTable
268:
269: if ($this->reverse && $this->masterField !== $idField) { // TODO not implemented yet, see https://github.com/atk4/data/issues/803
270: throw (new Exception('Joining tables on non-id fields is not implemented yet'))
271: ->addMoreInfo('masterField', $this->masterField)
272: ->addMoreInfo('idField', $idField);
273: }
274:
275: $this->initJoinHooks();
276: }
277:
278: protected function initJoinHooks(): void
279: {
280: $this->onHookToOwnerEntity(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad']));
281:
282: $createHookFxWithCleanup = function (string $methodName): \Closure {
283: return function (Model $entity, &...$args) use ($methodName): void {
284: try {
285: $this->{$methodName}($entity, ...$args);
286: } finally {
287: $this->unsetSaveBuffer($entity);
288: }
289: };
290: };
291:
292: if ($this->reverse) {
293: $this->onHookToOwnerEntity(Model::HOOK_AFTER_INSERT, $createHookFxWithCleanup('afterInsert'), [], 2);
294: $this->onHookToOwnerEntity(Model::HOOK_BEFORE_UPDATE, $createHookFxWithCleanup('beforeUpdate'), [], -5);
295: $this->onHookToOwnerEntity(Model::HOOK_BEFORE_DELETE, $createHookFxWithCleanup('afterDelete'), [], -5);
296: } else {
297: $this->onHookToOwnerEntity(Model::HOOK_BEFORE_INSERT, $createHookFxWithCleanup('beforeInsert'), [], -5);
298: $this->onHookToOwnerEntity(Model::HOOK_BEFORE_UPDATE, $createHookFxWithCleanup('beforeUpdate'), [], -5);
299: $this->onHookToOwnerEntity(Model::HOOK_AFTER_DELETE, $createHookFxWithCleanup('afterDelete'), [], 2);
300: }
301: }
302:
303: private function getJoinNameFromShortName(): string
304: {
305: return str_starts_with($this->shortName, '#join-') ? substr($this->shortName, 6) : null;
306: }
307:
308: /**
309: * Adding field into join will automatically associate that field
310: * with this join. That means it won't be loaded from $table, but
311: * form the join instead.
312: *
313: * @param array<mixed> $seed
314: */
315: public function addField(string $name, array $seed = []): Field
316: {
317: $seed['joinName'] = $this->getJoinNameFromShortName();
318: if ($this->prefix) {
319: $seed['actual'] ??= $name;
320: }
321:
322: return $this->getOwner()->addField($this->prefix . $name, $seed);
323: }
324:
325: /**
326: * Adds multiple fields.
327: *
328: * @param array<string, array<mixed>>|array<int, string> $fields
329: * @param array<mixed> $seed
330: *
331: * @return $this
332: */
333: public function addFields(array $fields = [], array $seed = [])
334: {
335: foreach ($fields as $k => $v) {
336: if (is_int($k)) {
337: $k = $v;
338: $v = [];
339: }
340:
341: $this->addField($k, Factory::mergeSeeds($v, $seed));
342: }
343:
344: return $this;
345: }
346:
347: /**
348: * Another join will be attached to a current join.
349: *
350: * @param array<string, mixed> $defaults
351: */
352: public function join(string $foreignTable, array $defaults = []): self
353: {
354: $defaults['joinName'] = $this->getJoinNameFromShortName();
355:
356: return $this->getOwner()->join($foreignTable, $defaults);
357: }
358:
359: /**
360: * Another leftJoin will be attached to a current join.
361: *
362: * @param array<string, mixed> $defaults
363: */
364: public function leftJoin(string $foreignTable, array $defaults = []): self
365: {
366: $defaults['joinName'] = $this->getJoinNameFromShortName();
367:
368: return $this->getOwner()->leftJoin($foreignTable, $defaults);
369: }
370:
371: /**
372: * Creates reference based on a field from the join.
373: *
374: * @param array<string, mixed> $defaults
375: *
376: * @return Reference\HasOne
377: */
378: public function hasOne(string $link, array $defaults = [])
379: {
380: $defaults['joinName'] = $this->getJoinNameFromShortName();
381:
382: return $this->getOwner()->hasOne($link, $defaults);
383: }
384:
385: /**
386: * Creates reference based on the field from the join.
387: *
388: * @param array<string, mixed> $defaults
389: *
390: * @return Reference\HasMany
391: */
392: public function hasMany(string $link, array $defaults = [])
393: {
394: return $this->getOwner()->hasMany($link, $defaults);
395: }
396:
397: /*
398: /**
399: * Wrapper for ContainsOne that will associate field with join.
400: *
401: * @TODO NOT IMPLEMENTED!
402: *
403: * @param array<string, mixed> $defaults
404: *
405: * @return Reference\ContainsOne
406: *X/
407: public function containsOne(string $link, array $defaults = []) // : Reference
408: {
409: $defaults['joinName'] = $this->getJoinNameFromShortName();
410:
411: return $this->getOwner()->containsOne($link, $defaults);
412: }
413:
414: /**
415: * Wrapper for ContainsMany that will associate field with join.
416: *
417: * @TODO NOT IMPLEMENTED!
418: *
419: * @param array<string, mixed> $defaults
420: *
421: * @return Reference\ContainsMany
422: *X/
423: public function containsMany(string $link, array $defaults = []) // : Reference
424: {
425: return $this->getOwner()->containsMany($link, $defaults);
426: }
427: */
428:
429: /**
430: * @return list<self>
431: */
432: public function getReverseJoins(): array
433: {
434: $res = [];
435: foreach ($this->getOwner()->getJoins() as $join) {
436: if ($join->hasJoin() && $join->getJoin()->shortName === $this->shortName) {
437: $res[] = $join;
438: }
439: }
440:
441: return $res;
442: }
443:
444: /**
445: * @param mixed $value
446: */
447: protected function assertReferenceIdNotNull($value): void
448: {
449: if ($value === null) {
450: throw (new Exception('Unable to join on null value'))
451: ->addMoreInfo('value', $value);
452: }
453: }
454:
455: /**
456: * @internal should be not used outside atk4/data
457: */
458: protected function issetSaveBuffer(Model $entity): bool
459: {
460: return isset($this->saveBufferByOid[spl_object_id($entity)]);
461: }
462:
463: /**
464: * @return array<string, mixed>
465: *
466: * @internal should be not used outside atk4/data
467: */
468: protected function getAndUnsetReindexedSaveBuffer(Model $entity): array
469: {
470: $resOur = $this->saveBufferByOid[spl_object_id($entity)];
471: $this->unsetSaveBuffer($entity);
472:
473: $res = [];
474: foreach ($resOur as $k => $v) {
475: $res[$this->getOwner()->getField($k)->getPersistenceName()] = $v;
476: }
477:
478: return $res;
479: }
480:
481: /**
482: * @param mixed $value
483: */
484: protected function setSaveBufferValue(Model $entity, string $fieldName, $value): void
485: {
486: $entity->assertIsEntity($this->getOwner());
487:
488: if (!isset($this->saveBufferByOid[spl_object_id($entity)])) {
489: $this->saveBufferByOid[spl_object_id($entity)] = [];
490: }
491:
492: $this->saveBufferByOid[spl_object_id($entity)][$fieldName] = $value;
493: }
494:
495: /**
496: * @internal should be not used outside atk4/data
497: */
498: protected function unsetSaveBuffer(Model $entity): void
499: {
500: unset($this->saveBufferByOid[spl_object_id($entity)]);
501: }
502:
503: protected function afterLoad(Model $entity): void {}
504:
505: protected function initSaveBuffer(Model $entity, bool $fromUpdate): void
506: {
507: foreach ($entity->get() as $name => $value) {
508: $field = $entity->getField($name);
509: if (!$field->hasJoin() || $field->getJoin()->shortName !== $this->shortName || $field->readOnly || $field->neverPersist || $field->neverSave) {
510: continue;
511: }
512:
513: if ($fromUpdate && !$entity->isDirty($name)) {
514: continue;
515: }
516:
517: $field->getJoin()->setSaveBufferValue($entity, $name, $value);
518: }
519: }
520:
521: /**
522: * @return mixed
523: */
524: private function getForeignIdFromEntity(Model $entity)
525: {
526: // relies on https://github.com/atk4/data/blob/b3e9ea844e/src/Persistence/Sql/Join.php#L40
527: $foreignId = $this->reverse
528: ? ($this->hasJoin() ? $entity->get($this->foreignField) : $entity->getId())
529: : $entity->get($this->masterField);
530:
531: return $foreignId;
532: }
533:
534: /**
535: * @param array<string, mixed> $data
536: */
537: protected function beforeInsert(Model $entity, array &$data): void
538: {
539: if ($this->weak) {
540: return;
541: }
542:
543: $this->initSaveBuffer($entity, false);
544:
545: // the value for the masterField is set, so we are going to use existing record anyway
546: if ($entity->get($this->masterField) !== null) {
547: return;
548: }
549:
550: $foreignModel = $this->getForeignModel();
551: $foreignEntity = $foreignModel->createEntity()
552: ->setMulti($this->getAndUnsetReindexedSaveBuffer($entity))
553: ->setNull($this->foreignField);
554: $foreignEntity->save();
555:
556: $foreignId = $foreignEntity->get($this->foreignField);
557: $this->assertReferenceIdNotNull($foreignId);
558:
559: if ($this->hasJoin()) {
560: $this->getJoin()->setSaveBufferValue($entity, $this->masterField, $foreignId);
561: } else {
562: $data[$this->masterField] = $foreignId;
563: }
564: }
565:
566: /**
567: * @param mixed $value
568: */
569: private function setEntityValueAfterUpdate(Model $entity, string $field, $value): void
570: {
571: $entity->getField($field); // assert field exists
572:
573: $entity->getDataRef()[$field] = $value;
574: }
575:
576: protected function afterInsert(Model $entity): void
577: {
578: if ($this->weak) {
579: return;
580: }
581:
582: $this->initSaveBuffer($entity, false);
583:
584: $foreignId = $this->getForeignIdFromEntity($entity);
585: $this->assertReferenceIdNotNull($foreignId);
586:
587: $foreignModel = $this->getForeignModel();
588: $foreignEntity = $foreignModel->createEntity()
589: ->setMulti($this->getAndUnsetReindexedSaveBuffer($entity))
590: ->set($this->foreignField, $foreignId);
591: $foreignEntity->save();
592:
593: foreach ($this->getReverseJoins() as $reverseJoin) {
594: // relies on https://github.com/atk4/data/blob/b3e9ea844e/src/Persistence/Sql/Join.php#L40
595: $this->setEntityValueAfterUpdate($entity, $reverseJoin->foreignField, $foreignEntity->get($this->masterField));
596: }
597: }
598:
599: /**
600: * @param array<string, mixed> $data
601: */
602: protected function beforeUpdate(Model $entity, array &$data): void
603: {
604: if ($this->weak) {
605: return;
606: }
607:
608: $this->initSaveBuffer($entity, true);
609:
610: if (!$this->issetSaveBuffer($entity)) {
611: return;
612: }
613:
614: $foreignModel = $this->getForeignModel();
615: $foreignId = $this->getForeignIdFromEntity($entity);
616: $this->assertReferenceIdNotNull($foreignId);
617: $saveBuffer = $this->getAndUnsetReindexedSaveBuffer($entity);
618: $foreignModel->atomic(function () use ($foreignModel, $foreignId, $saveBuffer) {
619: foreach ($foreignModel->createIteratorBy($this->foreignField, $foreignId) as $foreignEntity) {
620: $foreignEntity->setMulti($saveBuffer);
621: $foreignEntity->save();
622: }
623: });
624: }
625:
626: protected function afterDelete(Model $entity): void
627: {
628: if ($this->weak) {
629: return;
630: }
631:
632: $foreignModel = $this->getForeignModel();
633: $foreignId = $this->getForeignIdFromEntity($entity);
634: $this->assertReferenceIdNotNull($foreignId);
635: $foreignModel->atomic(function () use ($foreignModel, $foreignId) {
636: foreach ($foreignModel->createIteratorBy($this->foreignField, $foreignId) as $foreignEntity) {
637: $foreignEntity->delete();
638: }
639: });
640: }
641: }
642: