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

rotexsoft / leanorm / 19316703473

13 Nov 2025 12:47AM UTC coverage: 95.677%. Remained the same
19316703473

push

github

rotimi
Rector and Psalm recommended code tweaks

0 of 3 new or added lines in 1 file covered. (0.0%)

1483 of 1550 relevant lines covered (95.68%)

171.9 hits per line

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

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

5
/**
6
 * 
7
 * Represents a collection of \GDAO\Model\RecordInterface objects.
8
 *
9
 * @author Rotimi Adegbamigbe
10
 * @copyright (c) 2025, Rotexsoft
11
 * 
12
 * @psalm-suppress ClassMustBeFinal
13
 */
14
class Collection implements \GDAO\Model\CollectionInterface, \Stringable
15
{
16
    protected \GDAO\Model $model;
17

18
    /**
19
     * 
20
     * @var \GDAO\Model\RecordInterface[] array of \GDAO\Model\RecordInterface records
21
     */
22
    protected array $data = [];
23
    
24
    /**
25
     * @param \GDAO\Model $model The model object that transfers data between the db and this collection.
26
     */
27
    public function __construct(
28
        \GDAO\Model $model, \GDAO\Model\RecordInterface ...$data
29
    ) {
30
        $this->setModel($model);
264✔
31
        $this->data = $data;
264✔
32
    }
33
    
34
    /**
35
     * 
36
     * Deletes each record in the collection from the database, but leaves the
37
     * record objects with their data inside the collection object.
38
     * 
39
     * Call $this->removeAll() to empty the collection of the record objects.
40
     * 
41
     * @return bool|array true if all records were successfully deleted or an
42
     *                    array of keys in the collection for the records that 
43
     *                    couldn't be successfully deleted. It's most likely a 
44
     *                    PDOException would be thrown if the deletion failed.
45
     * 
46
     * @throws \PDOException 
47
     * @throws \LeanOrm\Exceptions\CantDeleteReadOnlyRecordFromDBException
48
     */
49
    #[\Override]
50
    public function deleteAll(): bool|array {
51
        
52
        $this->preDeleteAll();
8✔
53
        
54
        $result = true;
8✔
55
        
56
        foreach($this->data as $record) {
8✔
57
            
58
            if( $record instanceof ReadOnlyRecord ) {
8✔
59
                
60
                $msg = "ERROR: Can't delete ReadOnlyRecord in Collection from the database in " 
4✔
61
                     . static::class . '::' . __FUNCTION__ . '(...).'
4✔
62
                     . PHP_EOL .'Undeleted record' . var_export($record, true) . PHP_EOL;
4✔
63
                throw new \LeanOrm\Exceptions\CantDeleteReadOnlyRecordFromDBException($msg);
4✔
64
            }
65
        }
66

67
        if( $this->count() > 0 ) {
4✔
68

69
            $result = [];
4✔
70

71
            //generate list of keys of records in this collection
72
            //that were not successfully saved.
73

74
            foreach( $this->data as $key=>$record ) {
4✔
75
                
76
                $delete_result = $record->delete();
4✔
77

78
                if( !$delete_result ) {
4✔
79

80
                    //record still exists in the db table
81
                    //it wasn't successfully deleted.
82
                    $result[] = $key;
4✔
83
                }
84
            }
85

86
            if( count($result) <= 0 ) {
4✔
87

88
                $result = true;
4✔
89
            }
90
        }
91
        
92
        $this->postDeleteAll();
4✔
93
        
94
        return $result;
4✔
95
    }
96
    
97
    /**
98
     * 
99
     * Returns an array of all values for a single column in the collection.
100
     *
101
     * @param string $col The column name to retrieve values for.
102
     *
103
     * @return array An array of key-value pairs where the key is the collection 
104
     *               element key, and the value is the column value for that
105
     *               element.
106
     */
107
    #[\Override]
108
    public function getColVals(string $col): array {
109
        
110
        $list = [];
68✔
111
        
112
        foreach ($this->data as $key => $record) {
68✔
113
            
114
            /** @psalm-suppress MixedAssignment */
115
            $list[$key] = $record->$col;
68✔
116
        }
117
        
118
        return $list;
68✔
119
    }
120
    
121
    /**
122
     * 
123
     * Returns all the keys for this collection.
124
     * @return int[]|string[]
125
     */
126
    #[\Override]
127
    public function getKeys(): array {
128
        
129
        return array_keys($this->data);
28✔
130
    }
131
    
132
    /**
133
     * 
134
     * Returns the model from which the data originates.
135
     * 
136
     * @return \GDAO\Model The origin model object.
137
     */
138
    #[\Override]
139
    public function getModel(): \GDAO\Model {
140
        
141
        return $this->model;
24✔
142
    }
143
    
144
    /**
145
     * 
146
     * Are there any records in the collection?
147
     * 
148
     * @return bool True if empty, false if not.
149
     */
150
    #[\Override]
151
    public function isEmpty(): bool {
152
        
153
        return $this->data === [];
12✔
154
    }
155
    
156
    /**
157
     * 
158
     * Load the collection with a list of records.
159
     */
160
    #[\Override]
161
    public function loadData(\GDAO\Model\RecordInterface ...$data_2_load): static {
162
        
163
        $this->data = $data_2_load;
4✔
164
        
165
        return $this;
4✔
166
    }
167
    
168
    
169
    /**
170
     * 
171
     * Removes all records from the collection but **does not** delete them from the database.
172
     */
173
    #[\Override]
174
    public function removeAll(): static {
175
        
176
        $keys =  array_keys($this->data);
4✔
177
        
178
        foreach( $keys as $key ) {
4✔
179
            
180
            unset($this->data[$key]);
4✔
181
        }
182
        
183
        $this->data = [];
4✔
184
        
185
        return $this;
4✔
186
    }
187

188
    /**
189
     * 
190
     * Saves all the records from this collection to the database one-by-one,
191
     * inserting or updating as needed. 
192
     * 
193
     * For better performance, it can gather all records for inserts together
194
     * and then perform a single insert of multiple rows with one sql operation.
195
     * 
196
     * Updates cannot be batched together (they must be performed one-by-one) 
197
     * because there seems to be no neat update equivalent for bulk inserts:
198
     * 
199
     * example bulk insert:
200
     * 
201
     *      INSERT INTO mytable
202
     *                 (id, title)
203
     *          VALUES ('1', 'Lord of the Rings'),
204
     *                 ('2', 'Harry Potter');
205
     * 
206
     * @param bool $group_inserts_together true to group all records to be 
207
     *                                     inserted together in order to perform 
208
     *                                     a single sql insert operation, false
209
     *                                     to perform one-by-one inserts.
210
     * 
211
     * @return bool|array true if all inserts and updates were successful or
212
     *                    return an array of keys in the collection for the 
213
     *                    records that couldn't be successfully inserted or
214
     *                    updated. It's most likely a PDOException would be
215
     *                    thrown if an insert or update fails.
216
     * 
217
     * @throws \PDOException
218
     */
219
    #[\Override]
220
    public function saveAll(bool $group_inserts_together=false): bool|array {
221
        
222
        $this->preSaveAll($group_inserts_together);
16✔
223
        
224
        $result = true;
16✔
225
        $keys_4_unsuccessfully_saved_records = [];
16✔
226
        
227
        if ( $group_inserts_together ) {
16✔
228
            
229
            $data_2_save_4_new_records = [];
12✔
230
            
231
            /** 
232
             * @psalm-suppress UnnecessaryVarAnnotation 
233
             * @var \GDAO\Model\RecordInterface $record 
234
             */
235
            foreach ( $this->data as $key => $record ) {
12✔
236

237
                $this->throwExceptionOnSaveOfReadOnlyRecord($record, __FUNCTION__);
12✔
238
                
239
                if( $record->isNew()) {
12✔
240
                    
241
                    // Let's make sure that the table name associated with the
242
                    // model this record was created with is the same as the 
243
                    // table name of the model this collection was created with
244
                    // because the bulk insert will be performed via the 
245
                    // insertMany method of the model this collection was 
246
                    // created with.
247
                    if(
248
                        $record->getModel()->getTableName() 
12✔
249
                        !== $this->getModel()->getTableName()
12✔
250
                    ) {
251
                        $record_class_name = $record::class;
4✔
252
                        $record_table_name = $record->getModel()->getTableName();
4✔
253
                        
254
                        $collection_class_name = static::class;
4✔
255
                        $collection_table_name = $this->getModel()->getTableName();
4✔
256
                        
257
                        $method_name = __FUNCTION__;
4✔
258
                        
259
                        $msg = "ERROR: Can't save a record of type `{$record_class_name}`"
4✔
260
                            . " belonging to table `{$record_table_name}` in the database via the " 
4✔
261
                            . " `{$method_name}` method in a Collection of type `{$collection_class_name}`"
4✔
262
                            . " whose model is associated with the table `{$collection_table_name}` in the"
4✔
263
                            . " database. " . PHP_EOL .  static::class . '::' . $method_name . '(...).'
4✔
264
                            . PHP_EOL .'Unsaved record' . var_export($record, true) . PHP_EOL;
4✔
265
                        throw new \LeanOrm\Exceptions\Model\TableNameMismatchInCollectionSaveAllException($msg);
4✔
266
                    }
267
                    
268
                    //The record is new and must be inserted into the db.
269
                    //Get the data to insert, whilst preserving its 
270
                    //association with its key in this collection.
271
                    $data_2_save_4_new_records[$key] = $record->getData();
12✔
272
                    
273
                } else if( $record->save() === false ) {
4✔
274

275
                    //The record is not new, but the attempt to update it failed.
276
                    //Store its key in this collection into the array of keys
277
                    //of records that could not be successfully saved.
278
                    $keys_4_unsuccessfully_saved_records[] = $key;
4✔
279
                }
280
            }
281
            
282
            //Try bulk insertion of new records
283
            if( 
284
                $data_2_save_4_new_records !== []
4✔
285
                && !$this->getModel()->insertMany($data_2_save_4_new_records) 
4✔
286
            ) {
287
                //bulk insert failed, none of the new records got saved
288
                //gather all their keys in this collection and add them
289
                //to the keys to be returned.
290
                $keys_4_unsuccessfully_saved_records = array_merge(
4✔
291
                                        $keys_4_unsuccessfully_saved_records, 
4✔
292
                                        array_keys($data_2_save_4_new_records)
4✔
293
                                    );
4✔
294
            }
295
        } else {
296
            
297
            foreach ( $this->data as $key=>$record ) {
8✔
298
                
299
                $this->throwExceptionOnSaveOfReadOnlyRecord($record, __FUNCTION__);
8✔
300
                
301
                if( $record->save() === false ) {
8✔
302
                    
303
                    $keys_4_unsuccessfully_saved_records[] = $key;
4✔
304
                }
305
            }
306
        }
307
        
308
        if( $keys_4_unsuccessfully_saved_records !== [] ) {
4✔
309
            
310
            $result = $keys_4_unsuccessfully_saved_records;
4✔
311
        }
312
        
313
        $this->postSaveAll($result, $group_inserts_together);
4✔
314

315
        return $result;
4✔
316
    }
317
    
318
    protected function throwExceptionOnSaveOfReadOnlyRecord(
319
        \GDAO\Model\RecordInterface $record, string $calling_function
320
    ): void {
321
        
322
        if( $record instanceof ReadOnlyRecord ) {
16✔
323

324
            $msg = "ERROR: Can't save ReadOnlyRecord in Collection to  the database in " 
8✔
325
                 . static::class . '::' . $calling_function . '(...).'
8✔
326
                 . PHP_EOL .'Undeleted record' . var_export($record, true) . PHP_EOL;
8✔
327
            throw new \LeanOrm\Exceptions\CantSaveReadOnlyRecordException($msg);
8✔
328
        }
329
    }
330
    
331
    /**
332
     * 
333
     * Injects the model from which the data originates.
334
     * 
335
     * @param \GDAO\Model $model The origin model object.
336
     */
337
    #[\Override]
338
    public function setModel(\GDAO\Model $model): static {
339
        
340
        $this->model = $model;
264✔
341
        
342
        return $this;
264✔
343
    }
344
    
345
    /**
346
     * 
347
     * Returns an array representation of an instance of this class.
348
     * 
349
     * @return array an array representation of an instance of this class.
350
     */
351
    #[\Override]
352
    public function toArray(): array {
353

354
        $result = [];
16✔
355
        
356
        foreach ($this->data as $key=>$record) {
16✔
357
            
358
            $result[$key] = $record->toArray();
8✔
359
        }
360
        
361
        return $result;
16✔
362
    }
363
    
364
    /**
365
     * @return array an array where each value is the result of calling getData() on each record in the collection
366
     * @psalm-suppress PossiblyUnusedMethod
367
     */
368
    public function getData(): array {
369
    
370
        $rows = [];
4✔
371
        foreach ($this->data as $row) {
4✔
372
            
373
            $rows[] = $row->getData();
4✔
374
        }
375

376
        return $rows;
4✔
377
    }
378
    
379
    /////////////////////
380
    // Interface Methods
381
    /////////////////////
382
    
383
    /**
384
     * 
385
     * ArrayAccess: does the requested key exist?
386
     * 
387
     * @param string $key The requested key.
388
     */
389
    #[\Override]
390
    public function offsetExists($key): bool {
391
        
392
        return $this->__isset($key);
8✔
393
    }
394

395
    /**
396
     * 
397
     * ArrayAccess: get a key value.
398
     * 
399
     * @param string $key The requested key.
400
     * 
401
     */
402
    #[\Override]
403
    #[\ReturnTypeWillChange]
404
    public function offsetGet($key): \GDAO\Model\RecordInterface {
405
        
406
        return $this->__get($key);
68✔
407
    }
408

409
    /**
410
     * 
411
     * ArrayAccess: set a key value; appends to the array when using []
412
     * notation.
413
     * 
414
     * @param \GDAO\Model\RecordInterface $val The value to set it to.
415
     * 
416
     * @throws \GDAO\Model\CollectionCanOnlyContainGDAORecordsException
417
     * @psalm-suppress ParamNameMismatch
418
     */
419
    #[\Override]
420
    public function offsetSet(mixed $key, mixed $val): void {
421
        
422
        if( !($val instanceof \GDAO\Model\RecordInterface) ) {
84✔
423
            
424
            $msg = "ERROR: Only instances of " . \GDAO\Model\RecordInterface::class . " or its"
4✔
425
                   . " sub-classes can be added to a Collection. You tried to"
4✔
426
                   . " insert the following item: " 
4✔
427
                   . PHP_EOL . var_export($val, true) . PHP_EOL;
4✔
428
            
429
            throw new \GDAO\Model\CollectionCanOnlyContainGDAORecordsException($msg);
4✔
430
        }
431
        
432
        // only allow the key to be a string, int, Stringable object or 
433
        // null (for $this[] style assignment)
434
        if(!is_int($key) && !is_string($key) && $key !== null && !($key instanceof \Stringable)) {
80✔
435
            
436
            $msg = "ERROR: Only ints, strings, null or instances of Stringable are allowed as the first argument to "
4✔
437
                   . static::class . '::' . __FUNCTION__ . '(...).'
4✔
438
                   . PHP_EOL . ' Key of type `' . get_debug_type($key) . '` given.'
4✔
439
                   . PHP_EOL . ' Specified key: '. var_export($val, true) . PHP_EOL;
4✔
440
            
441
           throw new \LeanOrm\Exceptions\InvalidArgumentException($msg);
4✔
442
        }
443
        
444
        if ($key === null) {
80✔
445
            
446
            //support for $this[] = $record; syntax
447
            
448
            $key = $this->count();
12✔
449
        }
450
        
451
        $this->__set(($key instanceof \Stringable) ? $key->__toString() : ''.$key, $val);
80✔
452
    }
453

454
    /**
455
     * 
456
     * ArrayAccess: unset a key. 
457
     * Removes a record with the specified key from the collection.
458
     * 
459
     * @param string $key The requested key.
460
     */
461
    #[\Override]
462
    public function offsetUnset($key): void {
463
        
464
        $this->__unset($key);
8✔
465
    }
466

467
    /**
468
     * 
469
     * Countable: how many keys are there?
470
     */
471
    #[\Override]
472
    public function count(): int {
473
        
474
        return count($this->data);
140✔
475
    }
476

477
    /**
478
     * 
479
     * IteratorAggregate: returns an external iterator for this collection.
480
     * 
481
     * @return \ArrayIterator an Iterator eg. an instance of \ArrayIterator
482
     */
483
    #[\Override]
484
    public function getIterator(): \ArrayIterator {
485
        
486
        return new \ArrayIterator($this->data);
88✔
487
    }
488

489
    /////////////////////
490
    // Magic Methods
491
    /////////////////////
492
    
493
    /**
494
     * 
495
     * Returns a record from the collection based on its key value.
496
     * 
497
     * @param int|string $key The sequential or associative key value for the record.
498
     */
499
    #[\Override]
500
    public function __get($key): \GDAO\Model\RecordInterface {
501
        
502
        if (array_key_exists($key, $this->data)) {
76✔
503

504
            return $this->data[$key];
68✔
505
            
506
        } else {
507

508
            $msg = sprintf("ERROR: Item with key '%s' does not exist in ", $key) 
8✔
509
                   . static::class .'.'. PHP_EOL . $this->__toString();
8✔
510
            
511
            throw new \GDAO\Model\ItemNotFoundInCollectionException($msg);
8✔
512
        }
513
    }
514

515
    /**
516
     * 
517
     * Does a certain key exist in the data?
518
     * 
519
     * @param string $key The requested data key.
520
     */
521
    #[\Override]
522
    public function __isset($key): bool {
523
        
524
        return array_key_exists($key, $this->data);
12✔
525
    }
526

527
    /**
528
     * 
529
     * Set a key value.
530
     * 
531
     * @param string $key The requested key.
532
     * @param \GDAO\Model\RecordInterface $val The value to set it to.
533
     */
534
    #[\Override]
535
    public function __set($key, \GDAO\Model\RecordInterface $val): void {
536
        
537
        // set the value
538
        $this->data[$key] = $val;
88✔
539
    }
540

541
    /**
542
     * 
543
     * Returns a string representation of an instance of this class.
544
     * 
545
     * @return string a string representation of an instance of this class.
546
     */
547
    #[\Override]
548
    public function __toString(): string {
549
        
550
        return var_export($this->toArray(), true);
12✔
551
    }
552

553
    /**
554
     * 
555
     * Removes a record with the specified key from the collection.
556
     * 
557
     * @param string $key The requested data key.
558
     */
559
    #[\Override]
560
    public function __unset($key): void {
561
        
562
        unset($this->data[$key]);
12✔
563
    }
564
    
565
    //Hooks
566
    
567
    /**
568
     * {@inheritDoc}
569
     */
570
    #[\Override]
571
    public function preDeleteAll(): void { }
8✔
572
    
573
    /**
574
     * {@inheritDoc}
575
     */
576
    #[\Override]
577
    public function postDeleteAll(): void { }
4✔
578
    
579
    /**
580
     * {@inheritDoc}
581
     */
582
    #[\Override]
583
    public function preSaveAll(bool $group_inserts_together=false): void { }
16✔
584
    
585
    /**
586
     * {@inheritDoc}
587
     */
588
    #[\Override]
589
    public function postSaveAll(bool|array $save_all_result, bool $group_inserts_together=false): void { }
4✔
590

591
    /**
592
     * {@inheritDoc}
593
     */
594
    #[\Override]
595
    public function removeRecord(\GDAO\Model\RecordInterface $record): static {
596
        
597
        foreach($this->data as $key => $current_record) {
4✔
598
            
599
            if( $record === $current_record ) {
4✔
600
                
601
                // only remove record from the collection & not the database
602
                /** @psalm-suppress MixedArgumentTypeCoercion */
603
                $this->offsetUnset($key);
4✔
604
                break;
4✔
605
            }
606
        }
607
        
608
        return $this;
4✔
609
    }
610
    
611
    /**
612
     * Eager load related data into each record in this collection.
613
     * This will save us having to execute individual queries to the database for the 
614
     * related data for each record if we didn't eager load them. 
615
     * 
616
     * For example if you have a collection of 5 BlogPost records that each belong 
617
     * to an author, if you don't eager load, each time you access the Author 
618
     * relation for each BlogPost record, a query will be made to the database
619
     * to fetch the Author data for each BlogPost record, which will lead to 
620
     * 5 queries by the time you finish looping through all the BlogPost 
621
     * records in the collection. If you instead, eager load the Authors,
622
     * all the authors will be fetched by one extra query and stitched into
623
     * the corresponding BlogPost record.
624
     * 
625
     * @param array $relationsToInclude an array of relation names defined in the 
626
     *                                  corresponding Model Class for this collection
627
     *                                  via the relationship definition methods 
628
     *                                  (belongsTo, hasOne, hasMany, hasManyThrough)
629
     * @psalm-suppress PossiblyUnusedMethod
630
     */
631
    public function eagerLoadRelatedData(array $relationsToInclude): static {
632

633
        $model = $this->getModel();
×
634
        
NEW
635
        if(($model instanceof \LeanOrm\Model)) {
×
636
            /** @psalm-suppress MixedAssignment */
NEW
637
            foreach( $relationsToInclude as $relName ) {
×
638

NEW
639
                $model->loadRelationshipData((string)$relName, $this, true, true);
×
640
            }
641
        }
642
        
643
        return $this;
×
644
    }
645
}
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