• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rotexsoft / leanorm / 25641401969

10 May 2026 10:23PM UTC coverage: 96.429%. Remained the same
25641401969

push

github

rotexdegba
Pre 7.x release updates

1701 of 1764 relevant lines covered (96.43%)

192.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/src/LeanOrm/Model/Record.php
1
<?php
2
declare(strict_types=1);
3
namespace LeanOrm\Model;
4

5
use GDAO\Model\LoadingDataFromInvalidSourceIntoRecordException;
6

7
/**
8
 * Description of Record
9
 *
10
 * @author Rotimi Adegbamigbe
11
 * @copyright (c) 2026, Rotexsoft
12
 * @psalm-suppress ClassMustBeFinal
13
 * @psalm-suppress PropertyNotSetInConstructor
14
 */
15
class Record implements \GDAO\Model\RecordInterface, \Stringable
16
{
17
    use CommonRecordCodeTrait;
18
    
19
    /**
20
     * Copy of the initial data loaded into this record or data for this record immediately after an insert or update.
21
     */
22
    protected array $initial_data = [];
23
    
24
    /**
25
     * Tracks if *this record* is new (i.e., not in the database yet).
26
     */
27
    protected bool $is_new = true;
28
    
29
    /**
30
     * Delete the record from the db. 
31
     * 
32
     * If deletion was successful and the primary key column for the record's db
33
     * table is auto-incrementing, then unset the primary key field in the data 
34
     * contained in the record object.
35
     * 
36
     * NOTE: data contained in the record include $this->data, $this->related_data,
37
     *       $this->non_table_col_and_non_related_data and $this->initial_data.
38
     * 
39
     * @param bool $set_record_objects_data_to_empty_array true to reset the record object's data to an empty array if db deletion was successful, false to keep record object's data
40
     * 
41
     * @return bool true if record was successfully deleted from db or false if not
42
     */
43
    #[\Override]
44
    public function delete(bool $set_record_objects_data_to_empty_array=false): bool {
45
        
46
        $result = $this->getModel()->deleteSpecifiedRecord($this);
56✔
47
        
48
        if( $result && $set_record_objects_data_to_empty_array ) {
56✔
49
            
50
            $this->data = [];
4✔
51
            $this->related_data = [];
4✔
52
            $this->initial_data = [];
4✔
53
            $this->non_table_col_and_non_related_data = [];
4✔
54
        }
55
        
56
        // if $result is null this means the record does not even exist in the db
57
        // and it's as good as it being deleted, so return true
58
        return $result ?? true;
56✔
59
    }
60
    
61
    /**
62
     * Get a copy of the initial data loaded into this record.
63
     * Modifying the returned data will not affect the initial data inside this record.
64
     * 
65
     * @return array a copy of the initial data loaded into this record.
66
     */
67
    #[\Override]
68
    public function getInitialData(): array {
69
        
70
        return $this->initial_data;
24✔
71
    }
72
    
73
    /**
74
     * Get a reference to the initial data loaded into this record.
75
     * Modifying the returned data will affect the initial data inside this record.
76
     * 
77
     * @return array a reference to the initial data loaded into this record.
78
     */
79
    #[\Override]
80
    public function &getInitialDataByRef(): array {
81
        
82
        return $this->initial_data;
12✔
83
    }
84

85
    /**
86
     * Code below was lifted from Solar_Sql_Model_Record::isChanged($col=null)
87
     * 
88
     * Tells if a particular table-column has changed.
89
     * 
90
     * This is slightly complicated.  Changes to or from a null are reported
91
     * as "changed".  If both the initial value and new value are numeric
92
     * (that is, whether they are string/float/int), they are compared using
93
     * normal inequality (!=).  Otherwise, the initial value and new value
94
     * are compared using strict inequality (!==).
95
     * 
96
     * This complexity results from converting string and numeric values in
97
     * and out of the database.  Coming from the database, a string numeric
98
     * '1' might be filtered to an integer 1 at some point, making it look
99
     * like the value was changed when in practice it has not.
100
     * 
101
     * Similarly, we need to make allowances for nulls, because a non-numeric
102
     * null is loosely equal to zero or an empty string.
103
     * 
104
     * @param string|null $col The table-column name.
105
     * 
106
     * @return null|bool Returns null if the table-column name does not exist,
107
     * boolean true if the data is changed, boolean false if not changed.
108
     * 
109
     * @todo How to handle changes to array values?
110
     */
111
    #[\Override]
112
    public function isChanged(?string $col = null): ?bool {
113

114
        // if no column specified, check if the record as a whole has changed
115
        if ($col === null) {
32✔
116

117
            $cols = $this->getModel()->getTableColNames();
32✔
118
            
119
            /** @psalm-suppress MixedAssignment */
120
            foreach ($cols as $col) {
32✔
121
                
122
                /** @psalm-suppress MixedArgument */
123
                if ($this->isChanged($col)) {
32✔
124
                    return true;
32✔
125
                }
126
            }
127

128
            return false;
12✔
129
        }
130

131
        // col needs to exist in the initial array
132
        if (
133
            (    
134
                !array_key_exists($col, $this->initial_data)
32✔
135
                && array_key_exists($col, $this->data)
32✔
136
            )
137
            ||
138
            (    
139
                array_key_exists($col, $this->initial_data)
32✔
140
                && !array_key_exists($col, $this->data)
32✔
141
            )                    
142
        ) {
143
            return true;
4✔
144
            
145
        } elseif(
146
            !array_key_exists($col, $this->initial_data)
32✔
147
            && !array_key_exists($col, $this->data)
32✔
148
        ) {
149
            return null;
4✔
150
            
151
        } else {
152
            // array_key_exists($col, $this->initial_data)
153
            // && array_key_exists($col, $this->data)
154

155
            // track changes to or from null
156
            $from_null = $this->initial_data[$col] === null &&
32✔
157
                    $this->data[$col] !== null;
32✔
158

159
            $to_null = $this->initial_data[$col] !== null &&
32✔
160
                    $this->data[$col] === null;
32✔
161

162
            if ($from_null || $to_null) {
32✔
163
                
164
                return true;
4✔
165
            }
166

167
            // track numeric changes
168
            $both_numeric = is_numeric($this->initial_data[$col]) &&
32✔
169
                    is_numeric($this->data[$col]);
32✔
170

171
            if ($both_numeric) {
32✔
172
                
173
                return ((string)$this->initial_data[$col]) !== ((string)$this->data[$col]);
32✔
174
            }
175

176
            // use strict inequality
177
            return $this->initial_data[$col] !== $this->data[$col];
32✔
178
        }
179
    }
180
    
181
    /**
182
     * Is the record new? (I.e. its data has never been saved to the db)
183
     */
184
    #[\Override]
185
    public function isNew(): bool {
186
        
187
        return $this->is_new;
60✔
188
    }
189

190
    /**
191
     * \GDAO\Model\Record::$initial_data should be set here only if it has the
192
     * initial value of null.
193
     * 
194
     * This method partially or completely overwrites pre-existing data and
195
     * replaces it with the new data. Related data should also be loaded if
196
     * $data_2_load is an instance of \GDAO\Model\RecordInterface. However,
197
     * because of the way __get is implemented, there's no need to load
198
     * relationship data here, __get will load that data on-demand if not
199
     * already loaded.
200
     * 
201
     * Note if $cols_2_load === null all data should be replaced, else only
202
     * replace data for the cols in $cols_2_load.
203
     * 
204
     * If $data_2_load is an instance of \GDAO\Model\RecordInterface and is also an instance
205
     * of a sub-class of the Record class in a package that implements this API and
206
     * if $data_2_load->getModel()->getTableName() !== $this->getModel()->getTableName(),
207
     * then the exception below should be thrown:
208
     * 
209
     *      \GDAO\Model\LoadingDataFromInvalidSourceIntoRecordException
210
     * 
211
     * @param \GDAO\Model\RecordInterface|array $data_2_load source of data to be loaded into the record
212
     * @param array $cols_2_load name of field to load from $data_2_load. If empty, 
213
     *                           load all fields in $data_2_load.
214
     * 
215
     * @throws \GDAO\Model\LoadingDataFromInvalidSourceIntoRecordException
216
     */
217
    #[\Override]
218
    public function loadData(\GDAO\Model\RecordInterface|array $data_2_load, array $cols_2_load = []): static {
219

220
        $this->injectData($data_2_load, $cols_2_load);
564✔
221

222
        if ($this->initial_data === [] && $this->data !== []) {
564✔
223
            
224
            /** @psalm-suppress MixedAssignment */
225
            foreach($this->getModel()->getTableColNames() as $col_name) {
456✔
226

227
                /** 
228
                 * @psalm-suppress MixedArrayOffset
229
                 * @psalm-suppress MixedArgument
230
                 */
231
                $this->initial_data[$col_name] = array_key_exists($col_name, $this->data)? $this->data[$col_name] : '';
456✔
232
            }
233
        }
234
        
235
        return $this;
564✔
236
    }
237
    
238
    /**
239
     * Set the is_new attribute of this record to true (meaning that the data
240
     * for this record has never been saved to the db).
241
     */
242
    #[\Override]
243
    public function markAsNew(): static {
244
        
245
        $this->is_new = true;
72✔
246
        
247
        return $this;
72✔
248
    }
249
    
250
    /**
251
     * Set the is_new attribute of this record to false (meaning that the data
252
     * for this record has been saved to the db or was read from the db).
253
     */
254
    #[\Override]
255
    public function markAsNotNew(): static {
256
        
257
        $this->is_new = false;
296✔
258
        
259
        return $this;
296✔
260
    }
261
    
262
    /**
263
     * Set all properties of this record to the state they should be in for a new record.
264
     * For example:
265
     *  - unset its primary key value via unset($this[$this->getPrimaryCol()]);
266
     *  - call $this->markAsNew()
267
     *  - etc.
268
     * 
269
     * The data & initial_data properties can be updated as needed by the 
270
     * implementing sub-class. 
271
     * For example:
272
     *  - they could be left as is 
273
     *  - or the value of _data could be copied to initial_data
274
     *  - or the value of initial_data could be copied to _data
275
     *  - etc.
276
     */
277
    #[\Override]
278
    public function setStateToNew(): static {
279

280
        $this->data = [];
4✔
281
        $this->related_data = [];
4✔
282
        $this->initial_data = [];
4✔
283
        $this->non_table_col_and_non_related_data = [];
4✔
284
        $this->markAsNew();
4✔
285
        
286
        return $this;
4✔
287
    }
288
    
289
    /**
290
     * Save the specified or already existing data for this record to the db.
291
     * Since this record can only talk to the db via its model property (_model)
292
     * the save operation will actually be done via $this->model.
293
     *  
294
     * @return null|bool true: successful save, false: failed save, null: no changed data to save
295
     */
296
    #[\Override]
297
    public function save(null|\GDAO\Model\RecordInterface|array $data_2_save = null): ?bool {
298

299
        $result = null;
68✔
300
        
301
        if (
302
            is_null($data_2_save) 
68✔
303
            || $data_2_save === [] 
8✔
304
            || ($data_2_save instanceof \GDAO\Model\RecordInterface &&  $data_2_save->getData() === [])
68✔
305
        ) {
306
            $data_2_save = $this->getData();
68✔
307
            
308
        } elseif( ($data_2_save instanceof \GDAO\Model\RecordInterface &&  $data_2_save->getData() !== []) ) {
8✔
309
                
310
            $data_2_save = $data_2_save->getData();
4✔
311
            
312
        } else {
313
            
314
            $data_2_save = is_array($data_2_save) ? $data_2_save : [];
8✔
315
        }
316
        
317
        // $data_2_save must have been converted to an array at this point
318
        if ( $data_2_save !== [] ) {
68✔
319
            
320
            /** @psalm-suppress MixedAssignment */
321
            $pri_val = $this->getPrimaryVal();
64✔
322
            
323
            if ( empty($pri_val) ) {
64✔
324
                
325
                // New record because of empty primary key value, do insert
326
                $inserted_data = $this->getModel()->insert($data_2_save);
56✔
327
                $result = ($inserted_data !== false);
52✔
328
                
329
                if( $result && is_array($inserted_data) && $inserted_data !== [] ) {
52✔
330
                    
331
                    //update the record with the newly inserted data
332
                    $this->loadData($inserted_data);
52✔
333
                    
334
                    //update initial data
335
                    $this->initial_data = $inserted_data;
52✔
336
                    
337
                    //record has now been saved to the DB, 
338
                    //it is no longer a new record (it now exists in the DB).
339
                    $this->markAsNotNew();
52✔
340
                }
341
                
342
            } else {
343

344
                //load data into the record
345
                $this->loadData($data_2_save);
20✔
346
                
347
                if( $this->isChanged() ) {
20✔
348
                    
349
                    //update
350
                    $this->getModel()->updateSpecifiedRecord($this);
20✔
351
                    $this->initial_data = $this->data;
20✔
352
                    $result = true;
20✔
353
                }
354
            }
355
        }
356
        
357
        return $result;
64✔
358
    }
359

360
    /**
361
     * Save the specified or already existing data for this record to the db.
362
     * 
363
     * This save operation is gaurded by the PDO transaction mechanism. 
364
     * If the save operation fails all changes are rolled back.
365
     *  
366
     * @return bool|null true for a successful save, false for failed save, null: no changed data to save
367
     * 
368
     * @throws \Exception throws exception if an error occurred during transaction
369
     */
370
    #[\Override]
371
    public function saveInTransaction(null|\GDAO\Model\RecordInterface|array $data_2_save = null): ?bool {
372

373
        $pdo_obj = $this->getModel()->getPDO();
8✔
374
        // start the transaction
375
        $pdo_obj->beginTransaction();
8✔
376
        
377
        try {
378

379
            
380
            $save_status = $this->save($data_2_save);
8✔
381

382
            // attempt the save
383
            if ($save_status === true) {
4✔
384

385
                // entire save was valid, keep it
386
                $pdo_obj->commit();
4✔
387
                return true;
4✔
388

389
            } elseif ($save_status === false) {
4✔
390

391
                // at least one part of the save was *not* valid.
392
                // throw it all away.
393
                $pdo_obj->rollBack();
4✔
394
                return false;
4✔
395

396
            } else {
397

398
                $pdo_obj->commit();
4✔
399
                return null; //$save_status === null nothing was done
4✔
400
            }
401
        } catch (\Exception $exception) {
4✔
402

403
            if($pdo_obj->inTransaction()) {
4✔
404
                
405
                // roll back
406
                $pdo_obj->rollBack();
4✔
407
            }
408
            
409
            // throw the exception
410
            throw $exception;
4✔
411
        }
412
    }
413
    
414
    //Magic Methods
415

416
    /**
417
     * Sets a key value.
418
     * 
419
     * @param string $key The requested data key.
420
     * 
421
     * @param mixed $val The value to set the data to.
422
     */
423
    #[\Override]
424
    public function __set($key, mixed $val): void {
425
        
426
        if ( in_array($key, $this->getModel()->getTableColNames()) ) {
56✔
427
            //$key is a valid db column in the db table assoiciated with this 
428
            //model's record.
429
            $this->data[$key] = $val;
48✔
430
            
431
        } elseif( 
432
            $this->getModel() instanceof \GDAO\Model 
28✔
433
            && in_array($key, $this->getModel()->getRelationNames()) 
28✔
434
        ) {
435
            //$key is a valid relation name in the model for this record.
436
            $this->related_data[$key] = $val;
20✔
437
            
438
        } else {
439

440
            $this->non_table_col_and_non_related_data[$key] = $val;
28✔
441
        }
442
    }
443

444
    /**
445
     * Removes a key and its value in the data.
446
     * 
447
     * @param string $key The requested data key.
448
     */
449
    #[\Override]
450
    public function __unset($key): void {
451
        
452
        if( array_key_exists($key, $this->initial_data) ) {
48✔
453
            
454
            $this->initial_data[$key] = null;
8✔
455
            unset($this->initial_data[$key]);
8✔
456
        }
457
        
458
        if( array_key_exists($key, $this->data) ) {
48✔
459
            
460
            $this->data[$key] = null;
8✔
461
            unset($this->data[$key]);
8✔
462
        }
463
        
464
        if( array_key_exists($key, $this->related_data) ) {
48✔
465
            
466
            $this->related_data[$key] = null;
48✔
467
            unset($this->related_data[$key]);
48✔
468
        }
469
        
470
        if( array_key_exists($key, $this->non_table_col_and_non_related_data) ) {
48✔
471
            
472
            $this->non_table_col_and_non_related_data[$key] = null;
8✔
473
            unset($this->non_table_col_and_non_related_data[$key]);
8✔
474
        }
475
    }
476
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc