AbstractRecord::createAccessor()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 9
rs 9.9666
c 0
b 0
f 0
cc 1
nc 1
nop 4
1
<?php
2
/**
3
 * Spiral, Core Components
4
 *
5
 * @author Wolfy-J
6
 */
7
8
namespace Spiral\ORM;
9
10
use Spiral\Core\Component;
11
use Spiral\Models\AccessorInterface;
12
use Spiral\Models\Exceptions\AccessException;
13
use Spiral\Models\SchematicEntity;
14
use Spiral\Models\Traits\SolidableTrait;
15
use Spiral\ORM\Entities\RelationMap;
16
use Spiral\ORM\Exceptions\RelationException;
17
18
/**
19
 * Provides data and relation access functionality.
20
 */
21
abstract class AbstractRecord extends SchematicEntity
22
{
23
    use SolidableTrait;
24
25
    /**
26
     * Set of schema sections needed to describe entity behaviour.
27
     */
28
    const SH_PRIMARY_KEY = 0;
29
    const SH_DEFAULTS    = 1;
30
    const SH_RELATIONS   = 5;
31
32
    /**
33
     * Record behaviour definition.
34
     *
35
     * @var array
36
     */
37
    private $recordSchema = [];
38
39
    /**
40
     * Record field updates (changed values). This array contain set of initial property values if
41
     * any of them changed.
42
     *
43
     * @var array
44
     */
45
    private $changes = [];
46
47
    /**
48
     * AssociatedRelation bucket. Manages declared record relations.
49
     *
50
     * @var RelationMap
51
     */
52
    protected $relations;
53
54
    /**
55
     * Parent ORM instance, responsible for relation initialization and lazy loading operations.
56
     *
57
     * @invisible
58
     * @var ORMInterface
59
     */
60
    protected $orm;
61
62
    /**
63
     * @param ORMInterface $orm
64
     * @param array        $data
65
     * @param RelationMap  $relations
66
     */
67
    public function __construct(
68
        ORMInterface $orm,
69
        array $data = [],
70
        RelationMap $relations
71
    ) {
72
        $this->orm = $orm;
73
        $this->recordSchema = (array)$this->orm->define(static::class, ORMInterface::R_SCHEMA);
74
75
        $this->relations = $relations;
76
        $this->relations->extractRelations($data);
77
78
        //Populating default fields
79
        parent::__construct($data + $this->recordSchema[self::SH_DEFAULTS], $this->recordSchema);
80
    }
81
82
    /**
83
     * Get value of primary of model. Make sure to call isLoaded first!
84
     *
85
     * Attention, this method MIGHT return null even when isLoaded() returns true, this situation
86
     * is possible when record is scheduled for save or update but transaction/unit-of-work not
87
     * executed yet.
88
     *
89
     * @return int|string|null
90
     */
91
    public function primaryKey()
92
    {
93
        return $this->getField($this->primaryColumn(), null);
94
    }
95
96
    /**
97
     * {@inheritdoc}
98
     *
99
     * @throws RelationException
100
     */
101
    public function getField(string $name, $default = null, bool $filter = true)
102
    {
103
        if ($this->relations->has($name)) {
104
            return $this->relations->getRelated($name);
105
        }
106
107
        $this->assertField($name);
108
109
        return parent::getField($name, $default, $filter);
110
    }
111
112
    /**
113
     * {@inheritdoc}
114
     *
115
     * @param bool $registerChanges Track field changes.
116
     *
117
     * @throws RelationException
118
     */
119
    public function setField(
120
        string $name,
121
        $value,
122
        bool $filter = true,
123
        bool $registerChanges = true
124
    ) {
125
        if ($this->relations->has($name)) {
126
            //Would not work with relations which do not represent singular entities
127
            $this->relations->setRelated($name, $value);
128
129
            return;
130
        }
131
132
        $this->assertField($name);
133
        if ($registerChanges) {
134
            $this->registerChange($name);
135
        }
136
137
        parent::setField($name, $value, $filter);
138
    }
139
140
    /**
141
     * {@inheritdoc}
142
     */
143
    public function hasField(string $name): bool
144
    {
145
        if ($this->relations->has($name)) {
146
            return $this->relations->hasRelated($name);
147
        }
148
149
        return parent::hasField($name);
150
    }
151
152
    /**
153
     * {@inheritdoc}
154
     *
155
     * @throws AccessException
156
     * @throws RelationException
157
     */
158
    public function __unset($offset)
159
    {
160
        if ($this->relations->has($offset)) {
161
            //Flush associated relation value if possible
162
            $this->relations->setRelated($offset, null);
163
164
            return;
165
        }
166
167
        if (!$this->isNullable($offset)) {
168
            throw new AccessException("Unable to unset not nullable field '{$offset}'");
169
        }
170
171
        $this->setField($offset, null, false);
172
    }
173
174
    /**
175
     * {@inheritdoc}
176
     *
177
     * Method does not check updates in nested relation, but only in primary record.
178
     *
179
     * @param string $field Check once specific field changes.
180
     */
181
    public function hasChanges(string $field = null): bool
182
    {
183
        //Check updates for specific field
184
        if (!empty($field)) {
185
            if (array_key_exists($field, $this->changes)) {
186
                return true;
187
            }
188
189
            //Do not force accessor creation
190
            $value = $this->getField($field, null, false);
191
            if ($value instanceof RecordAccessorInterface && $value->hasChanges()) {
192
                return true;
193
            }
194
195
            return false;
196
        }
197
198
        if (!empty($this->changes)) {
199
            return true;
200
        }
201
202
        //Do not force accessor creation
203
        foreach ($this->getFields(false) as $value) {
204
            //Checking all fields for changes (handled internally)
205
            if ($value instanceof RecordAccessorInterface && $value->hasChanges()) {
206
                return true;
207
            }
208
        }
209
210
        return false;
211
    }
212
213
    /**
214
     * @return array
215
     */
216
    public function __debugInfo()
217
    {
218
        return [
219
            'objectID'  => hash('crc32', spl_object_hash($this)),
220
            'database'  => $this->orm->define(static::class, ORMInterface::R_DATABASE),
221
            'table'     => $this->orm->define(static::class, ORMInterface::R_TABLE),
222
            'fields'    => $this->getFields(),
223
            'relations' => $this->relations
224
        ];
225
    }
226
227
    /**
228
     * @return string
229
     */
230
    public function __toString()
231
    {
232
        //Do we need to worry about collision rate in this context?
233
        return static::class . '#' . hash('crc32', spl_object_hash($this));
234
    }
235
236
    /**
237
     * {@inheritdoc}
238
     */
239
    protected function isNullable(string $field): bool
240
    {
241
        if (array_key_exists($field, $this->recordSchema[self::SH_DEFAULTS])) {
242
            //Only fields with default null value can be nullable
243
            return is_null($this->recordSchema[self::SH_DEFAULTS][$field]);
244
        }
245
246
        //Values unknown to schema always nullable
247
        return true;
248
    }
249
250
    /**
251
     * {@inheritdoc}
252
     *
253
     * DocumentEntity will pass ODM instance as part of accessor context.
254
     *
255
     * @see CompositionDefinition
256
     */
257
    protected function createAccessor(
258
        $accessor,
259
        string $name,
260
        $value,
261
        array $context = []
262
    ): AccessorInterface {
263
        //Giving ORM as context
264
        return parent::createAccessor($accessor, $name, $value, $context + ['orm' => $this->orm]);
265
    }
266
267
    /**
268
     * {@inheritdoc}
269
     */
270
    protected function iocContainer()
271
    {
272
        if ($this->orm instanceof Component) {
273
            //Forwarding IoC scope to parent ORM instance
274
            return $this->orm->iocContainer();
275
        }
276
277
        return parent::iocContainer();
278
    }
279
280
    /**
281
     * Name of column used as primary key.
282
     *
283
     * @return string
284
     */
285
    protected function primaryColumn(): string
286
    {
287
        return $this->recordSchema[self::SH_PRIMARY_KEY];
288
    }
289
290
    /**
291
     * Create set of fields to be sent to UPDATE statement.
292
     *
293
     * @param bool $skipPrimary Skip primary key
294
     *
295
     * @return array
296
     */
297
    protected function packChanges(bool $skipPrimary = false): array
298
    {
299
        if (!$this->hasChanges() && !$this->isSolid()) {
300
            return [];
301
        }
302
303
        if ($this->isSolid()) {
304
            //Solid record always updated as one big solid
305
            $updates = $this->packValue();
306
        } else {
307
            //Updating each field individually
308
            $updates = [];
309
            foreach ($this->getFields(false) as $field => $value) {
310
                //Handled by sub-accessor
311
                if ($value instanceof RecordAccessorInterface) {
312
                    if ($value->hasChanges()) {
313
                        $updates[$field] = $value->compileUpdates($field);
314
                        continue;
315
                    }
316
317
                    $value = $value->packValue();
318
                }
319
320
                //Field change registered
321
                if (array_key_exists($field, $this->changes)) {
322
                    $updates[$field] = $value;
323
                }
324
            }
325
        }
326
327
        if ($skipPrimary) {
328
            unset($updates[$this->primaryColumn()]);
329
        }
330
331
        return $updates;
332
    }
333
334
    /**
335
     * Indicate that all updates done, reset dirty state.
336
     */
337
    protected function flushChanges()
338
    {
339
        $this->changes = [];
340
341
        foreach ($this->getFields(false) as $field => $value) {
342
            if ($value instanceof RecordAccessorInterface) {
343
                $value->flushChanges();
344
            }
345
        }
346
    }
347
348
    /**
349
     * @param string $name
350
     */
351
    private function registerChange(string $name)
352
    {
353
        $original = $this->getField($name, null, false);
354
355
        if (!array_key_exists($name, $this->changes)) {
356
            //Let's keep track of how field looked before first change
357
            $this->changes[$name] = $original instanceof AccessorInterface
358
                ? $original->packValue()
359
                : $original;
360
        }
361
    }
362
363
    /**
364
     * @param string $name
365
     *
366
     * @throws AccessException
367
     */
368
    private function assertField(string $name)
369
    {
370
        if (!$this->hasField($name)) {
371
            throw new AccessException(sprintf(
372
                "No such property '%s' in '%s', check schema being relevant",
373
                $name,
374
                get_called_class()
375
            ));
376
        }
377
    }
378
}
379