1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Reference;
6:
7: use Atk4\Data\Exception;
8: use Atk4\Data\Field\SqlExpressionField;
9: use Atk4\Data\Model;
10:
11: class HasOneSql extends HasOne
12: {
13: /**
14: * @param ($theirFieldIsTitle is true ? null : string) $theirFieldName
15: * @param array<string, mixed> $defaults
16: */
17: private function _addField(string $fieldName, bool $theirFieldIsTitle, ?string $theirFieldName, array $defaults): SqlExpressionField
18: {
19: $ourModel = $this->getOurModel(null);
20:
21: $fieldExpression = $ourModel->addExpression($fieldName, array_merge([
22: 'expr' => function (Model $ourModel) use ($theirFieldIsTitle, $theirFieldName) {
23: $theirModel = $ourModel->refLink($this->link);
24: if ($theirFieldIsTitle) {
25: $theirFieldName = $theirModel->titleField;
26: }
27:
28: // remove order if we just select one field from hasOne model, needed for Oracle
29: return $theirModel->action('field', [$theirFieldName])->reset('order');
30: },
31: ], $defaults, [
32: // allow to set our field value by an imported foreign field, but only when
33: // the our field value is null
34: 'readOnly' => false,
35: ]));
36:
37: $this->onHookToOurModel($ourModel, Model::HOOK_BEFORE_SAVE, function (Model $ourModel) use ($fieldName, $theirFieldIsTitle, $theirFieldName) {
38: if ($ourModel->isDirty($fieldName)) {
39: $theirModel = $this->createTheirModel();
40: if ($theirFieldIsTitle) {
41: $theirFieldName = $theirModel->titleField;
42: }
43:
44: // when our field is not null or dirty too, update nothing, but check if the imported
45: // field was changed to expected value implied by the relation
46: if ($ourModel->isDirty($this->getOurFieldName()) || $ourModel->get($this->getOurFieldName()) !== null) {
47: $importedFieldValue = $ourModel->get($fieldName);
48: $expectedTheirEntity = $theirModel->loadBy($this->getTheirFieldName($theirModel), $ourModel->get($this->getOurFieldName()));
49: if (!$expectedTheirEntity->compare($theirFieldName, $importedFieldValue)) {
50: throw (new Exception('Imported field was changed to an unexpected value'))
51: ->addMoreInfo('ourFieldName', $this->getOurFieldName())
52: ->addMoreInfo('theirFieldName', $this->getTheirFieldName($theirModel))
53: ->addMoreInfo('importedFieldName', $fieldName)
54: ->addMoreInfo('sourceFieldName', $theirFieldName)
55: ->addMoreInfo('importedFieldValue', $importedFieldValue)
56: ->addMoreInfo('sourceFieldValue', $expectedTheirEntity->get($theirFieldName));
57: }
58: } else {
59: $newTheirEntity = $theirModel->loadBy($theirFieldName, $ourModel->get($fieldName));
60: $ourModel->set($this->getOurFieldName(), $newTheirEntity->get($this->getTheirFieldName($theirModel)));
61: $ourModel->_unset($fieldName);
62: }
63: }
64: }, [], 20);
65:
66: return $fieldExpression;
67: }
68:
69: /**
70: * Creates expression which sub-selects a field inside related model.
71: *
72: * @param array<string, mixed> $defaults
73: */
74: public function addField(string $fieldName, string $theirFieldName = null, array $defaults = []): SqlExpressionField
75: {
76: if ($theirFieldName === null) {
77: $theirFieldName = $fieldName;
78: }
79:
80: $ourModel = $this->getOurModel(null);
81:
82: // if caption/type is not defined in $defaults -> get it directly from the linked model field $theirFieldName
83: $refModelField = $ourModel->refModel($this->link)->getField($theirFieldName);
84: $defaults['type'] ??= $refModelField->type;
85: $defaults['enum'] ??= $refModelField->enum;
86: $defaults['values'] ??= $refModelField->values;
87: $defaults['caption'] ??= $refModelField->caption;
88: $defaults['ui'] = array_merge($defaults['ui'] ?? $refModelField->ui, ['editable' => false]);
89:
90: $fieldExpression = $this->_addField($fieldName, false, $theirFieldName, $defaults);
91:
92: return $fieldExpression;
93: }
94:
95: /**
96: * Add multiple expressions by calling addField several times. Fields
97: * may contain 3 types of elements:.
98: *
99: * ['name', 'surname'] - will import those fields as-is
100: * ['full_name' => 'name', 'day_of_birth' => ['dob', 'type' => 'date']] - use alias and options
101: * [['dob', 'type' => 'date']] - use options
102: *
103: * @param array<string, array<mixed>>|array<int, string> $fields
104: * @param array<string, mixed> $defaults
105: *
106: * @return $this
107: */
108: public function addFields(array $fields = [], array $defaults = [])
109: {
110: foreach ($fields as $ourFieldName => $ourFieldDefaults) {
111: $ourFieldDefaults = array_merge($defaults, (array) $ourFieldDefaults);
112:
113: $theirFieldName = $ourFieldDefaults[0] ?? null;
114: unset($ourFieldDefaults[0]);
115: if (is_int($ourFieldName)) {
116: $ourFieldName = $theirFieldName;
117: }
118:
119: $this->addField($ourFieldName, $theirFieldName, $ourFieldDefaults);
120: }
121:
122: return $this;
123: }
124:
125: /**
126: * Creates model that can be used for generating sub-query actions.
127: *
128: * @param array<string, mixed> $defaults
129: */
130: public function refLink(Model $ourModel, array $defaults = []): Model
131: {
132: $theirModel = $this->createTheirModel($defaults);
133:
134: $theirModel->addCondition($this->getTheirFieldName($theirModel), $this->referenceOurValue());
135:
136: return $theirModel;
137: }
138:
139: #[\Override]
140: public function ref(Model $ourModel, array $defaults = []): Model
141: {
142: $theirModel = parent::ref($ourModel, $defaults);
143: $ourModel = $this->getOurModel($ourModel);
144:
145: if ($ourModel->isEntity() && $this->getOurFieldValue($ourModel) !== null) {
146: // materialized condition already added in parent/HasOne class
147: } else {
148: // handle deep traversal using an expression
149: $ourFieldExpression = $ourModel->action('field', [$this->getOurField()]);
150:
151: $theirModel->getModel(true)
152: ->addCondition($this->getTheirFieldName($theirModel), $ourFieldExpression);
153: }
154:
155: return $theirModel;
156: }
157:
158: /**
159: * Add a title of related entity as expression to our field.
160: *
161: * $order->hasOne('user_id', 'User')->addTitle();
162: *
163: * This will add expression 'user' equal to ref('user_id')['name'];
164: *
165: * @param array<string, mixed> $defaults
166: */
167: public function addTitle(array $defaults = []): SqlExpressionField
168: {
169: $ourModel = $this->getOurModel(null);
170:
171: $fieldName = $defaults['field'] ?? preg_replace('~_(' . preg_quote($ourModel->idField, '~') . '|id)$~', '', $this->link);
172:
173: $defaults['ui'] = array_merge(['visible' => true], $defaults['ui'] ?? [], ['editable' => false]);
174:
175: $fieldExpression = $this->_addField($fieldName, true, null, $defaults);
176:
177: // set ID field as not visible in grid by default
178: if (!array_key_exists('visible', $this->getOurField()->ui)) {
179: $this->getOurField()->ui['visible'] = false;
180: }
181:
182: return $fieldExpression;
183: }
184: }
185: