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

rotexsoft / leanorm / 19314143888

12 Nov 2025 10:38PM UTC coverage: 95.787% (-0.2%) from 95.974%
19314143888

push

github

rotimi
Updated test suite to fix some PHP 8.4 warnings

1478 of 1543 relevant lines covered (95.79%)

172.39 hits per line

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

97.6
/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) 2024, Rotexsoft
11
 */
12
class Collection implements \GDAO\Model\CollectionInterface, \Stringable
13
{
14
    protected \GDAO\Model $model;
15

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

64
        if( $this->count() > 0 ) {
4✔
65

66
            $result = [];
4✔
67

68
            //generate list of keys of records in this collection
69
            //that were not successfully saved.
70

71
            foreach( $this->data as $key=>$record ) {
4✔
72
                
73
                $delete_result = $record->delete();
4✔
74

75
                if( !$delete_result ) {
4✔
76

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

83
            if( count($result) <= 0 ) {
4✔
84

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

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

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

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

305
        return $result;
4✔
306
    }
307
    
308
    protected function throwExceptionOnSaveOfReadOnlyRecord(
309
        \GDAO\Model\RecordInterface $record, string $calling_function
310
    ): void {
311
        
312
        if( $record instanceof ReadOnlyRecord ) {
16✔
313

314
            $msg = "ERROR: Can't save ReadOnlyRecord in Collection to  the database in " 
8✔
315
                 . static::class . '::' . $calling_function . '(...).'
8✔
316
                 . PHP_EOL .'Undeleted record' . var_export($record, true) . PHP_EOL;
8✔
317
            throw new \LeanOrm\Exceptions\CantSaveReadOnlyRecordException($msg);
8✔
318
        }
319
    }
320
    
321
    /**
322
     * 
323
     * Injects the model from which the data originates.
324
     * 
325
     * @param \GDAO\Model $model The origin model object.
326
     */
327
    public function setModel(\GDAO\Model $model): static {
328
        
329
        $this->model = $model;
264✔
330
        
331
        return $this;
264✔
332
    }
333
    
334
    /**
335
     * 
336
     * Returns an array representation of an instance of this class.
337
     * 
338
     * @return array an array representation of an instance of this class.
339
     */
340
    public function toArray(): array {
341

342
        $result = [];
16✔
343
        
344
        foreach ($this->data as $key=>$record) {
16✔
345
            
346
            $result[$key] = $record->toArray();
8✔
347
        }
348
        
349
        return $result;
16✔
350
    }
351
    
352
    /**
353
     * @return array an array where each value is the result of calling getData() on each record in the collection
354
     * @psalm-suppress PossiblyUnusedMethod
355
     */
356
    public function getData(): array {
357
    
358
        $rows = [];
4✔
359
        foreach ($this->data as $row) {
4✔
360
            
361
            $rows[] = $row->getData();
4✔
362
        }
363

364
        return $rows;
4✔
365
    }
366
    
367
    /////////////////////
368
    // Interface Methods
369
    /////////////////////
370
    
371
    /**
372
     * 
373
     * ArrayAccess: does the requested key exist?
374
     * 
375
     * @param string $key The requested key.
376
     */
377
    public function offsetExists($key): bool {
378
        
379
        return $this->__isset($key);
8✔
380
    }
381

382
    /**
383
     * 
384
     * ArrayAccess: get a key value.
385
     * 
386
     * @param string $key The requested key.
387
     * 
388
     */
389
    #[\ReturnTypeWillChange]
390
    public function offsetGet($key): \GDAO\Model\RecordInterface {
391
        
392
        return $this->__get($key);
68✔
393
    }
394

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

439
    /**
440
     * 
441
     * ArrayAccess: unset a key. 
442
     * Removes a record with the specified key from the collection.
443
     * 
444
     * @param string $key The requested key.
445
     */
446
    public function offsetUnset($key): void {
447
        
448
        $this->__unset($key);
8✔
449
    }
450

451
    /**
452
     * 
453
     * Countable: how many keys are there?
454
     */
455
    public function count(): int {
456
        
457
        return count($this->data);
140✔
458
    }
459

460
    /**
461
     * 
462
     * IteratorAggregate: returns an external iterator for this collection.
463
     * 
464
     * @return \ArrayIterator an Iterator eg. an instance of \ArrayIterator
465
     */
466
    public function getIterator(): \ArrayIterator {
467
        
468
        return new \ArrayIterator($this->data);
88✔
469
    }
470

471
    /////////////////////
472
    // Magic Methods
473
    /////////////////////
474
    
475
    /**
476
     * 
477
     * Returns a record from the collection based on its key value.
478
     * 
479
     * @param int|string $key The sequential or associative key value for the record.
480
     */
481
    public function __get($key): \GDAO\Model\RecordInterface {
482
        
483
        if (array_key_exists($key, $this->data)) {
76✔
484

485
            return $this->data[$key];
68✔
486
            
487
        } else {
488

489
            $msg = sprintf("ERROR: Item with key '%s' does not exist in ", $key) 
8✔
490
                   . static::class .'.'. PHP_EOL . $this->__toString();
8✔
491
            
492
            throw new \GDAO\Model\ItemNotFoundInCollectionException($msg);
8✔
493
        }
494
    }
495

496
    /**
497
     * 
498
     * Does a certain key exist in the data?
499
     * 
500
     * @param string $key The requested data key.
501
     */
502
    public function __isset($key): bool {
503
        
504
        return array_key_exists($key, $this->data);
12✔
505
    }
506

507
    /**
508
     * 
509
     * Set a key value.
510
     * 
511
     * @param string $key The requested key.
512
     * @param \GDAO\Model\RecordInterface $val The value to set it to.
513
     */
514
    public function __set($key, \GDAO\Model\RecordInterface $val): void {
515
        
516
        // set the value
517
        $this->data[$key] = $val;
88✔
518
    }
519

520
    /**
521
     * 
522
     * Returns a string representation of an instance of this class.
523
     * 
524
     * @return string a string representation of an instance of this class.
525
     */
526
    public function __toString(): string {
527
        
528
        return var_export($this->toArray(), true);
12✔
529
    }
530

531
    /**
532
     * 
533
     * Removes a record with the specified key from the collection.
534
     * 
535
     * @param string $key The requested data key.
536
     */
537
    public function __unset($key): void {
538
        
539
        unset($this->data[$key]);
12✔
540
    }
541
    
542
    //Hooks
543
    
544
    /**
545
     * {@inheritDoc}
546
     */
547
    public function preDeleteAll(): void { }
548
    
549
    /**
550
     * {@inheritDoc}
551
     */
552
    public function postDeleteAll(): void { }
553
    
554
    /**
555
     * {@inheritDoc}
556
     */
557
    public function preSaveAll(bool $group_inserts_together=false): void { }
558
    
559
    /**
560
     * {@inheritDoc}
561
     */
562
    public function postSaveAll(bool|array $save_all_result, bool $group_inserts_together=false): void { }
563

564
    /**
565
     * {@inheritDoc}
566
     */
567
    public function removeRecord(\GDAO\Model\RecordInterface $record): static {
568
        
569
        foreach($this->data as $key => $current_record) {
4✔
570
            
571
            if( $record === $current_record ) {
4✔
572
                
573
                // only remove record from the collection & not the database
574
                /** @psalm-suppress MixedArgumentTypeCoercion */
575
                $this->offsetUnset($key);
4✔
576
                break;
4✔
577
            }
578
        }
579
        
580
        return $this;
4✔
581
    }
582
    
583
    /**
584
     * Eager load related data into each record in this collection.
585
     * This will save us having to execute individual queries to the database for the 
586
     * related data for each record if we didn't eager load them. 
587
     * 
588
     * For example if you have a collection of 5 BlogPost records that each belong 
589
     * to an author, if you don't eager load, each time you access the Author 
590
     * relation for each BlogPost record, a query will be made to the database
591
     * to fetch the Author data for each BlogPost record, which will lead to 
592
     * 5 queries by the time you finish looping through all the BlogPost 
593
     * records in the collection. If you instead, eager load the Authors,
594
     * all the authors will be fetched by one extra query and stitched into
595
     * the corresponding BlogPost record.
596
     * 
597
     * @param array $relationsToInclude an array of relation names defined in the 
598
     *                                  corresponding Model Class for this collection
599
     *                                  via the relationship definition methods 
600
     *                                  (belongsTo, hasOne, hasMany, hasManyThrough)
601
     * @return static
602
     */
603
    public function eagerLoadRelatedData(array $relationsToInclude): static {
604

605
        /** @psalm-suppress MixedAssignment */
606
        foreach( $relationsToInclude as $relName ) {
×
607

608
            /** @psalm-suppress MixedArgument */
609
            $this->getModel()->loadRelationshipData($relName, $this, true, true);
×
610
        }
611
        
612
        return $this;
×
613
    }
614
}
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