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

rotexsoft / leanorm / 23474882386

24 Mar 2026 05:37AM UTC coverage: 96.042% (+0.2%) from 95.797%
23474882386

push

github

rotexdegba
Minimum PHP 8.2 refactoring

1650 of 1718 relevant lines covered (96.04%)

188.55 hits per line

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

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

5
use Aura\SqlQuery\QueryFactory;
6
use Rotexsoft\SqlSchema\ColumnFactory;
7
use LeanOrm\Utils;
8
use Psr\Log\LoggerInterface;
9
use function sprintf;
10

11
/**
12
 * Supported PDO drivers: mysql, pgsql, sqlite and sqlsrv
13
 *
14
 * @author Rotimi Adegbamigbe
15
 * @copyright (c) 2026, Rotexsoft
16
 * 
17
 * @psalm-suppress MixedPropertyFetch
18
 * @psalm-suppress MoreSpecificReturnType
19
 */
20
class Model extends \GDAO\Model implements \Stringable {
21
    
22
    //overriden parent's properties
23
    /**
24
     * Name of the collection class for this model. 
25
     * Must be a descendant of \GDAO\Model\Collection
26
     */
27
    protected ?string $collection_class_name = \LeanOrm\Model\Collection::class;
28

29
    /**
30
     * Name of the record class for this model. 
31
     * Must be a descendant of \GDAO\Model\Record
32
     */
33
    protected ?string $record_class_name = \LeanOrm\Model\Record::class;
34

35
    /////////////////////////////////////////////////////////////////////////////
36
    // Properties declared here are specific to \LeanOrm\Model and its kids //
37
    /////////////////////////////////////////////////////////////////////////////
38

39
    /**
40
     * Name of the pdo driver currently being used.
41
     * It must be one of the values returned by $this->getPDO()->getAvailableDrivers()
42
     */
43
    protected string $pdo_driver_name = '';
44

45
    public function getPdoDriverName(): string {
46
        
47
        return $this->pdo_driver_name;
1,484✔
48
    }
49

50
    /**
51
     *  An object for interacting with the db
52
     */
53
    protected \LeanOrm\DBConnector $db_connector;
54
    
55
    /**
56
     * @psalm-suppress PossiblyUnusedMethod
57
     */
58
    public function getDbConnector(): \LeanOrm\DBConnector { return $this->db_connector; }
59
    
60
    // Query Logging related properties
61
    protected bool $can_log_queries = false;
62

63
    public function canLogQueries(): bool { return $this->can_log_queries; }
64

65
    /** @psalm-suppress PossiblyUnusedMethod */
66
    public function enableQueryLogging(): static {
67

68
        $this->can_log_queries = true;
92✔
69
        return $this;
92✔
70
    }
71
    
72
    /** @psalm-suppress PossiblyUnusedMethod */
73
    public function disableQueryLogging(): static {
74

75
        $this->can_log_queries = false;
76✔
76
        return $this;
76✔
77
    }
78

79
    /**
80
     * @var array<string, array>
81
     */
82
    protected array $query_log = [];
83

84
    /**
85
     * @var array<string, array>
86
     */
87
    protected static array $all_instances_query_log = [];
88

89
    protected ?LoggerInterface $logger = null;
90

91
    /** @psalm-suppress PossiblyUnusedMethod */
92
    public function setLogger(?LoggerInterface $logger): static {
93
        
94
        $this->logger = $logger;
184✔
95
        return $this;
184✔
96
    }
97
    
98
    public function getLogger(): ?LoggerInterface { return $this->logger; }
99
    
100
    /**
101
     * {@inheritDoc}
102
     */
103
    public function __construct(
104
        string $dsn = '', 
105
        string $username = '', 
106
        #[\SensitiveParameter] string $passwd = '', 
107
        array $pdo_driver_opts = [],
108
        string $primary_col_name='',
109
        string $table_name=''
110
    ) {
111
        $pri_col_not_set_exception_msg = '';
1,484✔
112

113
        try {
114

115
            parent::__construct($dsn, $username, $passwd, $pdo_driver_opts, $primary_col_name, $table_name);
1,484✔
116

117
        } catch (\GDAO\ModelPrimaryColNameNotSetDuringConstructionException $e) {
48✔
118

119
            //$this->primary_col (primary key colun has not yet been set)
120
            //hold this exception for later if necessary
121
            $pri_col_not_set_exception_msg = $e->getMessage();
40✔
122
        }
123
        
124
        DBConnector::configure($dsn, null, $dsn);//use $dsn as connection name in 3rd parameter
1,484✔
125
        DBConnector::configure(DBConnector::CONFIG_KEY_USERNAME, $username, $dsn);//use $dsn as connection name in 3rd parameter
1,484✔
126
        DBConnector::configure(DBConnector::CONFIG_KEY_PASSWORD, $passwd, $dsn);//use $dsn as connection name in 3rd parameter
1,484✔
127

128
        if( $pdo_driver_opts !== [] ) {
1,484✔
129

130
            DBConnector::configure(DBConnector::CONFIG_KEY_DRIVER_OPTS, $pdo_driver_opts, $dsn);//use $dsn as connection name in 3rd parameter
8✔
131
        }
132

133
        $this->db_connector = DBConnector::create($dsn);//use $dsn as connection name
1,484✔
134
        
135
        /** @psalm-suppress MixedAssignment */
136
        $this->pdo_driver_name = $this->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME);
1,484✔
137
        $this->pdoServerVersionCheck();
1,484✔
138

139
        ////////////////////////////////////////////////////////
140
        //Get and Set Table Schema Meta Data if Not Already Set
141
        ////////////////////////////////////////////////////////
142
        if ( $this->table_cols === [] ) {
1,484✔
143

144
            /** @var array $dsn_n_tname_to_schema_def_map */
145
            static $dsn_n_tname_to_schema_def_map;
1,484✔
146

147
            if( !$dsn_n_tname_to_schema_def_map ) {
1,484✔
148

149
                $dsn_n_tname_to_schema_def_map = [];
4✔
150
            }
151

152
            if( array_key_exists($dsn.$this->getTableName(), $dsn_n_tname_to_schema_def_map) ) {
1,484✔
153

154
                // use cached schema definition for the dsn and table name combo
155
                /** @psalm-suppress MixedAssignment */
156
                $schema_definitions = $dsn_n_tname_to_schema_def_map[$dsn.$this->getTableName()];
1,480✔
157

158
            } else {
159

160
                // let's make sure that $this->getTableName() is an actual table / view in the db
161
                if( !$this->tableExistsInDB($this->getTableName()) ) {
44✔
162

163
                    $msg = "ERROR: Table name `{$this->getTableName()}` supplied to " 
8✔
164
                            . static::class . '::' . __FUNCTION__ . '(...)'
8✔
165
                            . ' does not exist as a table or view in the database';
8✔
166
                    throw new \LeanOrm\Exceptions\BadModelTableNameException($msg);
8✔
167
                }
168

169
                $this->table_cols = [];
36✔
170
                $schema_definitions = $this->fetchTableColsFromDB($this->getTableName());
36✔
171

172
                // cache schema definition for the current dsn and table combo
173
                $dsn_n_tname_to_schema_def_map[$dsn.$this->getTableName()] = $schema_definitions;
36✔
174

175
            } // if( array_key_exists($dsn.$this->getTableName(), $dsn_n_tname_to_schema_def_map) )
176

177
            if( 
178
                $primary_col_name !== ''
1,484✔
179
                && !$this->columnExistsInDbTable($this->getTableName(), $primary_col_name) 
1,484✔
180
            ) {
181
                $msg = "ERROR: The Primary Key column name `{$primary_col_name}` supplied to " 
8✔
182
                        . static::class . '::' . __FUNCTION__ . '(...)'
8✔
183
                        . " does not exist as an actual column in the supplied table `{$this->getTableName()}`.";
8✔
184
                throw new \LeanOrm\Exceptions\BadModelPrimaryColumnNameException($msg);
8✔
185
            }
186

187
            /** @psalm-suppress MixedAssignment */
188
            foreach( $schema_definitions as $colname => $metadata_obj ) {
1,484✔
189

190
                /** @psalm-suppress MixedArrayOffset */
191
                $this->table_cols[$colname] = [];
1,484✔
192
                /** @psalm-suppress MixedArrayOffset */
193
                $this->table_cols[$colname]['name'] = $metadata_obj->name;
1,484✔
194
                /** @psalm-suppress MixedArrayOffset */
195
                $this->table_cols[$colname]['type'] = $metadata_obj->type;
1,484✔
196
                /** @psalm-suppress MixedArrayOffset */
197
                $this->table_cols[$colname]['size'] = $metadata_obj->size;
1,484✔
198
                /** @psalm-suppress MixedArrayOffset */
199
                $this->table_cols[$colname]['scale'] = $metadata_obj->scale;
1,484✔
200
                /** @psalm-suppress MixedArrayOffset */
201
                $this->table_cols[$colname]['notnull'] = $metadata_obj->notnull;
1,484✔
202
                /** @psalm-suppress MixedArrayOffset */
203
                $this->table_cols[$colname]['default'] = $metadata_obj->default;
1,484✔
204
                /** @psalm-suppress MixedArrayOffset */
205
                $this->table_cols[$colname]['autoinc'] = $metadata_obj->autoinc;
1,484✔
206
                /** @psalm-suppress MixedArrayOffset */
207
                $this->table_cols[$colname]['primary'] = $metadata_obj->primary;
1,484✔
208

209
                if( $this->getPrimaryCol() === '' && $metadata_obj->primary ) {
1,484✔
210

211
                    //this is a primary column
212
                    /** @psalm-suppress MixedArgument */
213
                    $this->setPrimaryCol($metadata_obj->name);
20✔
214

215
                } // $this->getPrimaryCol() === '' && $metadata_obj->primary
216
            } // foreach( $schema_definitions as $colname => $metadata_obj )
217
            
218
        } else { // $this->table_cols !== []
219

220
            if($this->getPrimaryCol() === '') {
136✔
221

222
                /** @psalm-suppress MixedAssignment */
223
                foreach ($this->table_cols as $colname => $col_metadata) {
12✔
224

225
                    /** @psalm-suppress MixedArrayAccess */
226
                    if($col_metadata['primary']) {
12✔
227

228
                        /** @psalm-suppress MixedArgumentTypeCoercion */
229
                        $this->setPrimaryCol($colname);
12✔
230
                        break;
12✔
231
                    }
232
                }
233
            } // if($this->getPrimaryCol() === '')
234
            
235
        }// if ( $this->table_cols === [] )
236

237
        //if $this->getPrimaryCol() is still '' at this point, throw an exception.
238
        if( $this->getPrimaryCol() === '' ) {
1,484✔
239

240
            throw new \GDAO\ModelPrimaryColNameNotSetDuringConstructionException($pri_col_not_set_exception_msg);
8✔
241
        }
242
    }
243
    
244
    /**
245
     * Detect if an unsupported DB Engine version is being used
246
     */
247
    protected function pdoServerVersionCheck(): void {
248

249
        if(strtolower($this->getPdoDriverName()) === 'sqlite') {
1,484✔
250

251
            $pdo_obj = $this->getPDO();
1,484✔
252

253
            /** @psalm-suppress MixedAssignment */
254
            $sqlite_version_number = $pdo_obj->getAttribute(\PDO::ATTR_SERVER_VERSION);
1,484✔
255

256
            /** @psalm-suppress MixedArgument */
257
            if(version_compare($sqlite_version_number, '3.7.10', '<=')) {
1,484✔
258

259
                $source = static::class . '::' . __FUNCTION__ . '(...)';
×
260
                $msg = "ERROR ({$source}): Sqlite version `{$sqlite_version_number}`"
×
261
                        . " detected. This package requires Sqlite version `3.7.11`"
×
262
                        . " or greater. Use a newer version of sqlite or use another"
×
263
                        . " DB server supported by this package." . PHP_EOL . 'Goodbye!!';
×
264

265
                throw new \LeanOrm\Exceptions\UnsupportedPdoServerVersionException($msg);
×
266

267
            } // if( version_compare($sqlite_version_number, '3.7.10', '<=') )
268
        } // if( strtolower($this->getPdoDriverName()) === 'sqlite' )
269
    }
270
    
271
    protected function columnExistsInDbTable(string $table_name, string $column_name): bool {
272
        
273
        $schema_definitions = $this->fetchTableColsFromDB($table_name);
1,484✔
274
        
275
        return array_key_exists($column_name, $schema_definitions);
1,484✔
276
    }
277
    
278
    protected function tableExistsInDB(string $table_name): bool {
279
        
280
        $list_of_tables_and_views = $this->fetchTableListFromDB();
1,484✔
281
        
282
        return in_array($table_name, $list_of_tables_and_views, true);
1,484✔
283
    }
284
    
285
    /**
286
     * @psalm-suppress MoreSpecificReturnType
287
     * @psalm-suppress UnusedPsalmSuppress
288
     */
289
    protected function getSchemaQueryingObject(): \Rotexsoft\SqlSchema\AbstractSchema {
290
        
291
        // a column definition factory 
292
        $column_factory = new ColumnFactory();
1,484✔
293
        /** @psalm-suppress MixedAssignment */
294
        $pdo_driver_name = $this->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME);
1,484✔
295

296
        $schema_class_name = '\\Rotexsoft\\SqlSchema\\' . ucfirst((string) $pdo_driver_name) . 'Schema';
1,484✔
297

298
        // the schema discovery object
299
        /** @psalm-suppress LessSpecificReturnStatement */
300
        return new $schema_class_name($this->getPDO(), $column_factory);
1,484✔
301
    }
302
    
303
    /**
304
     * @return mixed[]|string[]
305
     */
306
    protected function fetchTableListFromDB(): array {
307
        
308
        if(strtolower((string) $this->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME)) === 'sqlite') {
1,484✔
309
            
310
            // Do this to return both tables and views
311
            // $this->getSchemaQueryingObject()->fetchTableList()
312
            // only returns table names but no views. That's why
313
            // we are doing this here
314
            return $this->db_connector->dbFetchCol("
1,484✔
315
                SELECT name FROM sqlite_master
316
                UNION ALL
317
                SELECT name FROM sqlite_temp_master
318
                ORDER BY name
319
            ");
1,484✔
320
        }
321
        
322
        $schema = $this->getSchemaQueryingObject();
×
323
        
324
        if(strtolower((string) $this->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME)) ===  'pgsql') {
×
325
            
326
            // Calculate schema name for postgresql
327
            /** @psalm-suppress MixedAssignment */
328
            $schema_name = $this->db_connector->dbFetchValue('SELECT CURRENT_SCHEMA');
×
329
            
330
            /** @psalm-suppress MixedArgument */
331
            return $schema->fetchTableList($schema_name);
×
332
        }
333
        
334
        return $schema->fetchTableList();
×
335
    }
336
    
337
    /**
338
     * @return \Rotexsoft\SqlSchema\Column[]
339
     */
340
    protected function fetchTableColsFromDB(string $table_name): array {
341
        
342
        // This works so far for mysql, postgres & sqlite.  
343
        // Will need to test what works for MS Sql Server
344
        return $this->getSchemaQueryingObject()->fetchTableCols($table_name);
1,484✔
345
    }
346
    
347
    /** @psalm-suppress MoreSpecificReturnType */
348
    public function getSelect(): \Aura\SqlQuery\Common\Select {
349

350
        $selectObj = (new QueryFactory($this->getPdoDriverName()))->newSelect();
352✔
351

352
        $selectObj->from($this->getTableName());
352✔
353

354
        /** @psalm-suppress LessSpecificReturnStatement */
355
        return $selectObj;
352✔
356
    }
357

358
    /**
359
     * {@inheritDoc}
360
     * 
361
     * @psalm-suppress LessSpecificReturnStatement
362
     */
363
    #[\Override]
364
    public function createNewCollection(\GDAO\Model\RecordInterface ...$list_of_records): \GDAO\Model\CollectionInterface {
365

366
        return ($this->collection_class_name === null || $this->collection_class_name === '')
224✔
367
                ? //default to creating new collection of type \LeanOrm\Model\Collection
224✔
368
                  new \LeanOrm\Model\Collection($this, ...$list_of_records)
8✔
369
                : new $this->collection_class_name($this, ...$list_of_records);
224✔
370
    }
371

372
    /**
373
     * {@inheritDoc}
374
     */
375
    #[\Override]
376
    public function createNewRecord(array $col_names_and_values = []): \GDAO\Model\RecordInterface {
377

378

379
        $result = ($this->record_class_name === null || $this->record_class_name === '')
528✔
380
                    ? //default to creating new record of type \LeanOrm\Model\Record
528✔
381
                      new \LeanOrm\Model\Record($col_names_and_values, $this)
8✔
382
                    : new $this->record_class_name($col_names_and_values, $this);
528✔
383
        
384
        /** @psalm-suppress LessSpecificReturnStatement */
385
        return $result;
528✔
386
    }
387

388
    /**
389
     * 
390
     * @param string $table_name name of the table to select from (will default to $this->getTableName() if empty)
391
     * @return \Aura\SqlQuery\Common\Select or any of its descendants
392
     */
393
    protected function createQueryObjectIfNullAndAddColsToQuery(
394
        ?\Aura\SqlQuery\Common\Select $select_obj=null, string $table_name=''
395
    ): \Aura\SqlQuery\Common\Select {
396
        
397
        $initiallyNull = !( $select_obj instanceof \Aura\SqlQuery\Common\Select );
352✔
398
        $select_obj ??= $this->getSelect();
352✔
399

400
        if( $table_name === '' ) {
352✔
401

402
            $table_name = $this->getTableName();
352✔
403
        }
404

405
        if($initiallyNull || !$select_obj->hasCols()) {
352✔
406

407
            // We either just created the select object in this method or
408
            // there are no cols to select specified yet. 
409
            // Let's select all cols.
410
            $select_obj->cols([' ' . $table_name . '.* ']);
344✔
411
        }
412

413
        return $select_obj;
352✔
414
    }
415

416
    /**
417
     * @return mixed[]
418
     * @psalm-suppress PossiblyUnusedMethod
419
     */
420
    public function getDefaultColVals(): array {
421

422
        $default_colvals = [];
8✔
423

424
        /** @psalm-suppress MixedAssignment */
425
        foreach($this->table_cols as $col_name => $col_metadata) {
8✔
426
            
427
            /** @psalm-suppress MixedArrayAccess */
428
            $default_colvals[$col_name] = $col_metadata['default'];
8✔
429
        }
430

431
        return $default_colvals;
8✔
432
    }
433
    
434
    public function loadRelationshipData(
435
        string $rel_name, 
436
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data, 
437
        bool $wrap_each_row_in_a_record=false, 
438
        bool $wrap_records_in_collection=false
439
    ): static {
440

441
        /** @psalm-suppress MixedArrayAccess */
442
        if( 
443
            array_key_exists($rel_name, $this->relations) 
160✔
444
            && $this->relations[$rel_name]['relation_type'] === \GDAO\Model::RELATION_TYPE_HAS_MANY 
160✔
445
        ) {
446
            $this->loadHasMany($rel_name, $parent_data, $wrap_each_row_in_a_record, $wrap_records_in_collection);
160✔
447

448
        } else if (
449
            array_key_exists($rel_name, $this->relations) 
128✔
450
            && $this->relations[$rel_name]['relation_type'] === \GDAO\Model::RELATION_TYPE_HAS_MANY_THROUGH        
128✔
451
        ) {
452
            $this->loadHasManyThrough($rel_name, $parent_data, $wrap_each_row_in_a_record, $wrap_records_in_collection);
120✔
453

454
        } else if (
455
            array_key_exists($rel_name, $this->relations) 
128✔
456
            && $this->relations[$rel_name]['relation_type'] === \GDAO\Model::RELATION_TYPE_HAS_ONE
128✔
457
        ) {
458
            $this->loadHasOne($rel_name, $parent_data, $wrap_each_row_in_a_record);
128✔
459

460
        } else if (
461
            array_key_exists($rel_name, $this->relations) 
128✔
462
            && $this->relations[$rel_name]['relation_type'] === \GDAO\Model::RELATION_TYPE_BELONGS_TO
128✔
463
        ) {
464
            $this->loadBelongsTo($rel_name, $parent_data, $wrap_each_row_in_a_record);
128✔
465
        }
466

467
        return $this;
160✔
468
    }
469

470
    public function recursivelyStitchRelatedData(
471
        Model $model,
472
        array $relations_to_include,
473
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$fetched_data,
474
        bool $wrap_records_in_collection = false
475
    ): void {
476

477
        if ($relations_to_include !== []) {
296✔
478

479
            /** @psalm-suppress MixedAssignment */
480
            foreach ($relations_to_include as $potential_relation_name => $potential_array_of_relations_to_include_next) {
104✔
481

482
                $current_relation_name = $potential_relation_name;
104✔
483

484
                if (\is_numeric($potential_relation_name)) {
104✔
485

486
                    // $potential_array_of_relations_to_include_next must be a string containing the name of a relation to fetch
487
                    $current_relation_name = (string) $potential_array_of_relations_to_include_next; // value has to be relation name
104✔
488

489
                    // no need for recursion here, just load data for current relation name
490
                    $model->loadRelationshipData($current_relation_name, $fetched_data, true, $wrap_records_in_collection);
104✔
491

492
                } elseif (
493
                    \is_array($potential_array_of_relations_to_include_next)
32✔
494
                    && \count($potential_array_of_relations_to_include_next) > 0
32✔
495
                ) {
496

497
                    // $potential_array_of_relations_to_include_next is
498
                    // a sub array of relation names to be eager loaded into 
499
                    // the related records fetched based on the relationship
500
                    // defined by $current_relation_name
501
                    // 
502
                    // E.g if $fetched_data contains data fetched from authors table
503
                    // $potential_relation_name could be something like 'posts' meaning 
504
                    // we want posts associated with each author to be eager loaded
505
                    // 
506
                    // $potential_array_of_next_level_relation_names could be something like 
507
                    // ['comments', 'tags'] meaning that we want to eager load
508
                    // the comments and also tags associated with the posts we 
509
                    // just eager loaded for the fetched authors
510
                    // 
511
                    // $relations_to_include would look something like this:
512
                    // ['posts'=> ['comments', 'tags'], ...]
513

514
                    /** @psalm-suppress MixedArgumentTypeCoercion */
515
                    $model->loadRelationshipData($current_relation_name, $fetched_data, true, $wrap_records_in_collection);
32✔
516

517
                    $model_obj_for_recursive_call = null;
32✔
518
                    $fetched_data_for_recursive_call = [];
32✔
519

520
                    /** @psalm-suppress MixedArgument */
521
                    if(
522
                        $fetched_data instanceof \GDAO\Model\RecordInterface
32✔
523
                        && 
524
                        (
525
                            ($fetched_data->{$current_relation_name} instanceof \GDAO\Model\RecordInterface)
32✔
526
                            ||
32✔
527
                            (
32✔
528
                                (
32✔
529
                                    $fetched_data->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface
32✔
530
                                    || \is_array($fetched_data->{$current_relation_name})
32✔
531
                                )
32✔
532
                                && count($fetched_data->{$current_relation_name}) > 0
32✔
533
                            )
32✔
534
                        )
535
                    ) {
536
                        $fetched_data_for_recursive_call = $fetched_data->{$current_relation_name};
8✔
537

538
                        if (
539
                            $fetched_data->{$current_relation_name} instanceof \GDAO\Model\RecordInterface 
8✔
540
                            || $fetched_data->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface
8✔
541
                        ) {
542
                            /** @psalm-suppress MixedMethodCall */
543
                            $model_obj_for_recursive_call = $fetched_data->{$current_relation_name}->getModel();
8✔
544

545
                        } else {
546

547
                            // $fetched_data->{$current_relation_name} is an array
548
                            /** @psalm-suppress MixedMethodCall */
549
                            $model_obj_for_recursive_call = reset($fetched_data->{$current_relation_name})->getModel();
×
550
                        }
551
                    } elseif(
552
                        (
553
                            $fetched_data instanceof \GDAO\Model\CollectionInterface
32✔
554
                            || \is_array($fetched_data)
32✔
555
                        )
556
                        && count($fetched_data) > 0
32✔
557
                    ) {
558
                        foreach ($fetched_data as $current_record) {
32✔
559

560
                            if (
561
                                ($current_record->{$current_relation_name} instanceof \GDAO\Model\RecordInterface)
32✔
562
                            ) {
563
                                $fetched_data_for_recursive_call[] = $current_record->{$current_relation_name};
4✔
564

565
                                if ($model_obj_for_recursive_call === null) {
4✔
566

567
                                    /** @psalm-suppress MixedMethodCall */
568
                                    $model_obj_for_recursive_call = $current_record->{$current_relation_name}->getModel();
4✔
569
                                }
570

571
                            } elseif (
572
                                (
573
                                    $current_record->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface 
32✔
574
                                    || \is_array($current_record->{$current_relation_name})
32✔
575
                                ) && count($current_record->{$current_relation_name}) > 0
32✔
576
                            ) {
577
                                foreach ($current_record->{$current_relation_name} as $current_related_record) {
32✔
578

579
                                    $fetched_data_for_recursive_call[] = $current_related_record;
32✔
580

581
                                    if ($model_obj_for_recursive_call === null) {
32✔
582

583
                                        /** @psalm-suppress MixedMethodCall */
584
                                        $model_obj_for_recursive_call = $current_related_record->getModel();
32✔
585

586
                                    } // if ($model_obj_for_recursive_call === null)
587
                                } // foreach ($current_record->{$current_relation_name} as $current_related_record)
588
                            } // if( ($current_record->{$current_relation_name} instanceof \GDAO\Model\RecordInterface) ) ......
589
                        } // foreach ($fetched_data as $current_record)
590
                    } // if( $fetched_data instanceof \GDAO\Model\RecordInterface .....
591

592
                    if (
593
                        $model_obj_for_recursive_call !== null
32✔
594
                        && $fetched_data_for_recursive_call !== []
32✔
595
                        && $model_obj_for_recursive_call instanceof Model
32✔
596
                    ) {
597
                        // do recursive call
598
                        /** @psalm-suppress MixedArgument */
599
                        $this->recursivelyStitchRelatedData(
32✔
600
                                $model_obj_for_recursive_call,
32✔
601
                                $potential_array_of_relations_to_include_next,
32✔
602
                                $fetched_data_for_recursive_call,
32✔
603
                                $wrap_records_in_collection
32✔
604
                        );
32✔
605
                    } // if ($model_obj_for_recursive_call !== null && $fetched_data_for_recursive_call !== [])
606
                } // if (\is_numeric($potential_relation_name)) ..elseif (\is_array($potential_array_of_next_level_relation_names))
607
            } // foreach ($relations_to_include as $potential_relation_name => $potential_array_of_next_level_relation_names)
608
        } // if ($relations_to_include !== []) 
609
    }
610

611
    /**
612
     * @psalm-suppress PossiblyUnusedReturnValue
613
     */
614
    protected function validateRelatedModelClassName(string $model_class_name): bool {
615
        
616
        // DO NOT use static::class here, we always want self::class
617
        // Subclasses can override this method to redefine their own
618
        // Valid Related Model Class logic.
619
        $parent_model_class_name = self::class;
1,484✔
620
        
621
        if( !is_a($model_class_name, $parent_model_class_name, true) ) {
1,484✔
622
            
623
            //throw exception
624
            $msg = "ERROR: '{$model_class_name}' is not a subclass or instance of "
32✔
625
                 . "'{$parent_model_class_name}'. A model class name specified"
32✔
626
                 . " for fetching related data must be the name of a class that"
32✔
627
                 . " is a sub-class or instance of '{$parent_model_class_name}'"
32✔
628
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
629
                 . PHP_EOL;
32✔
630

631
            throw new \LeanOrm\Exceptions\BadModelClassNameForFetchingRelatedDataException($msg);
32✔
632
        }
633
        
634
        return true;
1,484✔
635
    }
636
    
637
    /**
638
     * @psalm-suppress PossiblyUnusedReturnValue
639
     */
640
    protected function validateRelatedCollectionClassName(string $collection_class_name): bool {
641

642
        $parent_collection_class_name = \GDAO\Model\CollectionInterface::class;
1,484✔
643

644
        if( !is_subclass_of($collection_class_name, $parent_collection_class_name, true) ) {
1,484✔
645

646
            //throw exception
647
            $msg = "ERROR: '{$collection_class_name}' is not a subclass of "
32✔
648
                 . "'{$parent_collection_class_name}'. A collection class name specified"
32✔
649
                 . " for fetching related data must be the name of a class that"
32✔
650
                 . " is a sub-class of '{$parent_collection_class_name}'"
32✔
651
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
652
                 . PHP_EOL;
32✔
653

654
            throw new \LeanOrm\Exceptions\BadCollectionClassNameForFetchingRelatedDataException($msg);
32✔
655
        }
656

657
        return true;
1,484✔
658
    }
659

660
    /**
661
     * @psalm-suppress PossiblyUnusedReturnValue
662
     */
663
    protected function validateRelatedRecordClassName(string $record_class_name): bool {
664
        
665
        $parent_record_class_name = \GDAO\Model\RecordInterface::class;
1,484✔
666

667
        if( !is_subclass_of($record_class_name, $parent_record_class_name, true)  ) {
1,484✔
668

669
            //throw exception
670
            $msg = "ERROR: '{$record_class_name}' is not a subclass of "
32✔
671
                 . "'{$parent_record_class_name}'. A record class name specified for"
32✔
672
                 . " fetching related data must be the name of a class that"
32✔
673
                 . " is a sub-class of '{$parent_record_class_name}'"
32✔
674
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
675
                 . PHP_EOL;
32✔
676

677
            throw new \LeanOrm\Exceptions\BadRecordClassNameForFetchingRelatedDataException($msg);
32✔
678
        }
679

680
        return true;
1,484✔
681
    }
682
    
683
    protected function loadHasMany( 
684
        string $rel_name, 
685
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data, 
686
        bool $wrap_each_row_in_a_record=false, 
687
        bool $wrap_records_in_collection=false 
688
    ): void {
689
        /** @psalm-suppress MixedArrayAccess */
690
        if( 
691
            array_key_exists($rel_name, $this->relations) 
160✔
692
            && $this->relations[$rel_name]['relation_type']  === \GDAO\Model::RELATION_TYPE_HAS_MANY
160✔
693
        ) {
694
            /*
695
                -- BASIC SQL For Fetching the Related Data
696

697
                -- $parent_data is a collection or array of records    
698
                SELECT {$foreign_table_name}.*
699
                  FROM {$foreign_table_name}
700
                 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} IN ( $fkey_col_in_my_table column values in $parent_data )
701

702
                -- OR
703

704
                -- $parent_data is a single record
705
                SELECT {$foreign_table_name}.*
706
                  FROM {$foreign_table_name}
707
                 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} = {$parent_data->$fkey_col_in_my_table}
708
            */
709
            [
160✔
710
                $fkey_col_in_foreign_table, $fkey_col_in_my_table, 
160✔
711
                $foreign_model_obj, $related_data
160✔
712
            ] = $this->getBelongsToOrHasOneOrHasManyData($rel_name, $parent_data);
160✔
713

714
            if ( 
715
                $parent_data instanceof \GDAO\Model\CollectionInterface
160✔
716
                || is_array($parent_data)
160✔
717
            ) {
718
                ///////////////////////////////////////////////////////////
719
                // Stitch the related data to the approriate parent records
720
                ///////////////////////////////////////////////////////////
721

722
                $fkey_val_to_related_data_keys = [];
96✔
723

724
                // Generate a map of 
725
                //      foreign key value => [keys of related rows in $related_data]
726
                /** @psalm-suppress MixedAssignment */
727
                foreach ($related_data as $curr_key => $related_datum) {
96✔
728

729
                    /** @psalm-suppress MixedArrayOffset */
730
                    $curr_fkey_val = $related_datum[$fkey_col_in_foreign_table];
96✔
731

732
                    /** @psalm-suppress MixedArgument */
733
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
96✔
734

735
                        /** @psalm-suppress MixedArrayOffset */
736
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
96✔
737
                    }
738

739
                    // Add current key in $related_data to sub array of keys for the 
740
                    // foreign key value in the current related row $related_datum
741
                    /** @psalm-suppress MixedArrayOffset */
742
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
96✔
743

744
                } // foreach ($related_data as $curr_key => $related_datum)
745

746
                // Now use $fkey_val_to_related_data_keys map to
747
                // look up related rows of data for each parent row of data
748
                /** @psalm-suppress MixedAssignment */
749
                foreach( $parent_data as $p_rec_key => $parent_row ) {
96✔
750

751
                    $matching_related_rows = [];
96✔
752

753
                    /** 
754
                     * @psalm-suppress MixedArgument 
755
                     * @psalm-suppress MixedArrayOffset 
756
                     */
757
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
96✔
758

759
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
96✔
760

761
                            $matching_related_rows[] = $related_data[$related_data_key];
96✔
762
                        }
763
                    }
764

765
                    /** @psalm-suppress MixedArgument */
766
                    $this->wrapRelatedDataInsideRecordsAndCollection(
96✔
767
                        $matching_related_rows, $foreign_model_obj, 
96✔
768
                        $wrap_each_row_in_a_record, $wrap_records_in_collection
96✔
769
                    );
96✔
770

771
                    //set the related data for the current parent row / record
772
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
96✔
773
                        /**
774
                         * @psalm-suppress MixedArrayTypeCoercion
775
                         * @psalm-suppress MixedArrayOffset
776
                         * @psalm-suppress MixedMethodCall
777
                         */
778
                        $parent_data[$p_rec_key]->setRelatedData($rel_name, $matching_related_rows);
80✔
779

780
                    } else {
781

782
                        //the current row must be an array
783
                        /**
784
                         * @psalm-suppress MixedArrayOffset
785
                         * @psalm-suppress MixedArrayAssignment
786
                         * @psalm-suppress InvalidArgument
787
                         */
788
                        $parent_data[$p_rec_key][$rel_name] = $matching_related_rows;
24✔
789
                    }
790
                } // foreach( $parent_data as $p_rec_key => $parent_record )
791

792
                ////////////////////////////////////////////////////////////////
793
                // End: Stitch the related data to the approriate parent records
794
                ////////////////////////////////////////////////////////////////
795

796
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
96✔
797

798
                /** @psalm-suppress MixedArgument */
799
                $this->wrapRelatedDataInsideRecordsAndCollection(
96✔
800
                    $related_data, $foreign_model_obj, 
96✔
801
                    $wrap_each_row_in_a_record, $wrap_records_in_collection
96✔
802
                );
96✔
803

804
                ///////////////////////////////////////////////
805
                //stitch the related data to the parent record
806
                ///////////////////////////////////////////////
807
                $parent_data->setRelatedData($rel_name, $related_data);
96✔
808

809
            } // else if ($parent_data instanceof \GDAO\Model\RecordInterface)
810
        } // if( array_key_exists($rel_name, $this->relations) )
811
    }
812
    
813
    protected function loadHasManyThrough( 
814
        string $rel_name, 
815
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data, 
816
        bool $wrap_each_row_in_a_record=false, 
817
        bool $wrap_records_in_collection=false 
818
    ): void {
819
        /** @psalm-suppress MixedArrayAccess */
820
        if( 
821
            array_key_exists($rel_name, $this->relations) 
120✔
822
            && $this->relations[$rel_name]['relation_type']  === \GDAO\Model::RELATION_TYPE_HAS_MANY_THROUGH
120✔
823
        ) {
824
            /** @psalm-suppress MixedAssignment */
825
            $rel_info = $this->relations[$rel_name];
120✔
826

827
            /** 
828
             * @psalm-suppress MixedAssignment
829
             * @psalm-suppress MixedArgument
830
             */
831
            $foreign_table_name = Utils::arrayGet($rel_info, 'foreign_table');
120✔
832

833
            /** @psalm-suppress MixedAssignment */
834
            $fkey_col_in_foreign_table = 
120✔
835
                Utils::arrayGet($rel_info, 'col_in_foreign_table_linked_to_join_table');
120✔
836

837
            /** @psalm-suppress MixedAssignment */
838
            $foreign_models_class_name = 
120✔
839
                Utils::arrayGet($rel_info, 'foreign_models_class_name', \LeanOrm\Model::class);
120✔
840

841
            /** @psalm-suppress MixedAssignment */
842
            $pri_key_col_in_foreign_models_table = 
120✔
843
                Utils::arrayGet($rel_info, 'primary_key_col_in_foreign_table');
120✔
844

845
            /** @psalm-suppress MixedAssignment */
846
            $fkey_col_in_my_table = 
120✔
847
                    Utils::arrayGet($rel_info, 'col_in_my_table_linked_to_join_table');
120✔
848

849
            //join table params
850
            /** @psalm-suppress MixedAssignment */
851
            $join_table_name = Utils::arrayGet($rel_info, 'join_table');
120✔
852

853
            /** @psalm-suppress MixedAssignment */
854
            $col_in_join_table_linked_to_my_models_table = 
120✔
855
                Utils::arrayGet($rel_info, 'col_in_join_table_linked_to_my_table');
120✔
856

857
            /** @psalm-suppress MixedAssignment */
858
            $col_in_join_table_linked_to_foreign_models_table = 
120✔
859
                Utils::arrayGet($rel_info, 'col_in_join_table_linked_to_foreign_table');
120✔
860

861
            /** @psalm-suppress MixedAssignment */
862
            $sql_query_modifier = 
120✔
863
                Utils::arrayGet($rel_info, 'sql_query_modifier', null);
120✔
864

865
            /** @psalm-suppress MixedArgument */
866
            $foreign_model_obj = 
120✔
867
                $this->createRelatedModelObject(
120✔
868
                    $foreign_models_class_name,
120✔
869
                    $pri_key_col_in_foreign_models_table,
120✔
870
                    $foreign_table_name
120✔
871
                );
120✔
872

873
            /** @psalm-suppress MixedAssignment */
874
            $foreign_models_collection_class_name = 
120✔
875
                Utils::arrayGet($rel_info, 'foreign_models_collection_class_name', '');
120✔
876

877
            /** @psalm-suppress MixedAssignment */
878
            $foreign_models_record_class_name = 
120✔
879
                Utils::arrayGet($rel_info, 'foreign_models_record_class_name', '');
120✔
880

881
            if($foreign_models_collection_class_name !== '') {
120✔
882

883
                /** @psalm-suppress MixedArgument */
884
                $foreign_model_obj->setCollectionClassName($foreign_models_collection_class_name);
120✔
885
            }
886

887
            if($foreign_models_record_class_name !== '') {
120✔
888

889
                /** @psalm-suppress MixedArgument */
890
                $foreign_model_obj->setRecordClassName($foreign_models_record_class_name);
120✔
891
            }
892

893
            $query_obj = $foreign_model_obj->getSelect();
120✔
894

895
            $query_obj->cols( [" {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} ", " {$foreign_table_name}.* "] );
120✔
896

897
            /** @psalm-suppress MixedArgument */
898
            $query_obj->innerJoin(
120✔
899
                            $join_table_name, 
120✔
900
                            " {$join_table_name}.{$col_in_join_table_linked_to_foreign_models_table} = {$foreign_table_name}.{$fkey_col_in_foreign_table} "
120✔
901
                        );
120✔
902

903
            if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
120✔
904

905
                $query_obj->where(
32✔
906
                    " {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} = :leanorm_col_in_join_table_linked_to_my_models_table_val ",
32✔
907
                    ['leanorm_col_in_join_table_linked_to_my_models_table_val' => $parent_data->$fkey_col_in_my_table]
32✔
908
                );
32✔
909

910
            } else {
911

912
                //assume it's a collection or array
913
                /** @psalm-suppress MixedArgument */
914
                $col_vals = $this->getColValsFromArrayOrCollection(
88✔
915
                                $parent_data, $fkey_col_in_my_table
88✔
916
                            );
88✔
917

918
                if( $col_vals !== [] ) {
88✔
919

920
                    $this->addWhereInAndOrIsNullToQuery(
88✔
921
                        "{$join_table_name}.{$col_in_join_table_linked_to_my_models_table}", 
88✔
922
                        $col_vals, 
88✔
923
                        $query_obj
88✔
924
                    );
88✔
925
                }
926
            }
927

928
            if(\is_callable($sql_query_modifier)) {
120✔
929

930
                $sql_query_modifier = Utils::getClosureFromCallable($sql_query_modifier);
16✔
931
                // modify the query object before executing the query
932
                /** @psalm-suppress MixedAssignment */
933
                $query_obj = $sql_query_modifier($query_obj);
16✔
934
            }
935

936
            /** @psalm-suppress MixedAssignment */
937
            $params_2_bind_2_sql = $query_obj->getBindValues();
120✔
938

939
            /** @psalm-suppress MixedAssignment */
940
            $sql_2_get_related_data = $query_obj->__toString();
120✔
941

942
/*
943
-- SQL For Fetching the Related Data
944

945
-- $parent_data is a collection or array of records    
946
SELECT {$foreign_table_name}.*,
947
       {$join_table_name}.{$col_in_join_table_linked_to_my_models_table}
948
  FROM {$foreign_table_name}
949
  JOIN {$join_table_name} ON {$join_table_name}.{$col_in_join_table_linked_to_foreign_models_table} = {$foreign_table_name}.{$fkey_col_in_foreign_table}
950
 WHERE {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} IN ( $fkey_col_in_my_table column values in $parent_data )
951

952
OR
953

954
-- $parent_data is a single record
955
SELECT {$foreign_table_name}.*,
956
       {$join_table_name}.{$col_in_join_table_linked_to_my_models_table}
957
  FROM {$foreign_table_name}
958
  JOIN {$join_table_name} ON {$join_table_name}.{$col_in_join_table_linked_to_foreign_models_table} = {$foreign_table_name}.{$fkey_col_in_foreign_table}
959
 WHERE {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} = {$parent_data->$fkey_col_in_my_table}
960
*/
961
            /** @psalm-suppress MixedArgument */
962
            $this->logQuery($sql_2_get_related_data, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
120✔
963

964
            //GRAB DA RELATED DATA
965
            $related_data = 
120✔
966
                $this->db_connector
120✔
967
                     ->dbFetchAll($sql_2_get_related_data, $params_2_bind_2_sql);
120✔
968

969
            if ( 
970
                $parent_data instanceof \GDAO\Model\CollectionInterface
120✔
971
                || is_array($parent_data)
120✔
972
            ) {
973
                ///////////////////////////////////////////////////////////
974
                // Stitch the related data to the approriate parent records
975
                ///////////////////////////////////////////////////////////
976

977
                $fkey_val_to_related_data_keys = [];
88✔
978

979
                // Generate a map of 
980
                //      foreign key value => [keys of related rows in $related_data]
981
                /** @psalm-suppress MixedAssignment */
982
                foreach ($related_data as $curr_key => $related_datum) {
88✔
983

984
                    /** @psalm-suppress MixedArrayOffset */
985
                    $curr_fkey_val = $related_datum[$col_in_join_table_linked_to_my_models_table];
88✔
986

987
                    /** @psalm-suppress MixedArgument */
988
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
88✔
989

990
                        /** @psalm-suppress MixedArrayOffset */
991
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
88✔
992
                    }
993

994
                    // Add current key in $related_data to sub array of keys for the 
995
                    // foreign key value in the current related row $related_datum
996
                    /** @psalm-suppress MixedArrayOffset */
997
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
88✔
998

999
                } // foreach ($related_data as $curr_key => $related_datum)
1000

1001
                // Now use $fkey_val_to_related_data_keys map to
1002
                // look up related rows of data for each parent row of data
1003
                /** @psalm-suppress MixedAssignment */
1004
                foreach( $parent_data as $p_rec_key => $parent_row ) {
88✔
1005

1006
                    $matching_related_rows = [];
88✔
1007

1008
                    /** 
1009
                     * @psalm-suppress MixedArrayOffset
1010
                     * @psalm-suppress MixedArgument
1011
                     */
1012
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
88✔
1013

1014
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
88✔
1015

1016
                            $matching_related_rows[] = $related_data[$related_data_key];
88✔
1017
                        }
1018
                    }
1019

1020
                    $this->wrapRelatedDataInsideRecordsAndCollection(
88✔
1021
                        $matching_related_rows, $foreign_model_obj, 
88✔
1022
                        $wrap_each_row_in_a_record, $wrap_records_in_collection
88✔
1023
                    );
88✔
1024

1025
                    //set the related data for the current parent row / record
1026
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
88✔
1027

1028
                        /** 
1029
                         * @psalm-suppress MixedArrayOffset
1030
                         * @psalm-suppress MixedArrayTypeCoercion
1031
                         */
1032
                        $parent_data[$p_rec_key]->setRelatedData($rel_name, $matching_related_rows);
72✔
1033

1034
                    } else {
1035

1036
                        //the current row must be an array
1037
                        /** 
1038
                         * @psalm-suppress MixedArrayOffset
1039
                         * @psalm-suppress MixedArrayAssignment
1040
                         * @psalm-suppress InvalidArgument
1041
                         */
1042
                        $parent_data[$p_rec_key][$rel_name] = $matching_related_rows;
16✔
1043
                    }
1044

1045
                } // foreach( $parent_data as $p_rec_key => $parent_record )
1046

1047
                ////////////////////////////////////////////////////////////////
1048
                // End: Stitch the related data to the approriate parent records
1049
                ////////////////////////////////////////////////////////////////
1050

1051
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
32✔
1052

1053
                $this->wrapRelatedDataInsideRecordsAndCollection(
32✔
1054
                    $related_data, $foreign_model_obj, 
32✔
1055
                    $wrap_each_row_in_a_record, $wrap_records_in_collection
32✔
1056
                );
32✔
1057

1058
                //stitch the related data to the parent record
1059
                $parent_data->setRelatedData($rel_name, $related_data);
32✔
1060
            } // else if ( $parent_data instanceof \GDAO\Model\RecordInterface )
1061
        } // if( array_key_exists($rel_name, $this->relations) )
1062
    }
1063
    
1064
    protected function loadHasOne( 
1065
        string $rel_name, 
1066
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data, 
1067
        bool $wrap_row_in_a_record=false
1068
    ): void {
1069
        /**
1070
         * @psalm-suppress MixedArrayAccess
1071
         */
1072
        if( 
1073
            array_key_exists($rel_name, $this->relations) 
128✔
1074
            && $this->relations[$rel_name]['relation_type']  === \GDAO\Model::RELATION_TYPE_HAS_ONE
128✔
1075
        ) {
1076
            [
128✔
1077
                $fkey_col_in_foreign_table, $fkey_col_in_my_table, 
128✔
1078
                $foreign_model_obj, $related_data
128✔
1079
            ] = $this->getBelongsToOrHasOneOrHasManyData($rel_name, $parent_data);
128✔
1080

1081
/*
1082
-- SQL For Fetching the Related Data
1083

1084
-- $parent_data is a collection or array of records    
1085
SELECT {$foreign_table_name}.*
1086
  FROM {$foreign_table_name}
1087
 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} IN ( $fkey_col_in_my_table column values in $parent_data )
1088

1089
OR
1090

1091
-- $parent_data is a single record
1092
SELECT {$foreign_table_name}.*
1093
  FROM {$foreign_table_name}
1094
 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} = {$parent_data->$fkey_col_in_my_table}
1095
*/
1096

1097
            if ( 
1098
                $parent_data instanceof \GDAO\Model\CollectionInterface
128✔
1099
                || is_array($parent_data)
128✔
1100
            ) {
1101
                ///////////////////////////////////////////////////////////
1102
                // Stitch the related data to the approriate parent records
1103
                ///////////////////////////////////////////////////////////
1104

1105
                $fkey_val_to_related_data_keys = [];
96✔
1106

1107
                // Generate a map of 
1108
                //      foreign key value => [keys of related rows in $related_data]
1109
                /** @psalm-suppress MixedAssignment */
1110
                foreach ($related_data as $curr_key => $related_datum) {
96✔
1111

1112
                    /** @psalm-suppress MixedArrayOffset */
1113
                    $curr_fkey_val = $related_datum[$fkey_col_in_foreign_table];
96✔
1114

1115
                    /** @psalm-suppress MixedArgument */
1116
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
96✔
1117

1118
                        /** @psalm-suppress MixedArrayOffset */
1119
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
96✔
1120
                    }
1121

1122
                    // Add current key in $related_data to sub array of keys for the 
1123
                    // foreign key value in the current related row $related_datum
1124
                    /** @psalm-suppress MixedArrayOffset */
1125
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
96✔
1126

1127
                } // foreach ($related_data as $curr_key => $related_datum)
1128

1129
                // Now use $fkey_val_to_related_data_keys map to
1130
                // look up related rows of data for each parent row of data
1131
                /** @psalm-suppress MixedAssignment */
1132
                foreach( $parent_data as $p_rec_key => $parent_row ) {
96✔
1133

1134
                    $matching_related_rows = [];
96✔
1135

1136
                    /** 
1137
                     * @psalm-suppress MixedArgument
1138
                     * @psalm-suppress MixedArrayOffset
1139
                     */
1140
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
96✔
1141

1142
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
96✔
1143

1144
                            // There should really only be one matching related 
1145
                            // record per parent record since this is a hasOne
1146
                            // relationship
1147
                            $matching_related_rows[] = $related_data[$related_data_key];
96✔
1148
                        }
1149
                    }
1150

1151
                    /** @psalm-suppress MixedArgument */
1152
                    $this->wrapRelatedDataInsideRecordsAndCollection(
96✔
1153
                        $matching_related_rows, $foreign_model_obj, 
96✔
1154
                        $wrap_row_in_a_record, false
96✔
1155
                    );
96✔
1156

1157
                    //set the related data for the current parent row / record
1158
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
96✔
1159

1160
                        // There should really only be one matching related 
1161
                        // record per parent record since this is a hasOne
1162
                        // relationship. That's why we are doing 
1163
                        // $matching_related_rows[0]
1164
                        /** 
1165
                         * @psalm-suppress MixedArrayTypeCoercion
1166
                         * @psalm-suppress MixedArrayOffset
1167
                         * @psalm-suppress MixedMethodCall
1168
                         */
1169
                        $parent_data[$p_rec_key]->setRelatedData(
72✔
1170
                                $rel_name, 
72✔
1171
                                (\count($matching_related_rows) > 0) 
72✔
1172
                                    ? $matching_related_rows[0] : []
72✔
1173
                            );
72✔
1174

1175
                    } else {
1176

1177
                        // There should really only be one matching related 
1178
                        // record per parent record since this is a hasOne
1179
                        // relationship. That's why we are doing 
1180
                        // $matching_related_rows[0]
1181

1182
                        //the current row must be an array
1183
                        /**
1184
                         * @psalm-suppress MixedArrayOffset
1185
                         * @psalm-suppress MixedArrayAssignment
1186
                         * @psalm-suppress MixedArgument
1187
                         * @psalm-suppress PossiblyInvalidArgument
1188
                         */
1189
                        $parent_data[$p_rec_key][$rel_name] = 
24✔
1190
                                (\count($matching_related_rows) > 0) 
24✔
1191
                                    ? $matching_related_rows[0] : [];
24✔
1192
                    }
1193
                } // foreach( $parent_data as $p_rec_key => $parent_record )
1194

1195
                ////////////////////////////////////////////////////////////////
1196
                // End: Stitch the related data to the approriate parent records
1197
                ////////////////////////////////////////////////////////////////
1198

1199
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
72✔
1200

1201
                /** @psalm-suppress MixedArgument */
1202
                $this->wrapRelatedDataInsideRecordsAndCollection(
72✔
1203
                            $related_data, $foreign_model_obj, 
72✔
1204
                            $wrap_row_in_a_record, false
72✔
1205
                        );
72✔
1206

1207
                //stitch the related data to the parent record
1208
                /** @psalm-suppress MixedArgument */
1209
                $parent_data->setRelatedData(
72✔
1210
                    $rel_name, 
72✔
1211
                    (\count($related_data) > 0) ? \array_shift($related_data) : []
72✔
1212
                );
72✔
1213
            } // else if ($parent_data instanceof \GDAO\Model\RecordInterface)
1214
        } // if( array_key_exists($rel_name, $this->relations) )
1215
    }
1216
    
1217
    protected function loadBelongsTo(
1218
        string $rel_name, 
1219
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data, 
1220
        bool $wrap_row_in_a_record=false
1221
    ): void {
1222

1223
        /** @psalm-suppress MixedArrayAccess */
1224
        if( 
1225
            array_key_exists($rel_name, $this->relations) 
128✔
1226
            && $this->relations[$rel_name]['relation_type']  === \GDAO\Model::RELATION_TYPE_BELONGS_TO
128✔
1227
        ) {
1228
            //quick hack
1229
            /** @psalm-suppress MixedArrayAssignment */
1230
            $this->relations[$rel_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_ONE;
128✔
1231

1232
            //I really don't see the difference in the sql to fetch data for
1233
            //a has-one relationship and a belongs-to relationship. Hence, I
1234
            //have resorted to using the same code to satisfy both relationships
1235
            $this->loadHasOne($rel_name, $parent_data, $wrap_row_in_a_record);
128✔
1236

1237
            //undo quick hack
1238
            /** @psalm-suppress MixedArrayAssignment */
1239
            $this->relations[$rel_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_BELONGS_TO;
128✔
1240
        }
1241
    }
1242
    
1243
    /**
1244
     * @return mixed[]
1245
     */
1246
    protected function getBelongsToOrHasOneOrHasManyData(
1247
        string $rel_name, 
1248
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data
1249
    ): array {
1250
        /** 
1251
         * @psalm-suppress MixedAssignment
1252
         */
1253
        $rel_info = $this->relations[$rel_name];
160✔
1254

1255
        /** 
1256
         * @psalm-suppress MixedAssignment
1257
         * @psalm-suppress MixedArgument
1258
         */
1259
        $foreign_table_name = Utils::arrayGet($rel_info, 'foreign_table');
160✔
1260

1261
        /** @psalm-suppress MixedAssignment */
1262
        $fkey_col_in_foreign_table = 
160✔
1263
            Utils::arrayGet($rel_info, 'foreign_key_col_in_foreign_table');
160✔
1264
        
1265
        /** @psalm-suppress MixedAssignment */
1266
        $foreign_models_class_name = 
160✔
1267
            Utils::arrayGet($rel_info, 'foreign_models_class_name', \LeanOrm\Model::class);
160✔
1268

1269
        /** @psalm-suppress MixedAssignment */
1270
        $pri_key_col_in_foreign_models_table = 
160✔
1271
            Utils::arrayGet($rel_info, 'primary_key_col_in_foreign_table');
160✔
1272

1273
        /** @psalm-suppress MixedAssignment */
1274
        $fkey_col_in_my_table = 
160✔
1275
                Utils::arrayGet($rel_info, 'foreign_key_col_in_my_table');
160✔
1276

1277
        /** @psalm-suppress MixedAssignment */
1278
        $sql_query_modifier = 
160✔
1279
                Utils::arrayGet($rel_info, 'sql_query_modifier', null);
160✔
1280

1281
        /** @psalm-suppress MixedArgument */
1282
        $foreign_model_obj = $this->createRelatedModelObject(
160✔
1283
                                        $foreign_models_class_name,
160✔
1284
                                        $pri_key_col_in_foreign_models_table,
160✔
1285
                                        $foreign_table_name
160✔
1286
                                    );
160✔
1287
        
1288
        /** @psalm-suppress MixedAssignment */
1289
        $foreign_models_collection_class_name = 
160✔
1290
            Utils::arrayGet($rel_info, 'foreign_models_collection_class_name', '');
160✔
1291

1292
        /** @psalm-suppress MixedAssignment */
1293
        $foreign_models_record_class_name = 
160✔
1294
            Utils::arrayGet($rel_info, 'foreign_models_record_class_name', '');
160✔
1295

1296
        if($foreign_models_collection_class_name !== '') {
160✔
1297
            
1298
            /** @psalm-suppress MixedArgument */
1299
            $foreign_model_obj->setCollectionClassName($foreign_models_collection_class_name);
160✔
1300
        }
1301

1302
        if($foreign_models_record_class_name !== '') {
160✔
1303
            
1304
            /** @psalm-suppress MixedArgument */
1305
            $foreign_model_obj->setRecordClassName($foreign_models_record_class_name);
160✔
1306
        }
1307

1308
        $query_obj = $foreign_model_obj->getSelect();
160✔
1309

1310
        if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
160✔
1311

1312
            $query_obj->where(
96✔
1313
                " {$foreign_table_name}.{$fkey_col_in_foreign_table} = :leanorm_fkey_col_in_foreign_table_val ",
96✔
1314
                ['leanorm_fkey_col_in_foreign_table_val' => $parent_data->$fkey_col_in_my_table]
96✔
1315
            );
96✔
1316

1317
        } else {
1318
            //assume it's a collection or array
1319
            /** @psalm-suppress MixedArgument */
1320
            $col_vals = $this->getColValsFromArrayOrCollection(
104✔
1321
                            $parent_data, $fkey_col_in_my_table
104✔
1322
                        );
104✔
1323

1324
            if( $col_vals !== [] ) {
104✔
1325
                
1326
                $this->addWhereInAndOrIsNullToQuery(
104✔
1327
                    "{$foreign_table_name}.{$fkey_col_in_foreign_table}", 
104✔
1328
                    $col_vals, 
104✔
1329
                    $query_obj
104✔
1330
                );
104✔
1331
            }
1332
        }
1333

1334
        if(\is_callable($sql_query_modifier)) {
160✔
1335

1336
            $sql_query_modifier = Utils::getClosureFromCallable($sql_query_modifier);
56✔
1337
            
1338
            // modify the query object before executing the query
1339
            /** @psalm-suppress MixedAssignment */
1340
            $query_obj = $sql_query_modifier($query_obj);
56✔
1341
        }
1342

1343
        if($query_obj->hasCols() === false){
160✔
1344

1345
            $query_obj->cols(["{$foreign_table_name}.*"]);
160✔
1346
        }
1347
        
1348
        /** @psalm-suppress MixedAssignment */
1349
        $params_2_bind_2_sql = $query_obj->getBindValues();
160✔
1350
        
1351
        /** @psalm-suppress MixedAssignment */
1352
        $sql_2_get_related_data = $query_obj->__toString();
160✔
1353
        
1354
        /** @psalm-suppress MixedArgument */
1355
        $this->logQuery($sql_2_get_related_data, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
160✔
1356
        
1357
        return [
160✔
1358
            $fkey_col_in_foreign_table, $fkey_col_in_my_table, $foreign_model_obj,
160✔
1359
            $this->db_connector->dbFetchAll($sql_2_get_related_data, $params_2_bind_2_sql) // fetch the related data
160✔
1360
        ]; 
160✔
1361
    }
1362
    
1363
    /** @psalm-suppress MoreSpecificReturnType */
1364
    protected function createRelatedModelObject(
1365
        string $f_models_class_name, 
1366
        string $pri_key_col_in_f_models_table, 
1367
        string $f_table_name
1368
    ): Model {
1369
        //$foreign_models_class_name will never be empty it will default to \LeanOrm\Model
1370
        //$foreign_table_name will never be empty because it is needed for fetching the 
1371
        //related data
1372
        if( ($f_models_class_name === '') ) {
160✔
1373

1374
            $f_models_class_name = \LeanOrm\Model::class;
×
1375
        }
1376

1377
        try {
1378
            //try to create a model object for the related data
1379
            /** @psalm-suppress MixedMethodCall */
1380
            $related_model = new $f_models_class_name(
160✔
1381
                $this->dsn, 
160✔
1382
                $this->username, 
160✔
1383
                $this->passwd, 
160✔
1384
                $this->pdo_driver_opts,
160✔
1385
                $pri_key_col_in_f_models_table,
160✔
1386
                $f_table_name
160✔
1387
            );
160✔
1388
            
1389
        } catch (\GDAO\ModelPrimaryColNameNotSetDuringConstructionException) {
×
1390
            
1391
            $msg = "ERROR: Couldn't create foreign model of type '{$f_models_class_name}'."
×
1392
                 . "  No primary key supplied for the database table '{$f_table_name}'"
×
1393
                 . " associated with the foreign table class '{$f_models_class_name}'."
×
1394
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
×
1395
                 . PHP_EOL;
×
1396
            throw new \LeanOrm\Exceptions\RelatedModelNotCreatedException($msg);
×
1397
            
1398
        } catch (\GDAO\ModelTableNameNotSetDuringConstructionException) {
×
1399
            
1400
            $msg = "ERROR: Couldn't create foreign model of type '{$f_models_class_name}'."
×
1401
                 . "  No database table name supplied."
×
1402
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
×
1403
                 . PHP_EOL;
×
1404
            throw new \LeanOrm\Exceptions\RelatedModelNotCreatedException($msg);
×
1405
            
1406
        } catch (\LeanOrm\Exceptions\BadModelTableNameException) {
×
1407
            
1408
            $msg = "ERROR: Couldn't create foreign model of type '{$f_models_class_name}'."
×
1409
                 . " The supplied table name `{$f_table_name}` does not exist as a table or"
×
1410
                 . " view in the database."
×
1411
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
×
1412
                 . PHP_EOL;
×
1413
            throw new \LeanOrm\Exceptions\RelatedModelNotCreatedException($msg);
×
1414
            
1415
        } catch(\LeanOrm\Exceptions\BadModelPrimaryColumnNameException) {
×
1416
            
1417
            $msg = "ERROR: Couldn't create foreign model of type '{$f_models_class_name}'."
×
1418
                 . " The supplied primary key column `{$pri_key_col_in_f_models_table}` "
×
1419
                 . " does not exist in the supplied table named `{$f_table_name}`."
×
1420
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
×
1421
                 . PHP_EOL;
×
1422
            throw new \LeanOrm\Exceptions\RelatedModelNotCreatedException($msg);
×
1423
        }
1424
        
1425
        if($this->canLogQueries()) {
160✔
1426
            
1427
            // Transfer logger settings from this model
1428
            // to the newly created model
1429
            /** @psalm-suppress MixedMethodCall */
1430
            $related_model->enableQueryLogging();
44✔
1431
        }
1432
        
1433
        /** @psalm-suppress MixedMethodCall */
1434
        if( 
1435
            $this->getLogger() instanceof \Psr\Log\LoggerInterface
160✔
1436
            && $related_model->getLogger() === null
160✔
1437
        ) {
1438
            /** @psalm-suppress MixedMethodCall */
1439
            $related_model->setLogger($this->getLogger());
8✔
1440
        }
1441
        
1442
        /** @psalm-suppress LessSpecificReturnStatement */
1443
        return $related_model;
160✔
1444
    }
1445

1446
    /**
1447
     * @return mixed[]
1448
     */
1449
    protected function getColValsFromArrayOrCollection(
1450
        \GDAO\Model\CollectionInterface|array &$parent_data, 
1451
        string $fkey_col_in_my_table
1452
    ): array {
1453
        $col_vals = [];
104✔
1454

1455
        if ( is_array($parent_data) ) {
104✔
1456

1457
            /** @psalm-suppress MixedAssignment */
1458
            foreach($parent_data as $data) {
80✔
1459

1460
                /** 
1461
                 * @psalm-suppress MixedAssignment
1462
                 * @psalm-suppress MixedArrayAccess
1463
                 */
1464
                $col_vals[] = $data[$fkey_col_in_my_table];
80✔
1465
            }
1466

1467
        } elseif($parent_data instanceof \GDAO\Model\CollectionInterface) {
56✔
1468

1469
            $col_vals = $parent_data->getColVals($fkey_col_in_my_table);
56✔
1470
        }
1471

1472
        return $col_vals;
104✔
1473
    }
1474

1475
    /** @psalm-suppress ReferenceConstraintViolation */
1476
    protected function wrapRelatedDataInsideRecordsAndCollection(
1477
        array &$matching_related_records, Model $foreign_model_obj, 
1478
        bool $wrap_each_row_in_a_record, bool $wrap_records_in_collection
1479
    ): void {
1480
        
1481
        if( $wrap_each_row_in_a_record ) {
160✔
1482

1483
            //wrap into records of the appropriate class
1484
            /** @psalm-suppress MixedAssignment */
1485
            foreach ($matching_related_records as $key=>$rec_data) {
144✔
1486
                
1487
                // Mark as not new because this is a related row of data that 
1488
                // already exists in the db as opposed to a row of data that
1489
                // has never been saved to the db
1490
                /** @psalm-suppress MixedArgument */
1491
                $matching_related_records[$key] = 
144✔
1492
                    $foreign_model_obj->createNewRecord($rec_data)
144✔
1493
                                      ->markAsNotNew();
144✔
1494
            }
1495
        }
1496

1497
        if($wrap_records_in_collection) {
160✔
1498
            
1499
            /** @psalm-suppress MixedArgument */
1500
            $matching_related_records = $foreign_model_obj->createNewCollection(...$matching_related_records);
128✔
1501
        }
1502
    }
1503

1504
    /**
1505
     * 
1506
     * Fetches a collection by primary key value(s).
1507
     * 
1508
     *      # `$use_collections === true`: return a \LeanOrm\Model\Collection of 
1509
     *        \LeanOrm\Model\Record records each matching the values in $ids
1510
     * 
1511
     *      # `$use_collections === false`:
1512
     * 
1513
     *          - `$use_records === true`: return an array of \LeanOrm\Model\Record 
1514
     *            records each matching the values in $ids
1515
     * 
1516
     *          - `$use_records === false`: return an array of rows (each row being
1517
     *            an associative array) each matching the values in $ids
1518
     * 
1519
     * @param array $ids an array of scalar values of the primary key field of db rows to be fetched
1520
     * 
1521
     * @param string[] $relations_to_include names of relations to include
1522
     * 
1523
     * @param bool $use_records true if each matched db row should be wrapped in 
1524
     *                          an instance of \LeanOrm\Model\Record; false if 
1525
     *                          rows should be returned as associative php 
1526
     *                          arrays. If $use_collections === true, records
1527
     *                          will be returned inside a collection regardless
1528
     *                          of the value of $use_records
1529
     * 
1530
     * @param bool $use_collections true if each matched db row should be wrapped
1531
     *                              in an instance of \LeanOrm\Model\Record and 
1532
     *                              all the records wrapped in an instance of
1533
     *                              \LeanOrm\Model\Collection; false if all 
1534
     *                              matched db rows should be returned in a
1535
     *                              php array
1536
     * 
1537
     * @param bool $use_p_k_val_as_key true means the collection or array returned should be keyed on the primary key values
1538
     * 
1539
     */
1540
    public function fetch(
1541
        array $ids, 
1542
        ?\Aura\SqlQuery\Common\Select $select_obj=null, 
1543
        array $relations_to_include=[], 
1544
        bool $use_records=false, 
1545
        bool $use_collections=false, 
1546
        bool $use_p_k_val_as_key=false
1547
    ): \GDAO\Model\CollectionInterface|array {
1548
        
1549
        $select_obj ??= $this->createQueryObjectIfNullAndAddColsToQuery($select_obj);
20✔
1550
        
1551
        if( $ids !== [] ) {
20✔
1552
            
1553
            $_result = [];
20✔
1554
            $this->addWhereInAndOrIsNullToQuery($this->getPrimaryCol(), $ids, $select_obj);
20✔
1555

1556
            if( $use_collections ) {
20✔
1557

1558
                $_result = ($use_p_k_val_as_key) 
20✔
1559
                            ? $this->fetchRecordsIntoCollectionKeyedOnPkVal($select_obj, $relations_to_include) 
8✔
1560
                            : $this->fetchRecordsIntoCollection($select_obj, $relations_to_include);
20✔
1561

1562
            } else {
1563

1564
                if( $use_records ) {
8✔
1565

1566
                    $_result = ($use_p_k_val_as_key) 
8✔
1567
                                ? $this->fetchRecordsIntoArrayKeyedOnPkVal($select_obj, $relations_to_include) 
8✔
1568
                                : $this->fetchRecordsIntoArray($select_obj, $relations_to_include);
8✔
1569
                } else {
1570

1571
                    //default
1572
                    $_result = ($use_p_k_val_as_key) 
8✔
1573
                                ? $this->fetchRowsIntoArrayKeyedOnPkVal($select_obj, $relations_to_include) 
8✔
1574
                                : $this->fetchRowsIntoArray($select_obj, $relations_to_include);
8✔
1575
                } // if( $use_records ) else ...
1576
            } // if( $use_collections ) else ...
1577
            
1578
            /** @psalm-suppress TypeDoesNotContainType */
1579
            if(!($_result instanceof \GDAO\Model\CollectionInterface) && !is_array($_result)) {
20✔
1580
               
1581
                return $use_collections ? $this->createNewCollection() : [];
×
1582
            } 
1583
            
1584
            return $_result;
20✔
1585
            
1586
        } // if( $ids !== [] )
1587

1588
        // return empty collection or array
1589
        return $use_collections ? $this->createNewCollection() : [];
8✔
1590
    }
1591

1592
    /**
1593
     * {@inheritDoc}
1594
     */
1595
    #[\Override]
1596
    public function fetchRecordsIntoCollection(?object $query=null, array $relations_to_include=[]): \GDAO\Model\CollectionInterface {
1597

1598
        return $this->doFetchRecordsIntoCollection(
112✔
1599
                    ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null, 
112✔
1600
                    $relations_to_include
112✔
1601
                );
112✔
1602
    }
1603

1604
    public function fetchRecordsIntoCollectionKeyedOnPkVal(?\Aura\SqlQuery\Common\Select $select_obj=null, array $relations_to_include=[]): \GDAO\Model\CollectionInterface {
1605

1606
        return $this->doFetchRecordsIntoCollection($select_obj, $relations_to_include, true);
36✔
1607
    }
1608

1609
    /**
1610
     * @psalm-suppress InvalidReturnType
1611
     */
1612
    protected function doFetchRecordsIntoCollection(
1613
        ?\Aura\SqlQuery\Common\Select $select_obj=null, 
1614
        array $relations_to_include=[], 
1615
        bool $use_p_k_val_as_key=false
1616
    ): \GDAO\Model\CollectionInterface {
1617
        $results = $this->createNewCollection();
132✔
1618
        $data = $this->getArrayOfRecordObjects($select_obj, $use_p_k_val_as_key);
132✔
1619

1620
        if($data !== [] ) {
132✔
1621

1622
            if($use_p_k_val_as_key) {
132✔
1623
                
1624
                foreach ($data as $pkey => $current_record) {
36✔
1625
                    
1626
                    $results[$pkey] = $current_record;
36✔
1627
                }
1628
                
1629
            } else {
1630
               
1631
                $results = $this->createNewCollection(...$data);
112✔
1632
            }
1633
            
1634
            $this->recursivelyStitchRelatedData(
132✔
1635
                model: $this,
132✔
1636
                relations_to_include: $relations_to_include, 
132✔
1637
                fetched_data: $results, 
132✔
1638
                wrap_records_in_collection: true
132✔
1639
            );
132✔
1640
        }
1641

1642
        /** @psalm-suppress InvalidReturnStatement */
1643
        return $results;
132✔
1644
    }
1645

1646
    /**
1647
     * {@inheritDoc}
1648
     */
1649
    #[\Override]
1650
    public function fetchRecordsIntoArray(?object $query=null, array $relations_to_include=[]): array {
1651
        
1652
        return $this->doFetchRecordsIntoArray(
28✔
1653
                    ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null, 
28✔
1654
                    $relations_to_include
28✔
1655
                );
28✔
1656
    }
1657

1658
    /**
1659
     * @return \GDAO\Model\RecordInterface[]
1660
     */
1661
    public function fetchRecordsIntoArrayKeyedOnPkVal(?\Aura\SqlQuery\Common\Select $select_obj=null, array $relations_to_include=[]): array {
1662
        
1663
        return $this->doFetchRecordsIntoArray($select_obj, $relations_to_include, true);
28✔
1664
    }
1665

1666
    /**
1667
     * @return \GDAO\Model\RecordInterface[]
1668
     * @psalm-suppress MixedReturnTypeCoercion
1669
     */
1670
    protected function doFetchRecordsIntoArray(
1671
        ?\Aura\SqlQuery\Common\Select $select_obj=null, 
1672
        array $relations_to_include=[], 
1673
        bool $use_p_k_val_as_key=false
1674
    ): array {
1675
        $results = $this->getArrayOfRecordObjects($select_obj, $use_p_k_val_as_key);
48✔
1676

1677
        if( $results !== [] ) {
48✔
1678
            
1679
            $this->recursivelyStitchRelatedData(
48✔
1680
                model: $this,
48✔
1681
                relations_to_include: $relations_to_include, 
48✔
1682
                fetched_data: $results, 
48✔
1683
                wrap_records_in_collection: false
48✔
1684
            );
48✔
1685
        }
1686

1687
        return $results;
48✔
1688
    }
1689

1690
    /**
1691
     * @return \GDAO\Model\RecordInterface[]
1692
     * @psalm-suppress MixedReturnTypeCoercion
1693
     */
1694
    protected function getArrayOfRecordObjects(?\Aura\SqlQuery\Common\Select $select_obj=null, bool $use_p_k_val_as_key=false): array {
1695

1696
        $results = $this->getArrayOfDbRows($select_obj, $use_p_k_val_as_key);
172✔
1697

1698
        /** @psalm-suppress MixedAssignment */
1699
        foreach ($results as $key=>$value) {
172✔
1700

1701
            /** @psalm-suppress MixedArgument */
1702
            $results[$key] = $this->createNewRecord($value)->markAsNotNew();
172✔
1703
        }
1704
        
1705
        return $results;
172✔
1706
    }
1707

1708
    /**
1709
     * @return mixed[]
1710
     */
1711
    protected function getArrayOfDbRows(?\Aura\SqlQuery\Common\Select $select_obj=null, bool $use_p_k_val_as_key=false): array {
1712

1713
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery($select_obj);
224✔
1714
        $sql = $query_obj->__toString();
224✔
1715
        $params_2_bind_2_sql = $query_obj->getBindValues();
224✔
1716
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
224✔
1717
        
1718
        $results = $this->db_connector->dbFetchAll($sql, $params_2_bind_2_sql);
224✔
1719
        
1720
        if( $use_p_k_val_as_key && $results !== [] && $this->getPrimaryCol() !== '' ) {
224✔
1721

1722
            $results_keyed_by_pk = [];
68✔
1723

1724
            /** @psalm-suppress MixedAssignment */
1725
            foreach( $results as $result ) {
68✔
1726

1727
                /** @psalm-suppress MixedArgument */
1728
                if( !array_key_exists($this->getPrimaryCol(), $result) ) {
68✔
1729

1730
                    $msg = "ERROR: Can't key fetch results by Primary Key value."
×
1731
                         . PHP_EOL . " One or more result rows has no Primary Key field (`{$this->getPrimaryCol()}`)" 
×
1732
                         . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).'
×
1733
                         . PHP_EOL . 'Fetch Results:' . PHP_EOL . var_export($results, true) . PHP_EOL
×
1734
                         . PHP_EOL . "Row without Primary Key field (`{$this->getPrimaryCol()}`):" . PHP_EOL . var_export($result, true) . PHP_EOL;
×
1735

1736
                    throw new \LeanOrm\Exceptions\KeyingFetchResultsByPrimaryKeyFailedException($msg);
×
1737
                }
1738

1739
                // key on primary key value
1740
                /** @psalm-suppress MixedArrayOffset */
1741
                $results_keyed_by_pk[$result[$this->getPrimaryCol()]] = $result;
68✔
1742
            }
1743

1744
            $results = $results_keyed_by_pk;
68✔
1745
        }
1746

1747
        return $results;
224✔
1748
    }
1749

1750
    /**
1751
     * {@inheritDoc}
1752
     */
1753
    #[\Override]
1754
    public function fetchRowsIntoArray(?object $query=null, array $relations_to_include=[]): array {
1755

1756
        return $this->doFetchRowsIntoArray(
84✔
1757
                    ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null, 
84✔
1758
                    $relations_to_include
84✔
1759
                );
84✔
1760
    }
1761

1762
    /**
1763
     * @return array[]
1764
     */
1765
    public function fetchRowsIntoArrayKeyedOnPkVal(?\Aura\SqlQuery\Common\Select $select_obj=null, array $relations_to_include=[]): array {
1766

1767
        return $this->doFetchRowsIntoArray($select_obj, $relations_to_include, true);
20✔
1768
    }
1769

1770
    /**
1771
     * @return array[]
1772
     * @psalm-suppress MixedReturnTypeCoercion
1773
     */
1774
    protected function doFetchRowsIntoArray(
1775
        ?\Aura\SqlQuery\Common\Select $select_obj=null, 
1776
        array $relations_to_include=[], 
1777
        bool $use_p_k_val_as_key=false
1778
    ): array {
1779
        $results = $this->getArrayOfDbRows($select_obj, $use_p_k_val_as_key);
92✔
1780
        
1781
        if( $results !== [] ) {
92✔
1782
            
1783
            /** @psalm-suppress MixedAssignment */
1784
            foreach( $relations_to_include as $key=>$rel_name ) {
92✔
1785

1786
                if(\is_array($rel_name) && \in_array($key, $this->getRelationNames())) {
32✔
1787
                    
1788
                    /////////////////////////////////////////////////////////////////
1789
                    // In case $relations_to_include contains nested relation names
1790
                    // only take the top level relations as doFetchRowsIntoArray
1791
                    // currently doesn't support nested eager fetching of related
1792
                    // data
1793
                    /////////////////////////////////////////////////////////////////
1794
                    $rel_name = $key;
8✔
1795
                    
1796
                } elseif(\is_array($rel_name) && !\in_array($key, $this->getRelationNames())) {
32✔
1797
                    
1798
                    continue;
8✔
1799
                } // else $rel_name is a potential relationship name
1800
                
1801
                /** @psalm-suppress MixedArgument */
1802
                $this->loadRelationshipData($rel_name, $results);
32✔
1803
            }
1804
        }
1805

1806
        return $results;
92✔
1807
    }
1808

1809
    #[\Override]
1810
    public function getPDO(): \PDO {
1811

1812
        //return pdo object associated with the current dsn
1813
        return DBConnector::getDb($this->dsn); 
1,484✔
1814
    }
1815

1816
    /**
1817
     * {@inheritDoc}
1818
     */
1819
    #[\Override]
1820
    public function deleteMatchingDbTableRows(array $cols_n_vals): int {
1821

1822
        $result = 0;
96✔
1823

1824
        if ( $cols_n_vals !== [] ) {
96✔
1825

1826
            //delete statement
1827
            $del_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newDelete();
96✔
1828
            $sel_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newSelect();
96✔
1829
            $del_qry_obj->from($this->getTableName());
96✔
1830
            $sel_qry_obj->from($this->getTableName());
96✔
1831
            $sel_qry_obj->cols([' count(*) ']);
96✔
1832
            $table_cols = $this->getTableColNames();
96✔
1833

1834
            /** @psalm-suppress MixedAssignment */
1835
            foreach ($cols_n_vals as $colname => $colval) {
96✔
1836

1837
                if(!in_array($colname, $table_cols)) {
96✔
1838

1839
                    // specified column is not a valid db table col, remove it
1840
                    unset($cols_n_vals[$colname]);
8✔
1841
                    continue;
8✔
1842
                }
1843

1844
                if (is_array($colval)) {
96✔
1845

1846
                    /** @psalm-suppress MixedAssignment */
1847
                    foreach($colval as $key=>$val) {
24✔
1848

1849
                        if(!$this->isAcceptableDeleteQueryValue($val)) {
24✔
1850

1851
                            $this->throwExceptionForInvalidDeleteQueryArg($val, $cols_n_vals);
8✔
1852
                        }
1853

1854
                        /** @psalm-suppress MixedAssignment */
1855
                        $colval[$key] = $this->stringifyIfStringable($val);
24✔
1856
                    }
1857

1858
                    $this->addWhereInAndOrIsNullToQuery(''.$colname, $colval, $del_qry_obj);
16✔
1859
                    $this->addWhereInAndOrIsNullToQuery(''.$colname, $colval, $sel_qry_obj);
16✔
1860

1861
                } else {
1862

1863
                    if(!$this->isAcceptableDeleteQueryValue($colval)) {
88✔
1864

1865
                        $this->throwExceptionForInvalidDeleteQueryArg($colval, $cols_n_vals);
8✔
1866
                    }
1867

1868
                    $del_qry_obj->where(" {$colname} = :{$colname} ", [$colname => $this->stringifyIfStringable($colval)]);
88✔
1869
                    $sel_qry_obj->where(" {$colname} = :{$colname} ", [$colname => $this->stringifyIfStringable($colval)]);
88✔
1870
                }
1871
            }
1872

1873
            // if at least one of the column names in the array is an actual 
1874
            // db table columns, then do delete
1875
            if($cols_n_vals !== []) {
80✔
1876

1877
                $dlt_qry = $del_qry_obj->__toString();
80✔
1878
                $dlt_qry_params = $del_qry_obj->getBindValues();
80✔
1879
                $this->logQuery($dlt_qry, $dlt_qry_params, __METHOD__, '' . __LINE__);
80✔
1880

1881
                $matching_rows_before_delete = (int) $this->fetchValue($sel_qry_obj);
80✔
1882

1883
                $this->db_connector->executeQuery($dlt_qry, $dlt_qry_params, true);
80✔
1884

1885
                $matching_rows_after_delete = (int) $this->fetchValue($sel_qry_obj);
80✔
1886

1887
                //number of deleted rows
1888
                $result = $matching_rows_before_delete - $matching_rows_after_delete;
80✔
1889
            } // if($cols_n_vals !== []) 
1890
        } // if ( $cols_n_vals !== [] )
1891

1892
        return $result;
80✔
1893
    }
1894
    
1895
    protected function throwExceptionForInvalidDeleteQueryArg(mixed $val, array $cols_n_vals): never {
1896

1897
        $msg = "ERROR: the value "
16✔
1898
             . PHP_EOL . var_export($val, true) . PHP_EOL
16✔
1899
             . " you are trying to use to bulid the where clause for deleting from the table `{$this->getTableName()}`"
16✔
1900
             . " is not acceptable ('".  gettype($val) . "'"
16✔
1901
             . " supplied). Boolean, NULL, numeric or string value expected."
16✔
1902
             . PHP_EOL
16✔
1903
             . "Data supplied to "
16✔
1904
             . static::class . '::' . __FUNCTION__ . '(...).' 
16✔
1905
             . " for buiding the where clause for the deletion:"
16✔
1906
             . PHP_EOL . var_export($cols_n_vals, true) . PHP_EOL
16✔
1907
             . PHP_EOL;
16✔
1908

1909
        throw new \LeanOrm\Exceptions\InvalidArgumentException($msg);
16✔
1910
    }
1911
    
1912
    /**
1913
     * {@inheritDoc}
1914
     */
1915
    #[\Override]
1916
    public function deleteSpecifiedRecord(\GDAO\Model\RecordInterface $record): ?bool {
1917

1918
        $succesfully_deleted = null;
88✔
1919

1920
        if( $record instanceof \LeanOrm\Model\ReadOnlyRecord ) {
88✔
1921

1922
            $msg = "ERROR: Can't delete ReadOnlyRecord from the database in " 
8✔
1923
                 . static::class . '::' . __FUNCTION__ . '(...).'
8✔
1924
                 . PHP_EOL .'Undeleted record' . var_export($record, true) . PHP_EOL;
8✔
1925
            throw new \LeanOrm\Exceptions\CantDeleteReadOnlyRecordFromDBException($msg);
8✔
1926
        }
1927
        
1928
        if( 
1929
            $record->getModel()->getTableName() !== $this->getTableName() 
80✔
1930
            || $record->getModel()::class !== static::class  
80✔
1931
        ) {
1932
            $msg = "ERROR: Can't delete a record (an instance of `%s` belonging to the Model class `%s`) belonging to the database table `%s` " 
16✔
1933
                . "using a Model instance of `%s` belonging to the database table `%s` in " 
16✔
1934
                 . static::class . '::' . __FUNCTION__ . '(...).'
16✔
1935
                 . PHP_EOL .'Undeleted record: ' . PHP_EOL . var_export($record, true) . PHP_EOL; 
16✔
1936
            throw new \LeanOrm\Exceptions\InvalidArgumentException(
16✔
1937
                sprintf(
16✔
1938
                    $msg, $record::class, $record->getModel()::class, 
16✔
1939
                    $record->getModel()->getTableName(),
16✔
1940
                    static::class, $this->getTableName()
16✔
1941
                )
16✔
1942
            );
16✔
1943
        }
1944

1945
        if ( $record->getData() !== [] ) { //test if the record object has data
64✔
1946

1947
            /** @psalm-suppress MixedAssignment */
1948
            $pri_key_val = $record->getPrimaryVal();
64✔
1949
            $cols_n_vals = [$record->getPrimaryCol() => $pri_key_val];
64✔
1950

1951
            $succesfully_deleted = 
64✔
1952
                $this->deleteMatchingDbTableRows($cols_n_vals);
64✔
1953

1954
            if ( $succesfully_deleted === 1 ) {
64✔
1955
                
1956
                $record->markAsNew();
64✔
1957
                
1958
                /** @psalm-suppress MixedAssignment */
1959
                foreach ($this->getRelationNames() as $relation_name) {
64✔
1960
                    
1961
                    // Remove all the related data since the primary key of the 
1962
                    // record may change or there may be ON DELETE CASACADE 
1963
                    // constraints that may have triggred those records being 
1964
                    // deleted from the db because of the deletion of this record
1965
                    /** @psalm-suppress MixedArrayOffset */
1966
                    unset($record[$relation_name]);
40✔
1967
                }
1968
                
1969
                if( $this->isAutoIncrementingField($record->getPrimaryCol()) ) {
64✔
1970
                    
1971
                    // unset the primary key value for auto-incrementing
1972
                    // primary key cols. It is actually set to null via
1973
                    // Record::offsetUnset(..)
1974
                    unset($record[$this->getPrimaryCol()]); 
×
1975
                }
1976
                
1977
            } elseif($succesfully_deleted <= 0) {
16✔
1978
                
1979
                $succesfully_deleted = null;
16✔
1980
                
1981
            } elseif(
1982
                count($this->fetch([$pri_key_val], null, [], true, true)) >= 1 
×
1983
            ) {
1984
                
1985
                //we were still able to fetch the record from the db, so delete failed
1986
                $succesfully_deleted = false;
×
1987
            }
1988
        }
1989

1990
        return ( $succesfully_deleted >= 1 ) ? true : $succesfully_deleted;
64✔
1991
    }
1992

1993
    /**
1994
     * {@inheritDoc}
1995
     */
1996
    #[\Override]
1997
    public function fetchCol(?object $query=null): array {
1998

1999
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
56✔
2000
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
56✔
2001
        );
56✔
2002
        $sql = $query_obj->__toString();
56✔
2003
        $params_2_bind_2_sql = $query_obj->getBindValues();
56✔
2004
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
56✔
2005

2006
        return $this->db_connector->dbFetchCol($sql, $params_2_bind_2_sql);
56✔
2007
    }
2008

2009
    /**
2010
     * {@inheritDoc}
2011
     */
2012
    #[\Override]
2013
    public function fetchOneRecord(?object $query=null, array $relations_to_include=[]): ?\GDAO\Model\RecordInterface {
2014

2015
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
136✔
2016
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
136✔
2017
        );
136✔
2018
        $query_obj->limit(1);
136✔
2019

2020
        $sql = $query_obj->__toString();
136✔
2021
        $params_2_bind_2_sql = $query_obj->getBindValues();
136✔
2022
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
136✔
2023

2024
        /** @psalm-suppress MixedAssignment */
2025
        $result = $this->db_connector->dbFetchOne($sql, $params_2_bind_2_sql);
136✔
2026

2027
        if( $result !== false && is_array($result) && $result !== [] ) {
136✔
2028

2029
            $result = $this->createNewRecord($result)->markAsNotNew();
136✔
2030
            
2031
            $this->recursivelyStitchRelatedData(
136✔
2032
                model: $this,
136✔
2033
                relations_to_include: $relations_to_include, 
136✔
2034
                fetched_data: $result, 
136✔
2035
                wrap_records_in_collection: true
136✔
2036
            );
136✔
2037
        }
2038
        
2039
        if(!($result instanceof \GDAO\Model\RecordInterface)) {
136✔
2040
            
2041
            /** @psalm-suppress ReferenceConstraintViolation */
2042
            $result = null;
40✔
2043
        }
2044

2045
        return $result;
136✔
2046
    }
2047
    
2048
    /**
2049
     * Convenience method to fetch one record by the specified primary key value.
2050
     * @param string[] $relations_to_include names of relations to include
2051
     * @psalm-suppress PossiblyUnusedMethod
2052
     */
2053
    public function fetchOneByPkey(string|int $id, array $relations_to_include = []): ?\GDAO\Model\RecordInterface {
2054
        
2055
        $select = $this->getSelect();
12✔
2056
        $query_placeholder = "leanorm_{$this->getTableName()}_{$this->getPrimaryCol()}_val";
12✔
2057
        $select->where(
12✔
2058
            " {$this->getPrimaryCol()} = :{$query_placeholder} ", 
12✔
2059
            [ $query_placeholder => $id]
12✔
2060
        );
12✔
2061
        
2062
        return $this->fetchOneRecord($select, $relations_to_include);
12✔
2063
    }
2064

2065
    /**
2066
     * {@inheritDoc}
2067
     */
2068
    #[\Override]
2069
    public function fetchPairs(?object $query=null): array {
2070

2071
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
8✔
2072
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
8✔
2073
        );
8✔
2074
        $sql = $query_obj->__toString();
8✔
2075
        $params_2_bind_2_sql = $query_obj->getBindValues();
8✔
2076
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
8✔
2077

2078
        return $this->db_connector->dbFetchPairs($sql, $params_2_bind_2_sql);
8✔
2079
    }
2080

2081
    /**
2082
     * {@inheritDoc}
2083
     */
2084
    #[\Override]
2085
    public function fetchValue(?object $query=null): mixed {
2086

2087
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
112✔
2088
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
112✔
2089
        );
112✔
2090
        $query_obj->limit(1);
112✔
2091

2092
        $query_obj_4_num_matching_rows = clone $query_obj;
112✔
2093

2094
        $sql = $query_obj->__toString();
112✔
2095
        $params_2_bind_2_sql = $query_obj->getBindValues();
112✔
2096
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
112✔
2097

2098
        /** @psalm-suppress MixedAssignment */
2099
        $result = $this->db_connector->dbFetchValue($sql, $params_2_bind_2_sql);
112✔
2100

2101
        // need to issue a second query to get the number of matching rows
2102
        // clear the cols part of the query above while preserving all the
2103
        // other parts of the query
2104
        $query_obj_4_num_matching_rows->resetCols();
112✔
2105
        $query_obj_4_num_matching_rows->cols([' COUNT(*) AS num_rows']);
112✔
2106

2107
        $sql = $query_obj_4_num_matching_rows->__toString();
112✔
2108
        $params_2_bind_2_sql = $query_obj_4_num_matching_rows->getBindValues();
112✔
2109
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
112✔
2110

2111
        /** @psalm-suppress MixedAssignment */
2112
        $num_matching_rows = $this->db_connector->dbFetchOne($sql, $params_2_bind_2_sql);
112✔
2113

2114
        //return null if there wasn't any matching row
2115
        /** @psalm-suppress MixedArrayAccess */
2116
        return (((int)$num_matching_rows['num_rows']) > 0) ? $result : null;
112✔
2117
    }
2118
    
2119
    protected function addTimestampToData(array &$data, ?string $timestamp_col_name, array $table_cols): void {
2120
        
2121
        if(
2122
            ($timestamp_col_name !== null && $timestamp_col_name !== '' )
124✔
2123
            && in_array($timestamp_col_name, $table_cols)
124✔
2124
            && 
2125
            (
2126
                !array_key_exists($timestamp_col_name, $data)
124✔
2127
                || empty($data[$timestamp_col_name])
124✔
2128
            )
2129
        ) {
2130
            //set timestamp to now
2131
            $data[$timestamp_col_name] = date('Y-m-d H:i:s');
72✔
2132
        }
2133
    }
2134
    
2135
    protected function stringifyIfStringable(mixed $col_val, string $col_name='', array $table_cols=[]): mixed {
2136
        
2137
        if(
2138
            ( 
2139
                ($col_name === '' && $table_cols === []) 
172✔
2140
                || in_array($col_name, $table_cols) 
172✔
2141
            )
2142
            && is_object($col_val) && method_exists($col_val, '__toString')
172✔
2143
        ) {
2144
            return $col_val->__toString();
24✔
2145
        }
2146
        
2147
        return $col_val;
172✔
2148
    }
2149
        
2150
    protected function isAcceptableInsertValue(mixed $val): bool {
2151
        
2152
        return is_bool($val) || is_null($val) || is_numeric($val) || is_string($val)
172✔
2153
               || ( is_object($val) && method_exists($val, '__toString') );
172✔
2154
    }
2155
    
2156
    protected function isAcceptableUpdateValue(mixed $val): bool {
2157
        
2158
        return $this->isAcceptableInsertValue($val);
148✔
2159
    }
2160
    
2161
    protected function isAcceptableUpdateQueryValue(mixed $val): bool {
2162
        
2163
        return $this->isAcceptableUpdateValue($val);
140✔
2164
    }
2165
    
2166
    protected function isAcceptableDeleteQueryValue(mixed $val): bool {
2167
        
2168
        return $this->isAcceptableUpdateQueryValue($val);
96✔
2169
    }
2170

2171
    protected function processRowOfDataToInsert(
2172
        array &$data, array &$table_cols, bool &$has_autoinc_pk_col=false
2173
    ): void {
2174

2175
        $this->addTimestampToData($data, $this->created_timestamp_column_name, $table_cols);
92✔
2176
        $this->addTimestampToData($data, $this->updated_timestamp_column_name, $table_cols);
92✔
2177

2178
        // remove non-existent table columns from the data and also
2179
        // converts object values for objects with __toString() to 
2180
        // their string value
2181
        /** @psalm-suppress MixedAssignment */
2182
        foreach ($data as $key => $val) {
92✔
2183

2184
            /** @psalm-suppress MixedAssignment */
2185
            $data[$key] = $this->stringifyIfStringable($val, ''.$key, $table_cols);
92✔
2186

2187
            if ( !in_array($key, $table_cols) ) {
92✔
2188

2189
                unset($data[$key]);
24✔
2190
                // not in the table, so no need to check for autoinc
2191
                continue;
24✔
2192

2193
            } elseif( !$this->isAcceptableInsertValue($val) ) {
92✔
2194

2195
                $msg = "ERROR: the value "
16✔
2196
                     . PHP_EOL . var_export($val, true) . PHP_EOL
16✔
2197
                     . " you are trying to insert into `{$this->getTableName()}`."
16✔
2198
                     . "`{$key}` is not acceptable ('".  gettype($val) . "'"
16✔
2199
                     . " supplied). Boolean, NULL, numeric or string value expected."
16✔
2200
                     . PHP_EOL
16✔
2201
                     . "Data supplied to "
16✔
2202
                     . static::class . '::' . __FUNCTION__ . '(...).' 
16✔
2203
                     . " for insertion:"
16✔
2204
                     . PHP_EOL . var_export($data, true) . PHP_EOL
16✔
2205
                     . PHP_EOL;
16✔
2206

2207
                throw new \GDAO\ModelInvalidInsertValueSuppliedException($msg);
16✔
2208
            }
2209

2210
            // Code below was lifted from Solar_Sql_Model::insert()
2211
            // remove empty autoinc columns to soothe postgres, which won't
2212
            // take explicit NULLs in SERIAL cols.
2213
            /** @psalm-suppress MixedArrayAccess */
2214
            if ( $this->table_cols[$key]['autoinc'] && empty($val) ) {
92✔
2215

2216
                unset($data[$key]);
×
2217

2218
            } // if ( $this->table_cols[$key]['autoinc'] && empty($val) )
2219
        } // foreach ($data as $key => $val)
2220

2221
        /** @psalm-suppress MixedAssignment */
2222
        foreach($this->table_cols as $col_name=>$col_info) {
76✔
2223

2224
            /** @psalm-suppress MixedArrayAccess */
2225
            if ( $col_info['autoinc'] === true && $col_info['primary'] === true ) {
76✔
2226

2227
                if(array_key_exists($col_name, $data)) {
×
2228

2229
                    //no need to add primary key value to the insert 
2230
                    //statement since the column is auto incrementing
2231
                    unset($data[$col_name]);
×
2232

2233
                } // if(array_key_exists($col_name, $data_2_insert))
2234

2235
                $has_autoinc_pk_col = true;
×
2236

2237
            } // if ( $col_info['autoinc'] === true && $col_info['primary'] === true )
2238
        } // foreach($this->table_cols as $col_name=>$col_info)
2239
    }
2240
    
2241
    protected function updateInsertDataArrayWithTheNewlyInsertedRecordFromDB(
2242
        array &$data_2_insert, array $table_cols
2243
    ): void {
2244
        
2245
        if(
2246
            array_key_exists($this->getPrimaryCol(), $data_2_insert)
60✔
2247
            && !empty($data_2_insert[$this->getPrimaryCol()])
60✔
2248
        ) {
2249
            $record = $this->fetchOneRecord(
8✔
2250
                        $this->getSelect()
8✔
2251
                             ->where(
8✔
2252
                                " {$this->getPrimaryCol()} = :{$this->getPrimaryCol()} ",
8✔
2253
                                [ $this->getPrimaryCol() => $data_2_insert[$this->getPrimaryCol()]]
8✔
2254
                             )
8✔
2255
                     );
8✔
2256
            $data_2_insert = ($record instanceof \GDAO\Model\RecordInterface) ? $record->getData() :  $data_2_insert;
8✔
2257
            
2258
        } else {
2259

2260
            // we don't have the primary key.
2261
            // Do a select using all the fields.
2262
            // If only one record is returned, we have found
2263
            // the record we just inserted, else we return $data_2_insert as is 
2264

2265
            $select = $this->getSelect();
60✔
2266

2267
            /** @psalm-suppress MixedAssignment */
2268
            foreach ($data_2_insert as $col => $val) {
60✔
2269

2270
                /** @psalm-suppress MixedAssignment */
2271
                $processed_val = $this->stringifyIfStringable($val, ''.$col, $table_cols);
60✔
2272

2273
                if(is_string($processed_val) || is_numeric($processed_val)) {
60✔
2274

2275
                    $select->where(" {$col} = :{$col} ", [$col=>$val]);
60✔
2276

2277
                } elseif(is_null($processed_val) && $this->getPrimaryCol() !== $col) {
8✔
2278

2279
                    $select->where(" {$col} IS NULL ");
8✔
2280
                } // if(is_string($processed_val) || is_numeric($processed_val))
2281
            } // foreach ($data_2_insert as $col => $val)
2282

2283
            $matching_rows = $this->fetchRowsIntoArray($select);
60✔
2284

2285
            if(count($matching_rows) === 1) {
60✔
2286

2287
                /** @psalm-suppress MixedAssignment */
2288
                $data_2_insert = array_pop($matching_rows);
60✔
2289
            }
2290
        }
2291
    }
2292

2293
    /**
2294
     * {@inheritDoc}
2295
     */
2296
    #[\Override]
2297
    public function insert(array $data_2_insert = []): bool|array {
2298
        
2299
        $result = false;
68✔
2300

2301
        if ( $data_2_insert !== [] ) {
68✔
2302

2303
            $table_cols = $this->getTableColNames();
68✔
2304
            $has_autoinc_pkey_col=false;
68✔
2305

2306
            $this->processRowOfDataToInsert(
68✔
2307
                $data_2_insert, $table_cols, $has_autoinc_pkey_col
68✔
2308
            );
68✔
2309

2310
            // Do we still have anything left to save after removing items
2311
            // in the array that do not map to actual db table columns
2312
            /**
2313
             * @psalm-suppress RedundantCondition
2314
             * @psalm-suppress TypeDoesNotContainType
2315
             */
2316
            if( (is_countable($data_2_insert) ? count($data_2_insert) : 0) > 0 ) {
60✔
2317

2318
                //Insert statement
2319
                $insrt_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newInsert();
60✔
2320
                $insrt_qry_obj->into($this->getTableName())->cols($data_2_insert);
60✔
2321

2322
                $insrt_qry_sql = $insrt_qry_obj->__toString();
60✔
2323
                $insrt_qry_params = $insrt_qry_obj->getBindValues();
60✔
2324
                $this->logQuery($insrt_qry_sql, $insrt_qry_params, __METHOD__, '' . __LINE__);
60✔
2325

2326
                if( $this->db_connector->executeQuery($insrt_qry_sql, $insrt_qry_params) ) {
60✔
2327

2328
                    // insert was successful, we are now going to try to 
2329
                    // fetch the inserted record from the db to get and 
2330
                    // return the db representation of the data
2331
                    if($has_autoinc_pkey_col) {
60✔
2332

2333
                        /** @psalm-suppress MixedAssignment */
2334
                        $last_insert_sequence_name = 
×
2335
                            $insrt_qry_obj->getLastInsertIdName($this->getPrimaryCol());
×
2336

2337
                        $pk_val_4_new_record = 
×
2338
                            $this->getPDO()->lastInsertId(is_string($last_insert_sequence_name) ? $last_insert_sequence_name : null);
×
2339

2340
                        // Add retrieved primary key value 
2341
                        // or null (if primary key value is empty) 
2342
                        // to the data to be returned.
2343
                        $data_2_insert[$this->primary_col] = 
×
2344
                            empty($pk_val_4_new_record) ? null : $pk_val_4_new_record;
×
2345

2346
                        $this->updateInsertDataArrayWithTheNewlyInsertedRecordFromDB(
×
2347
                            $data_2_insert, $table_cols
×
2348
                        );
×
2349

2350
                    } else {
2351

2352
                        $this->updateInsertDataArrayWithTheNewlyInsertedRecordFromDB(
60✔
2353
                            $data_2_insert, $table_cols
60✔
2354
                        );
60✔
2355

2356
                    } // if($has_autoinc_pkey_col)
2357

2358
                    //insert was successful
2359
                    $result = $data_2_insert;
60✔
2360

2361
                } // if( $this->db_connector->executeQuery($insrt_qry_sql, $insrt_qry_params) )
2362
            } // if(count($data_2_insert) > 0 ) 
2363
        } // if ( $data_2_insert !== [] )
2364
        
2365
        return $result;
60✔
2366
    }
2367

2368
    /**
2369
     * {@inheritDoc}
2370
     */
2371
    #[\Override]
2372
    public function insertMany(array $rows_of_data_2_insert = []): bool {
2373

2374
        $result = false;
36✔
2375

2376
        if ($rows_of_data_2_insert !== []) {
36✔
2377

2378
            $table_cols = $this->getTableColNames();
36✔
2379

2380
            foreach (array_keys($rows_of_data_2_insert) as $key) {
36✔
2381

2382
                if( !is_array($rows_of_data_2_insert[$key]) ) {
36✔
2383

2384
                    $item_type = gettype($rows_of_data_2_insert[$key]);
8✔
2385

2386
                    $msg = "ERROR: " . static::class . '::' . __FUNCTION__ . '(...)' 
8✔
2387
                         . " expects you to supply an array of arrays."
8✔
2388
                         . " One of the items in the array supplied is not an array."
8✔
2389
                         . PHP_EOL . " Item below of type `{$item_type}` is not an array: "
8✔
2390
                         . PHP_EOL . var_export($rows_of_data_2_insert[$key], true) 
8✔
2391
                         . PHP_EOL . PHP_EOL . "Data supplied to "
8✔
2392
                         . static::class . '::' . __FUNCTION__ . '(...).' 
8✔
2393
                         . " for insertion into the db table `{$this->getTableName()}`:"
8✔
2394
                         . PHP_EOL . var_export($rows_of_data_2_insert, true) . PHP_EOL
8✔
2395
                         . PHP_EOL;
8✔
2396

2397
                    throw new \GDAO\ModelInvalidInsertValueSuppliedException($msg);
8✔
2398
                }
2399

2400
                $this->processRowOfDataToInsert($rows_of_data_2_insert[$key], $table_cols);
28✔
2401

2402
                /** 
2403
                 * @psalm-suppress TypeDoesNotContainType
2404
                 * @psalm-suppress RedundantCondition
2405
                 */
2406
                if((is_countable($rows_of_data_2_insert[$key]) ? count($rows_of_data_2_insert[$key]) : 0) === 0) {
20✔
2407

2408
                    // all the keys in the curent row of data aren't valid
2409
                    // db table columns, remove the row of data from the 
2410
                    // data to be inserted into the DB.
2411
                    unset($rows_of_data_2_insert[$key]);
8✔
2412

2413
                } // if(count($rows_of_data_2_insert[$key]) === 0)
2414

2415
            } // foreach ($rows_of_data_2_insert as $key=>$row_2_insert)
2416

2417
            // do we still have any data left to insert after all the filtration above?
2418
            if($rows_of_data_2_insert !== []) {
20✔
2419

2420
                //Insert statement
2421
                $insrt_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newInsert();
20✔
2422

2423
                //Batch all the data into one insert query.
2424
                $insrt_qry_obj->into($this->getTableName())->addRows($rows_of_data_2_insert);           
20✔
2425
                $insrt_qry_sql = $insrt_qry_obj->__toString();
20✔
2426
                $insrt_qry_params = $insrt_qry_obj->getBindValues();
20✔
2427

2428
                $this->logQuery($insrt_qry_sql, $insrt_qry_params, __METHOD__, '' . __LINE__);
20✔
2429
                $result = (bool) $this->db_connector->executeQuery($insrt_qry_sql, $insrt_qry_params);
20✔
2430

2431
            } // if(count($rows_of_data_2_insert) > 0)
2432
        } // if ($rows_of_data_2_insert !== [])
2433

2434
        return $result;
20✔
2435
    }
2436
    
2437
    protected function throwExceptionForInvalidUpdateQueryArg(mixed $val, array $cols_n_vals): never {
2438

2439
        $msg = "ERROR: the value "
16✔
2440
             . PHP_EOL . var_export($val, true) . PHP_EOL
16✔
2441
             . " you are trying to use to bulid the where clause for updating the table `{$this->getTableName()}`"
16✔
2442
             . " is not acceptable ('".  gettype($val) . "'"
16✔
2443
             . " supplied). Boolean, NULL, numeric or string value expected."
16✔
2444
             . PHP_EOL
16✔
2445
             . "Data supplied to "
16✔
2446
             . static::class . '::' . __FUNCTION__ . '(...).' 
16✔
2447
             . " for buiding the where clause for the update:"
16✔
2448
             . PHP_EOL . var_export($cols_n_vals, true) . PHP_EOL
16✔
2449
             . PHP_EOL;
16✔
2450

2451
        throw new \LeanOrm\Exceptions\InvalidArgumentException($msg);
16✔
2452
    }
2453
    
2454
    /**
2455
     * {@inheritDoc}
2456
     * @psalm-suppress RedundantCondition
2457
     */
2458
    #[\Override]
2459
    public function updateMatchingDbTableRows(
2460
        array $col_names_n_values_2_save = [],
2461
        array $col_names_n_values_2_match = []
2462
    ): static {
2463
        $num_initial_match_items = count($col_names_n_values_2_match);
52✔
2464

2465
        if ($col_names_n_values_2_save !== []) {
52✔
2466

2467
            $table_cols = $this->getTableColNames();
52✔
2468
            $pkey_col_name = $this->getPrimaryCol();
52✔
2469
            $this->addTimestampToData(
52✔
2470
                $col_names_n_values_2_save, $this->updated_timestamp_column_name, $table_cols
52✔
2471
            );
52✔
2472

2473
            if(array_key_exists($pkey_col_name, $col_names_n_values_2_save)) {
52✔
2474

2475
                //don't update the primary key
2476
                unset($col_names_n_values_2_save[$pkey_col_name]);
28✔
2477
            }
2478

2479
            // remove non-existent table columns from the data
2480
            // and check that existent table columns have values of  
2481
            // the right data type: ie. Boolean, NULL, Number or String.
2482
            // Convert objects with a __toString to their string value.
2483
            /** @psalm-suppress MixedAssignment */
2484
            foreach ($col_names_n_values_2_save as $key => $val) {
52✔
2485

2486
                /** @psalm-suppress MixedAssignment */
2487
                $col_names_n_values_2_save[$key] = 
52✔
2488
                    $this->stringifyIfStringable($val, ''.$key, $table_cols);
52✔
2489

2490
                if ( !in_array($key, $table_cols) ) {
52✔
2491

2492
                    unset($col_names_n_values_2_save[$key]);
8✔
2493

2494
                } else if( !$this->isAcceptableUpdateValue($val) ) {
52✔
2495

2496
                    $msg = "ERROR: the value "
8✔
2497
                         . PHP_EOL . var_export($val, true) . PHP_EOL
8✔
2498
                         . " you are trying to update `{$this->getTableName()}`.`{$key}`."
8✔
2499
                         . "{$key} with is not acceptable ('".  gettype($val) . "'"
8✔
2500
                         . " supplied). Boolean, NULL, numeric or string value expected."
8✔
2501
                         . PHP_EOL
8✔
2502
                         . "Data supplied to "
8✔
2503
                         . static::class . '::' . __FUNCTION__ . '(...).' 
8✔
2504
                         . " for update:"
8✔
2505
                         . PHP_EOL . var_export($col_names_n_values_2_save, true) . PHP_EOL
8✔
2506
                         . PHP_EOL;
8✔
2507

2508
                    throw new \GDAO\ModelInvalidUpdateValueSuppliedException($msg);
8✔
2509
                } // if ( !in_array($key, $table_cols) )
2510
            } // foreach ($col_names_n_vals_2_save as $key => $val)
2511

2512
            // After filtering out non-table columns, if we have any table
2513
            // columns data left, we can do the update
2514
            if($col_names_n_values_2_save !== []) {
44✔
2515

2516
                //update statement
2517
                $update_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newUpdate();
44✔
2518
                $update_qry_obj->table($this->getTableName());
44✔
2519
                $update_qry_obj->cols($col_names_n_values_2_save);
44✔
2520

2521
                /** @psalm-suppress MixedAssignment */
2522
                foreach ($col_names_n_values_2_match as $colname => $colval) {
44✔
2523

2524
                    if(!in_array($colname, $table_cols)) {
44✔
2525

2526
                        //non-existent table column
2527
                        unset($col_names_n_values_2_match[$colname]);
8✔
2528
                        continue;
8✔
2529
                    }
2530

2531
                    if (is_array($colval)) {
44✔
2532

2533
                        if($colval !== []) {
16✔
2534

2535
                            /** @psalm-suppress MixedAssignment */
2536
                            foreach ($colval as $key=>$val) {
16✔
2537

2538
                                if(!$this->isAcceptableUpdateQueryValue($val)) {
16✔
2539

2540
                                    $this->throwExceptionForInvalidUpdateQueryArg(
8✔
2541
                                            $val, $col_names_n_values_2_match
8✔
2542
                                        );
8✔
2543
                                }
2544

2545
                                /** @psalm-suppress MixedAssignment */
2546
                                $colval[$key] = $this->stringifyIfStringable($val);
16✔
2547
                            }
2548

2549
                            $this->addWhereInAndOrIsNullToQuery(''.$colname, $colval, $update_qry_obj);
8✔
2550

2551
                        } // if($colval !== []) 
2552

2553
                    } else {
2554

2555
                        if(!$this->isAcceptableUpdateQueryValue($colval)) {
44✔
2556

2557
                            $this->throwExceptionForInvalidUpdateQueryArg(
8✔
2558
                                    $colval, $col_names_n_values_2_match
8✔
2559
                                );
8✔
2560
                        }
2561

2562
                        if(is_null($colval)) {
44✔
2563

2564
                            $update_qry_obj->where(
8✔
2565
                                " {$colname} IS NULL "
8✔
2566
                            );
8✔
2567

2568
                        } else {
2569

2570
                            $update_qry_obj->where(
44✔
2571
                                " {$colname} = :{$colname}_for_where ",  // add the _for_where suffix to deconflict where bind value, 
44✔
2572
                                                                         // from the set bind value when a column in the where clause
2573
                                                                         // is also being set and the value we are setting it to is 
2574
                                                                         // different from the value we are using for the same column 
2575
                                                                         // in the where clause
2576
                                ["{$colname}_for_where" => $this->stringifyIfStringable($colval)] 
44✔
2577
                            );
44✔
2578
                        }
2579

2580
                    } // if (is_array($colval))
2581
                } // foreach ($col_names_n_vals_2_match as $colname => $colval)
2582

2583
                // If after filtering out non existing cols in $col_names_n_vals_2_match
2584
                // if there is still data left in $col_names_n_vals_2_match, then
2585
                // finish building the update query and do the update
2586
                if( 
2587
                    $col_names_n_values_2_match !== [] // there are valid db table cols in here
28✔
2588
                    || 
2589
                    (
2590
                        $num_initial_match_items === 0
28✔
2591
                        && $col_names_n_values_2_match === [] // empty match array passed, we are updating all rows
28✔
2592
                    )
2593
                ) {
2594
                    $updt_qry = $update_qry_obj->__toString();
28✔
2595
                    $updt_qry_params = $update_qry_obj->getBindValues();
28✔
2596
                    $this->logQuery($updt_qry, $updt_qry_params, __METHOD__, '' . __LINE__);
28✔
2597

2598
                    $this->db_connector->executeQuery($updt_qry, $updt_qry_params, true);
28✔
2599
                }
2600

2601
            } // if($col_names_n_vals_2_save !== [])
2602
        } // if ($col_names_n_vals_2_save !== [])
2603

2604
        return $this;
28✔
2605
    }
2606

2607
    /**
2608
     * {@inheritDoc}
2609
     * @psalm-suppress UnusedVariable
2610
     */
2611
    #[\Override]
2612
    public function updateSpecifiedRecord(\GDAO\Model\RecordInterface $record): static {
2613
        
2614
        if( $record instanceof \LeanOrm\Model\ReadOnlyRecord ) {
44✔
2615

2616
            $msg = "ERROR: Can't save a ReadOnlyRecord to the database in " 
8✔
2617
                 . static::class . '::' . __FUNCTION__ . '(...).'
8✔
2618
                 . PHP_EOL .'Unupdated record' . var_export($record, true) . PHP_EOL;
8✔
2619
            throw new \LeanOrm\Exceptions\CantSaveReadOnlyRecordException($msg);
8✔
2620
        }
2621
        
2622
        if( $record->getModel()->getTableName() !== $this->getTableName() ) {
36✔
2623
            
2624
            $msg = "ERROR: Can't update a record (an instance of `%s`) belonging to the database table `%s` " 
8✔
2625
                . "using a Model instance of `%s` belonging to the database table `%s` in " 
8✔
2626
                 . static::class . '::' . __FUNCTION__ . '(...).'
8✔
2627
                 . PHP_EOL .'Unupdated record: ' . PHP_EOL . var_export($record, true) . PHP_EOL; 
8✔
2628
            throw new \GDAO\ModelInvalidUpdateValueSuppliedException(
8✔
2629
                sprintf(
8✔
2630
                    $msg, $record::class, $record->getModel()->getTableName(),
8✔
2631
                    static::class, $this->getTableName()
8✔
2632
                )
8✔
2633
            );
8✔
2634
        }
2635

2636
        /** @psalm-suppress MixedAssignment */
2637
        $pri_key_val = $record->getPrimaryVal();
28✔
2638
        
2639
        /** @psalm-suppress MixedOperand */
2640
        if( 
2641
            count($record) > 0  // There is data in the record
28✔
2642
            && !$record->isNew() // This is not a new record that wasn't fetched from the DB
28✔
2643
            && !Utils::isEmptyString(''.$pri_key_val) // Record has a primary key value
28✔
2644
            && $record->isChanged() // The data in the record has changed from the state it was when initially fetched from DB
28✔
2645
        ) {
2646
            $cols_n_vals_2_match = [$record->getPrimaryCol()=>$pri_key_val];
28✔
2647

2648
            if($this->getUpdatedTimestampColumnName() !== null) {
28✔
2649

2650
                // Record has changed value(s) & must definitely be updated.
2651
                // Set the value of the $this->getUpdatedTimestampColumnName()
2652
                // field to an empty string, force updateMatchingDbTableRows
2653
                // to add a new updated timestamp value during the update.
2654
                $record->{$this->getUpdatedTimestampColumnName()} = '';
8✔
2655
            }
2656

2657
            $data_2_save = $record->getData();
28✔
2658
            $this->updateMatchingDbTableRows(
28✔
2659
                $data_2_save, 
28✔
2660
                $cols_n_vals_2_match
28✔
2661
            );
28✔
2662

2663
            // update the record with the new updated copy from the DB
2664
            // which will contain the new updated timestamp value.
2665
            $record = $this->fetchOneRecord(
28✔
2666
                        $this->getSelect()
28✔
2667
                             ->where(
28✔
2668
                                    " {$record->getPrimaryCol()} = :{$record->getPrimaryCol()} ", 
28✔
2669
                                    [$record->getPrimaryCol() => $record->getPrimaryVal()]
28✔
2670
                                )
28✔
2671
                    );
28✔
2672
        } // if( count($record) > 0 && !$record->isNew()........
2673

2674
        return $this;
28✔
2675
    }
2676

2677
    /**
2678
     * @psalm-suppress RedundantConditionGivenDocblockType
2679
     */
2680
    protected function addWhereInAndOrIsNullToQuery(
2681
        string $colname, array &$colvals, \Aura\SqlQuery\Common\WhereInterface $qry_obj
2682
    ): void {
2683
        
2684
        if($colvals !== []) { // make sure it's a non-empty array
128✔
2685
            
2686
            // if there are one or more null values in the array,
2687
            // we need to unset them and add an
2688
            //      OR $colname IS NULL 
2689
            // clause to the query
2690
            $unique_colvals = array_unique($colvals);
128✔
2691
            $keys_for_null_vals = array_keys($unique_colvals, null, true);
128✔
2692

2693
            foreach($keys_for_null_vals as $key_for_null_val) {
128✔
2694

2695
                // remove the null vals from $colval
2696
                unset($unique_colvals[$key_for_null_val]);
8✔
2697
            }
2698

2699
            if(
2700
                $keys_for_null_vals !== [] && $unique_colvals !== []
128✔
2701
            ) {
2702
                // Some values in the array are null and some are non-null
2703
                // Generate WHERE COL IN () OR COL IS NULL
2704
                $qry_obj->where(
8✔
2705
                    " {$colname} IN (:bar) ",
8✔
2706
                    [ 'bar' => $unique_colvals ]
8✔
2707
                )->orWhere(" {$colname} IS NULL ");
8✔
2708

2709
            } elseif (
2710
                $keys_for_null_vals !== []
128✔
2711
                && $unique_colvals === []
128✔
2712
            ) {
2713
                // All values in the array are null
2714
                // Only generate WHERE COL IS NULL
2715
                $qry_obj->where(" {$colname} IS NULL ");
8✔
2716

2717
            } else { // ($keys_for_null_vals === [] && $unique_colvals !== []) // no nulls found
2718
                
2719
                ////////////////////////////////////////////////////////////////
2720
                // NOTE: ($keys_for_null_vals === [] && $unique_colvals === [])  
2721
                // is impossible because we started with if($colvals !== [])
2722
                ////////////////////////////////////////////////////////////////
2723

2724
                // All values in the array are non-null
2725
                // Only generate WHERE COL IN ()
2726
                $qry_obj->where(       
128✔
2727
                    " {$colname} IN (:bar) ",
128✔
2728
                    [ 'bar' => $unique_colvals ]
128✔
2729
                );
128✔
2730
            }
2731
        }
2732
    }
2733
    
2734
    /**
2735
     * @return array{
2736
     *              database_server_info: mixed, 
2737
     *              driver_name: mixed, 
2738
     *              pdo_client_version: mixed, 
2739
     *              database_server_version: mixed, 
2740
     *              connection_status: mixed, 
2741
     *              connection_is_persistent: mixed
2742
     *          }
2743
     * 
2744
     * @psalm-suppress PossiblyUnusedMethod
2745
     */
2746
    public function getCurrentConnectionInfo(): array {
2747

2748
        $pdo_obj = $this->getPDO();
8✔
2749
        $attributes = [
8✔
2750
            'database_server_info' => 'SERVER_INFO',
8✔
2751
            'driver_name' => 'DRIVER_NAME',
8✔
2752
            'pdo_client_version' => 'CLIENT_VERSION',
8✔
2753
            'database_server_version' => 'SERVER_VERSION',
8✔
2754
            'connection_status' => 'CONNECTION_STATUS',
8✔
2755
            'connection_is_persistent' => 'PERSISTENT',
8✔
2756
        ];
8✔
2757

2758
        foreach ($attributes as $key => $value) {
8✔
2759
            
2760
            try {
2761
                /**
2762
                 * @psalm-suppress MixedAssignment
2763
                 * @psalm-suppress MixedArgument
2764
                 */
2765
                $attributes[ $key ] = $pdo_obj->getAttribute(constant(\PDO::class .'::ATTR_' . $value));
8✔
2766
                
2767
            } catch (\PDOException) {
8✔
2768
                
2769
                $attributes[ $key ] = 'Unsupported attribute for the current PDO driver';
8✔
2770
                continue;
8✔
2771
            }
2772

2773
            if( $value === 'PERSISTENT' ) {
8✔
2774

2775
                $attributes[ $key ] = var_export($attributes[ $key ], true);
8✔
2776
            }
2777
        }
2778

2779
        return $attributes;
8✔
2780
    }
2781

2782
    /**
2783
     * @psalm-suppress PossiblyUnusedMethod
2784
     */
2785
    public function clearQueryLog(): static {
2786

2787
        $this->query_log = [];
48✔
2788
        
2789
        return $this;
48✔
2790
    }
2791

2792
    /**
2793
     * @return mixed[]
2794
     * @psalm-suppress PossiblyUnusedMethod
2795
     */
2796
    public function getQueryLog(): array {
2797

2798
        return $this->query_log;
16✔
2799
    }
2800

2801
    /**
2802
     * To get the log for all existing instances of this class & its subclasses,
2803
     * call this method with no args or with null.
2804
     * 
2805
     * To get the log for instances of a specific class (this class or a
2806
     * particular sub-class of this class), you must call this method with 
2807
     * an instance of the class whose log you want to get.
2808
     * 
2809
     * @return mixed[]
2810
     * @psalm-suppress PossiblyUnusedMethod
2811
     */
2812
    public static function getQueryLogForAllInstances(?\GDAO\Model $obj=null): array {
2813
        
2814
        $key = ($obj instanceof \GDAO\Model) ? static::createLoggingKey($obj) : '';
52✔
2815
        
2816
        return ($obj instanceof \GDAO\Model)
52✔
2817
                ?
52✔
2818
                (
52✔
2819
                    array_key_exists($key, static::$all_instances_query_log) 
8✔
2820
                    ? static::$all_instances_query_log[$key] : [] 
8✔
2821
                )
52✔
2822
                : static::$all_instances_query_log 
52✔
2823
                ;
52✔
2824
    }
2825
    
2826
    /**
2827
     * @psalm-suppress PossiblyUnusedMethod
2828
     */
2829
    public static function clearQueryLogForAllInstances(): void {
2830
        
2831
        static::$all_instances_query_log = [];
72✔
2832
    }
2833

2834
    protected static function createLoggingKey(\GDAO\Model $obj): string {
2835
        
2836
        return "{$obj->getDsn()}::" . $obj::class;
76✔
2837
    }
2838
    
2839
    protected function logQuery(string $sql, array $bind_params, string $calling_method='', string $calling_line=''): static {
2840

2841
        if( $this->can_log_queries ) {
352✔
2842

2843
            $key = static::createLoggingKey($this);
76✔
2844
            
2845
            if(!array_key_exists($key, static::$all_instances_query_log)) {
76✔
2846

2847
                static::$all_instances_query_log[$key] = [];
76✔
2848
            }
2849

2850
            $log_record = [
76✔
2851
                'sql' => $sql,
76✔
2852
                'bind_params' => $bind_params,
76✔
2853
                'date_executed' => date('Y-m-d H:i:s'),
76✔
2854
                'class_method' => $calling_method,
76✔
2855
                'line_of_execution' => $calling_line,
76✔
2856
            ];
76✔
2857
            
2858
            /** @psalm-suppress InvalidPropertyAssignmentValue */
2859
            $this->query_log[] = $log_record;
76✔
2860
            static::$all_instances_query_log[$key][] = $log_record;
76✔
2861

2862
            if($this->logger instanceof \Psr\Log\LoggerInterface) {
76✔
2863

2864
                $this->logger->info(
8✔
2865
                    PHP_EOL . PHP_EOL .
8✔
2866
                    'SQL:' . PHP_EOL . "{$sql}" . PHP_EOL . PHP_EOL . PHP_EOL .
8✔
2867
                    'BIND PARAMS:' . PHP_EOL . var_export($bind_params, true) .
8✔
2868
                    PHP_EOL . "Calling Method: `{$calling_method}`" . PHP_EOL .
8✔
2869
                    "Line of Execution: `{$calling_line}`" . PHP_EOL .
8✔
2870
                     PHP_EOL . PHP_EOL . PHP_EOL
8✔
2871
                );
8✔
2872
            }                    
2873
        }
2874

2875
        return $this;
352✔
2876
    }
2877

2878
    /**
2879
     * @psalm-suppress PossiblyUnusedMethod
2880
     */
2881
    public function hasAnyDataInTable(): bool {
2882

2883
        return $this->fetchOneRecord() instanceof \GDAO\Model\RecordInterface;
8✔
2884
    }
2885

2886
    ////////////////////////////////
2887
    // Metadata retreiving methods.
2888
    ////////////////////////////////
2889

2890
    /**
2891
     * @psalm-suppress PossiblyUnusedMethod
2892
     */
2893
    public function getFieldDefaultValue(string $fieldName): mixed {
2894

2895
        $fieldDefaultValue= null;
16✔
2896
        $fieldMetaData = $this->getFieldMetadata($fieldName);
16✔
2897

2898
        if($fieldMetaData !== [] && \array_key_exists('default', $fieldMetaData)) {
16✔
2899

2900
            /** @psalm-suppress MixedAssignment */
2901
            $fieldDefaultValue = $fieldMetaData['default'];
16✔
2902
        }
2903

2904
        return $fieldDefaultValue;
16✔
2905
    }
2906

2907
    /**
2908
     * @psalm-suppress PossiblyUnusedMethod
2909
     */
2910
    public function getFieldLength(string $fieldName): ?int {
2911

2912
        $fieldLength= null;
16✔
2913
        $fieldMetaData = $this->getFieldMetadata($fieldName);
16✔
2914

2915
        if($fieldMetaData !== [] && \array_key_exists('size', $fieldMetaData)) {
16✔
2916

2917
            /** @psalm-suppress MixedAssignment */
2918
            $fieldLength = $fieldMetaData['size'] ?? $fieldLength;
16✔
2919
        }
2920

2921
        /** @psalm-suppress MixedReturnStatement */
2922
        return $fieldLength;
16✔
2923
    }
2924
    
2925
    public function getFieldMetadata(string $fieldName): array {
2926

2927
        $fieldMetaData = [];
144✔
2928

2929
        if(
2930
            $this->isAnActualTableCol($fieldName)
144✔
2931
            && \array_key_exists($fieldName, $this->getTableCols())
144✔
2932
        ) {
2933
            /** @psalm-suppress MixedAssignment */
2934
            $fieldMetaData = $this->getTableCols()[$fieldName]; 
144✔
2935
        }
2936

2937
        /** @psalm-suppress MixedReturnStatement */
2938
        return $fieldMetaData;
144✔
2939
    }
2940

2941
    public function isAnActualTableCol(string $columnName): bool {
2942

2943
        return \in_array($columnName, $this->getTableColNames());
152✔
2944
    }
2945

2946
    public function isAutoIncrementingField(string $fieldName): bool {
2947

2948
        $isAutoIncing= false;
80✔
2949
        $fieldMetaData = $this->getFieldMetadata($fieldName);
80✔
2950

2951
        if($fieldMetaData !== [] && \array_key_exists('autoinc', $fieldMetaData)) {
80✔
2952

2953
            $isAutoIncing = (bool)$fieldMetaData['autoinc'];
80✔
2954
        }
2955

2956
        return $isAutoIncing;
80✔
2957
    }
2958

2959
    /**
2960
     * @psalm-suppress PossiblyUnusedMethod
2961
     */
2962
    public function isPrimaryKeyField(string $fieldName): bool {
2963

2964
        return $this->getPrimaryCol() === $fieldName;
16✔
2965
    }
2966

2967
    /**
2968
     * @psalm-suppress PossiblyUnusedMethod
2969
     */
2970
    public function isRequiredField(string $fieldName): bool {
2971

2972
        $isRequired= false;
16✔
2973
        $fieldMetaData = $this->getFieldMetadata($fieldName);
16✔
2974

2975
        if(
2976
            $fieldMetaData !== []
16✔
2977
            && \array_key_exists('notnull', $fieldMetaData)
16✔
2978
        ) {
2979
            $isRequired = (bool)$fieldMetaData['notnull'];
16✔
2980
        }
2981

2982
        return $isRequired;
16✔
2983
    }
2984

2985
    ///////////////////////////////////////
2986
    // Methods for defining relationships
2987
    ///////////////////////////////////////
2988
    
2989
    /**
2990
     * @psalm-suppress PossiblyUnusedMethod
2991
     */
2992
    public function hasOne(
2993
        string $relation_name,  // name of the relation, via which the related data
2994
                                // will be accessed as a property with the same name 
2995
                                // on record objects for this model class or array key 
2996
                                // for the related data when data is fetched into arrays 
2997
                                // via this model
2998
        
2999
        string $relationship_col_in_my_table,
3000
        
3001
        string $relationship_col_in_foreign_table,
3002
        
3003
        string $foreign_table_name='',   // If empty, the value set in the $table_name property
3004
                                         // of the model class specified in $foreign_models_class_name
3005
                                         // will be used if $foreign_models_class_name !== '' 
3006
                                         // and the value of the $table_name property is not ''
3007
        
3008
        string $primary_key_col_in_foreign_table='',   // If empty, the value set in the $primary_col property
3009
                                                       // of the model class specified in $foreign_models_class_name
3010
                                                       // will be used if $foreign_models_class_name !== '' 
3011
                                                       // and the value of the $primary_col property is not ''
3012
        
3013
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
3014
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
3015
        
3016
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
3017
                                                       // or the value of the $record_class_name property
3018
                                                       // in the class specfied in $foreign_models_class_name
3019
        
3020
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
3021
                                                            // or the value of the $collection_class_name property
3022
                                                            // in the class specfied in $foreign_models_class_name
3023
        
3024
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
3025
    ): static {
3026
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
312✔
3027
        $this->setRelationshipDefinitionDefaultsIfNeeded (
304✔
3028
            $foreign_models_class_name,
304✔
3029
            $foreign_table_name,
304✔
3030
            $primary_key_col_in_foreign_table,
304✔
3031
            $foreign_models_record_class_name,
304✔
3032
            $foreign_models_collection_class_name
304✔
3033
        );
304✔
3034
        
3035
        if($foreign_models_collection_class_name !== '') {
264✔
3036
            
3037
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
264✔
3038
        }
3039
        
3040
        if($foreign_models_record_class_name !== '') {
256✔
3041
            
3042
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
256✔
3043
        }
3044
        
3045
        $this->validateTableName($foreign_table_name);
248✔
3046
        
3047
        $this->validateThatTableHasColumn($this->getTableName(), $relationship_col_in_my_table);
240✔
3048
        $this->validateThatTableHasColumn($foreign_table_name, $relationship_col_in_foreign_table);
232✔
3049
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
224✔
3050
        
3051
        $this->relations[$relation_name] = [];
216✔
3052
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_ONE;
216✔
3053
        $this->relations[$relation_name]['foreign_key_col_in_my_table'] = $relationship_col_in_my_table;
216✔
3054
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
216✔
3055
        $this->relations[$relation_name]['foreign_key_col_in_foreign_table'] = $relationship_col_in_foreign_table;
216✔
3056
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
216✔
3057

3058
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
216✔
3059
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
216✔
3060
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
216✔
3061

3062
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
216✔
3063

3064
        return $this;
216✔
3065
    }
3066
    
3067
    /**
3068
     * @psalm-suppress PossiblyUnusedMethod
3069
     */
3070
    public function belongsTo(
3071
        string $relation_name,  // name of the relation, via which the related data
3072
                                // will be accessed as a property with the same name 
3073
                                // on record objects for this model class or array key 
3074
                                // for the related data when data is fetched into arrays 
3075
                                // via this model
3076
        
3077
        string $relationship_col_in_my_table,
3078
            
3079
        string $relationship_col_in_foreign_table,
3080
        
3081
        string $foreign_table_name = '', // If empty, the value set in the $table_name property
3082
                                         // of the model class specified in $foreign_models_class_name
3083
                                         // will be used if $foreign_models_class_name !== '' 
3084
                                         // and the value of the $table_name property is not ''
3085
        
3086
        string $primary_key_col_in_foreign_table = '', // If empty, the value set in the $primary_col property
3087
                                                       // of the model class specified in $foreign_models_class_name
3088
                                                       // will be used if $foreign_models_class_name !== '' 
3089
                                                       // and the value of the $primary_col property is not ''
3090
        
3091
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
3092
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
3093
        
3094
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
3095
                                                       // or the value of the $record_class_name property
3096
                                                       // in the class specfied in $foreign_models_class_name
3097
        
3098
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
3099
                                                            // or the value of the $collection_class_name property
3100
                                                            // in the class specfied in $foreign_models_class_name
3101
        
3102
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
3103
    ): static {
3104
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
376✔
3105
        $this->setRelationshipDefinitionDefaultsIfNeeded (
368✔
3106
            $foreign_models_class_name,
368✔
3107
            $foreign_table_name,
368✔
3108
            $primary_key_col_in_foreign_table,
368✔
3109
            $foreign_models_record_class_name,
368✔
3110
            $foreign_models_collection_class_name
368✔
3111
        );
368✔
3112
        
3113
        if($foreign_models_collection_class_name !== '') {
328✔
3114
        
3115
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
328✔
3116
        }
3117
        
3118
        if($foreign_models_record_class_name !== '') {
320✔
3119
            
3120
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
320✔
3121
        }
3122
        
3123
        $this->validateTableName($foreign_table_name);
312✔
3124
        
3125
        $this->validateThatTableHasColumn($this->getTableName(), $relationship_col_in_my_table);
304✔
3126
        $this->validateThatTableHasColumn($foreign_table_name, $relationship_col_in_foreign_table);
296✔
3127
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
288✔
3128
        
3129
        $this->relations[$relation_name] = [];
280✔
3130
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_BELONGS_TO;
280✔
3131
        $this->relations[$relation_name]['foreign_key_col_in_my_table'] = $relationship_col_in_my_table;
280✔
3132
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
280✔
3133
        $this->relations[$relation_name]['foreign_key_col_in_foreign_table'] = $relationship_col_in_foreign_table;
280✔
3134
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
280✔
3135

3136
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
280✔
3137
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
280✔
3138
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
280✔
3139

3140
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
280✔
3141

3142
        return $this;
280✔
3143
    }
3144
    
3145
    /**
3146
     * @psalm-suppress PossiblyUnusedMethod
3147
     * 
3148
     */
3149
    public function hasMany(
3150
        string $relation_name,  // name of the relation, via which the related data
3151
                                // will be accessed as a property with the same name 
3152
                                // on record objects for this model class or array key 
3153
                                // for the related data when data is fetched into arrays 
3154
                                // via this model
3155
        
3156
        string $relationship_col_in_my_table,
3157
        
3158
        string $relationship_col_in_foreign_table,
3159
        
3160
        string $foreign_table_name='',   // If empty, the value set in the $table_name property
3161
                                         // of the model class specified in $foreign_models_class_name
3162
                                         // will be used if $foreign_models_class_name !== '' 
3163
                                         // and the value of the $table_name property is not ''
3164
        
3165
        string $primary_key_col_in_foreign_table='',   // If empty, the value set in the $primary_col property
3166
                                                       // of the model class specified in $foreign_models_class_name
3167
                                                       // will be used if $foreign_models_class_name !== '' 
3168
                                                       // and the value of the $primary_col property is not ''
3169
        
3170
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
3171
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
3172
        
3173
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
3174
                                                       // or the value of the $record_class_name property
3175
                                                       // in the class specfied in $foreign_models_class_name
3176
        
3177
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
3178
                                                            // or the value of the $collection_class_name property
3179
                                                            // in the class specfied in $foreign_models_class_name
3180
        
3181
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
3182
    ): static {
3183
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
1,484✔
3184
        $this->setRelationshipDefinitionDefaultsIfNeeded (
1,484✔
3185
            $foreign_models_class_name,
1,484✔
3186
            $foreign_table_name,
1,484✔
3187
            $primary_key_col_in_foreign_table,
1,484✔
3188
            $foreign_models_record_class_name,
1,484✔
3189
            $foreign_models_collection_class_name
1,484✔
3190
        );
1,484✔
3191
        
3192
        if($foreign_models_collection_class_name !== '') {
1,484✔
3193
            
3194
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
1,484✔
3195
        }
3196
            
3197
        if($foreign_models_record_class_name !== '') {
1,484✔
3198
            
3199
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
1,484✔
3200
        }
3201
            
3202
        
3203
        $this->validateTableName($foreign_table_name);
1,484✔
3204
        
3205
        $this->validateThatTableHasColumn($this->getTableName(), $relationship_col_in_my_table);
1,484✔
3206
        $this->validateThatTableHasColumn($foreign_table_name, $relationship_col_in_foreign_table);
1,484✔
3207
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
1,484✔
3208
        
3209
        $this->relations[$relation_name] = [];
1,484✔
3210
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_MANY;
1,484✔
3211
        $this->relations[$relation_name]['foreign_key_col_in_my_table'] = $relationship_col_in_my_table;
1,484✔
3212
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
1,484✔
3213
        $this->relations[$relation_name]['foreign_key_col_in_foreign_table'] = $relationship_col_in_foreign_table;
1,484✔
3214
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
1,484✔
3215

3216
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
1,484✔
3217
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
1,484✔
3218
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
1,484✔
3219

3220
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
1,484✔
3221

3222
        return $this;
1,484✔
3223
    }
3224

3225
    /**
3226
     * @psalm-suppress PossiblyUnusedMethod
3227
     */
3228
    public function hasManyThrough(
3229
        string $relation_name,  // name of the relation, via which the related data
3230
                                // will be accessed as a property with the same name 
3231
                                // on record objects for this model class or array key 
3232
                                // for the related data when data is fetched into arrays 
3233
                                // via this model
3234
        
3235
        string $col_in_my_table_linked_to_join_table,
3236
        string $join_table,
3237
        string $col_in_join_table_linked_to_my_table,
3238
        string $col_in_join_table_linked_to_foreign_table,
3239
        string $col_in_foreign_table_linked_to_join_table,
3240
        
3241
        string $foreign_table_name = '', // If empty, the value set in the $table_name property
3242
                                         // of the model class specified in $foreign_models_class_name
3243
                                         // will be used if $foreign_models_class_name !== '' 
3244
                                         // and the value of the $table_name property is not ''
3245
            
3246
        string $primary_key_col_in_foreign_table = '', // If empty, the value set in the $primary_col property
3247
                                                       // of the model class specified in $foreign_models_class_name
3248
                                                       // will be used if $foreign_models_class_name !== '' 
3249
                                                       // and the value of the $primary_col property is not ''
3250
            
3251
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
3252
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
3253
        
3254
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
3255
                                                       // or the value of the $record_class_name property
3256
                                                       // in the class specfied in $foreign_models_class_name
3257
        
3258
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
3259
                                                            // or the value of the $collection_class_name property
3260
                                                            // in the class specfied in $foreign_models_class_name
3261
        
3262
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
3263
    ): static {
3264
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
352✔
3265
        $this->setRelationshipDefinitionDefaultsIfNeeded (
344✔
3266
            $foreign_models_class_name,
344✔
3267
            $foreign_table_name,
344✔
3268
            $primary_key_col_in_foreign_table,
344✔
3269
            $foreign_models_record_class_name,
344✔
3270
            $foreign_models_collection_class_name
344✔
3271
        );
344✔
3272
        
3273
        if ($foreign_models_collection_class_name !== '') {
304✔
3274
            
3275
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
304✔
3276
        }
3277
        
3278
        if ($foreign_models_record_class_name !== '') {
296✔
3279
            
3280
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
296✔
3281
        }
3282
        
3283
        $this->validateTableName($foreign_table_name);
288✔
3284
        $this->validateTableName($join_table);
280✔
3285
        
3286
        $this->validateThatTableHasColumn($this->getTableName(), $col_in_my_table_linked_to_join_table);
272✔
3287
        $this->validateThatTableHasColumn($join_table, $col_in_join_table_linked_to_my_table);
264✔
3288
        $this->validateThatTableHasColumn($join_table, $col_in_join_table_linked_to_foreign_table);
256✔
3289
        $this->validateThatTableHasColumn($foreign_table_name, $col_in_foreign_table_linked_to_join_table);
248✔
3290
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
240✔
3291
        
3292
        $this->relations[$relation_name] = [];
232✔
3293
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_MANY_THROUGH;
232✔
3294
        $this->relations[$relation_name]['col_in_my_table_linked_to_join_table'] = $col_in_my_table_linked_to_join_table;
232✔
3295
        $this->relations[$relation_name]['join_table'] = $join_table;
232✔
3296
        $this->relations[$relation_name]['col_in_join_table_linked_to_my_table'] = $col_in_join_table_linked_to_my_table;
232✔
3297
        $this->relations[$relation_name]['col_in_join_table_linked_to_foreign_table'] = $col_in_join_table_linked_to_foreign_table;
232✔
3298
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
232✔
3299
        $this->relations[$relation_name]['col_in_foreign_table_linked_to_join_table'] = $col_in_foreign_table_linked_to_join_table;
232✔
3300
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
232✔
3301

3302
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
232✔
3303
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
232✔
3304
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
232✔
3305

3306
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
232✔
3307

3308
        return $this;
232✔
3309
    }
3310
    
3311
    /**
3312
     * @psalm-suppress MixedAssignment
3313
     */
3314
    protected function setRelationshipDefinitionDefaultsIfNeeded (
3315
        string &$foreign_models_class_name,
3316
        string &$foreign_table_name,
3317
        string &$primary_key_col_in_foreign_table,
3318
        string &$foreign_models_record_class_name,
3319
        string &$foreign_models_collection_class_name,
3320
    ): void {
3321
        
3322
        if($foreign_models_class_name !== '' && $foreign_models_class_name !== \LeanOrm\Model::class) {
1,484✔
3323
           
3324
            $this->validateRelatedModelClassName($foreign_models_class_name);
1,484✔
3325
            
3326
            /**
3327
             * @psalm-suppress ArgumentTypeCoercion
3328
             */
3329
            $ref_class = new \ReflectionClass($foreign_models_class_name);
1,484✔
3330
            
3331
            if($foreign_table_name === '') {
1,484✔
3332
                
3333
                // Try to set it using the default value of the table_name property 
3334
                // in the specified foreign model class $foreign_models_class_name
3335
                $reflected_foreign_table_name = 
64✔
3336
                        $ref_class->getProperty('table_name')->getDefaultValue();
64✔
3337

3338
                if($reflected_foreign_table_name === '' || $reflected_foreign_table_name === null) {
64✔
3339
                    
3340
                    $msg = "ERROR: '\$foreign_table_name' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3341
                         . $foreign_models_class_name . "'"
32✔
3342
                         . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3343

3344
                    // we can't use Reflection to figure out this table name
3345
                    throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3346
                }
3347
                
3348
                $foreign_table_name = $reflected_foreign_table_name;
32✔
3349
            }
3350
            
3351
            if($primary_key_col_in_foreign_table === '') {
1,484✔
3352

3353
                // Try to set it using the default value of the primary_col property 
3354
                // in the specified foreign model class $foreign_models_class_name
3355
                $reflected_foreign_primary_key_col = 
64✔
3356
                        $ref_class->getProperty('primary_col')->getDefaultValue();
64✔
3357

3358
                if($reflected_foreign_primary_key_col === '' || $reflected_foreign_primary_key_col === null) {
64✔
3359

3360
                    $msg = "ERROR: '\$primary_key_col_in_foreign_table' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3361
                         . $foreign_models_class_name . "'"
32✔
3362
                         . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3363

3364
                    // we can't use Reflection to figure out this primary key column name
3365
                    throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3366
                }
3367

3368
                // set it to the reflected value
3369
                $primary_key_col_in_foreign_table = $reflected_foreign_primary_key_col;
32✔
3370
            }
3371
            
3372
            $reflected_record_class_name = $ref_class->getProperty('record_class_name')->getDefaultValue();
1,484✔
3373
            
3374
            if(
3375
                $foreign_models_record_class_name === ''
1,484✔
3376
                && $reflected_record_class_name !== ''
1,484✔
3377
                && $reflected_record_class_name !== null
1,484✔
3378
            ) {
3379
                $foreign_models_record_class_name = $reflected_record_class_name;
32✔
3380
            }
3381
            
3382
            $reflected_collection_class_name = $ref_class->getProperty('collection_class_name')->getDefaultValue();
1,484✔
3383
            
3384
            if(
3385
                $foreign_models_collection_class_name === ''
1,484✔
3386
                && $reflected_collection_class_name !== ''
1,484✔
3387
                && $reflected_collection_class_name !== null
1,484✔
3388
            ) {
3389
                $foreign_models_collection_class_name = $reflected_collection_class_name;
32✔
3390
            }
3391
            
3392
        } else {
3393
            
3394
            $foreign_models_class_name = \LeanOrm\Model::class;
248✔
3395
            
3396
            if($foreign_table_name === '') {
248✔
3397
                
3398
                $msg = "ERROR: '\$foreign_table_name' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3399
                     . \LeanOrm\Model::class . "'"
32✔
3400
                     . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3401
                
3402
                // we can't use Reflection to figure out this table name
3403
                // because \LeanOrm\Model->table_name has a default value of ''
3404
                throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3405
            }
3406
            
3407
            // $foreign_table_name !== '' if we got this far
3408
            if($primary_key_col_in_foreign_table === '') {
216✔
3409

3410
                $msg = "ERROR: '\$primary_key_col_in_foreign_table' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3411
                     . \LeanOrm\Model::class . "'"
32✔
3412
                     . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3413

3414
                // We can't use Reflection to figure out this primary key col
3415
                // because \LeanOrm\Model->primary_col has a default value of ''
3416
                throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3417

3418
            }
3419
        } // if($foreign_models_class_name !== '' && $foreign_models_class_name !== \LeanOrm\Model::class)
3420
        
3421
        if($foreign_models_record_class_name === '') {
1,484✔
3422
            
3423
            $foreign_models_record_class_name = \LeanOrm\Model\Record::class;
184✔
3424
        }
3425
        
3426
        if($foreign_models_collection_class_name === '') {
1,484✔
3427
            
3428
            $foreign_models_collection_class_name = \LeanOrm\Model\Collection::class;
184✔
3429
        }
3430
    }
3431
    
3432
    protected function checkThatRelationNameIsNotAnActualColumnName(string $relationName): void {
3433

3434
        $tableCols = $this->getTableColNames();
1,484✔
3435

3436

3437
        $tableColsLowerCase = array_map(strtolower(...), $tableCols);
1,484✔
3438

3439
        if( in_array(strtolower($relationName), $tableColsLowerCase) ) {
1,484✔
3440

3441
            //Error trying to add a relation whose name collides with an actual
3442
            //name of a column in the db table associated with this model.
3443
            $msg = sprintf("ERROR: You cannont add a relationship with the name '%s' ", $relationName)
32✔
3444
                 . " to the Model (".static::class."). The database table "
32✔
3445
                 . sprintf(" '%s' associated with the ", $this->getTableName())
32✔
3446
                 . " model (".static::class.") already contains"
32✔
3447
                 . " a column with the same name."
32✔
3448
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
3449
                 . PHP_EOL;
32✔
3450

3451
            throw new \GDAO\Model\RecordRelationWithSameNameAsAnExistingDBTableColumnNameException($msg);
32✔
3452
        } // if( in_array(strtolower($relationName), $tableColsLowerCase) ) 
3453
    }
3454
    
3455
    /**
3456
     * @psalm-suppress PossiblyUnusedReturnValue
3457
     */
3458
    protected function validateTableName(string $table_name): bool {
3459
        
3460
        if(!$this->tableExistsInDB($table_name)) {
1,484✔
3461
            
3462
            //throw exception
3463
            $msg = "ERROR: The specified table `{$table_name}` does not exist in the DB."
40✔
3464
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
40✔
3465
                 . PHP_EOL;
40✔
3466
            throw new \LeanOrm\Exceptions\BadModelTableNameException($msg);
40✔
3467
        } // if(!$this->tableExistsInDB($table_name))
3468
        
3469
        return true;
1,484✔
3470
    }
3471
    
3472
    /**
3473
     * @psalm-suppress PossiblyUnusedReturnValue
3474
     */
3475
    protected function validateThatTableHasColumn(string $table_name, string $column_name): bool {
3476
        
3477
        if(!$this->columnExistsInDbTable($table_name, $column_name)) {
1,484✔
3478

3479
            //throw exception
3480
            $msg = "ERROR: The specified table `{$table_name}` in the DB"
112✔
3481
                 . " does not contain the specified column `{$column_name}`."
112✔
3482
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
112✔
3483
                 . PHP_EOL;
112✔
3484
            throw new \LeanOrm\Exceptions\BadModelColumnNameException($msg);
112✔
3485
        } // if(!$this->columnExistsInDbTable($table_name, $column_name))
3486
        
3487
        return true;
1,484✔
3488
    }
3489
}
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