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

rotexsoft / leanorm / 25641401969

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

push

github

rotexdegba
Pre 7.x release updates

1701 of 1764 relevant lines covered (96.43%)

192.88 hits per line

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

94.66
/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,504✔
48
    }
49

50
    /**
51
     *  An object for interacting with the db
52
     */
53
    protected \LeanOrm\DBConnector $db_connector;
54

55
    public function getDbConnector(): \LeanOrm\DBConnector { return $this->db_connector; }
56
    
57
    // Query Logging related properties
58
    protected bool $can_log_queries = false;
59

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

62
    /** @psalm-suppress PossiblyUnusedMethod */
63
    public function enableQueryLogging(): static {
64

65
        $this->can_log_queries = true;
92✔
66
        $this->db_connector->enableQueryLogging();
92✔
67

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

74
        $this->can_log_queries = false;
76✔
75
        $this->db_connector->disableQueryLogging();
76✔
76
        
77
        return $this;
76✔
78
    }
79

80
    protected ?LoggerInterface $logger = null;
81

82
    /** @psalm-suppress PossiblyUnusedMethod */
83
    public function setLogger(?LoggerInterface $logger): static {
84
        
85
        $this->logger = $logger;
184✔
86
        $this->getDbConnector()->setLogger($logger);
184✔
87
        
88
        return $this;
184✔
89
    }
90
    
91
    public function getLogger(): ?LoggerInterface { return $this->logger; }
92
    
93
    /**
94
     * {@inheritDoc}
95
     */
96
    public function __construct(
97
        string $dsn = '', 
98
        string $username = '', 
99
        #[\SensitiveParameter] string $passwd = '', 
100
        array $pdo_driver_opts = [],
101
        string $primary_col_name='',
102
        string $table_name=''
103
    ) {
104
        $pri_col_not_set_exception_msg = '';
1,504✔
105

106
        try {
107

108
            parent::__construct($dsn, $username, $passwd, $pdo_driver_opts, $primary_col_name, $table_name);
1,504✔
109

110
        } catch (\GDAO\ModelPrimaryColNameNotSetDuringConstructionException $e) {
48✔
111

112
            //$this->primary_col (primary key colun has not yet been set)
113
            //hold this exception for later if necessary
114
            $pri_col_not_set_exception_msg = $e->getMessage();
40✔
115
        }
116
        
117
        DBConnector::configure($dsn, null, $dsn);//use $dsn as connection name in 3rd parameter
1,504✔
118
        DBConnector::configure(DBConnector::CONFIG_KEY_USERNAME, $username, $dsn);//use $dsn as connection name in 3rd parameter
1,504✔
119
        DBConnector::configure(DBConnector::CONFIG_KEY_PASSWORD, $passwd, $dsn);//use $dsn as connection name in 3rd parameter
1,504✔
120

121
        if( $pdo_driver_opts !== [] ) {
1,504✔
122

123
            DBConnector::configure(DBConnector::CONFIG_KEY_DRIVER_OPTS, $pdo_driver_opts, $dsn);//use $dsn as connection name in 3rd parameter
8✔
124
        }
125

126
        $this->db_connector = DBConnector::create($dsn);//use $dsn as connection name
1,504✔
127
        
128
        /** @psalm-suppress MixedAssignment */
129
        $this->pdo_driver_name = $this->getPDO()->getAttribute(\PDO::ATTR_DRIVER_NAME);
1,504✔
130
        $this->pdoServerVersionCheck();
1,504✔
131

132
        ////////////////////////////////////////////////////////
133
        //Get and Set Table Schema Meta Data if Not Already Set
134
        ////////////////////////////////////////////////////////
135
        if ( $this->table_cols === [] ) {
1,504✔
136

137
            /** @var array $dsn_n_tname_to_schema_def_map */
138
            static $dsn_n_tname_to_schema_def_map;
1,504✔
139

140
            if( !$dsn_n_tname_to_schema_def_map ) {
1,504✔
141

142
                $dsn_n_tname_to_schema_def_map = [];
4✔
143
            }
144

145
            if( array_key_exists($dsn.$this->getTableName(), $dsn_n_tname_to_schema_def_map) ) {
1,504✔
146

147
                // use cached schema definition for the dsn and table name combo
148
                /** @psalm-suppress MixedAssignment */
149
                $schema_definitions = $dsn_n_tname_to_schema_def_map[$dsn.$this->getTableName()];
1,500✔
150

151
            } else {
152

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

156
                    $msg = "ERROR: Table name `{$this->getTableName()}` supplied to " 
8✔
157
                            . static::class . '::' . __FUNCTION__ . '(...)'
8✔
158
                            . ' does not exist as a table or view in the database';
8✔
159
                    throw new \LeanOrm\Exceptions\BadModelTableNameException($msg);
8✔
160
                }
161

162
                $this->table_cols = [];
36✔
163
                $schema_definitions = $this->fetchTableColsFromDB($this->getTableName());
36✔
164

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

168
            } // if( array_key_exists($dsn.$this->getTableName(), $dsn_n_tname_to_schema_def_map) )
169

170
            if( 
171
                $primary_col_name !== ''
1,504✔
172
                && !$this->columnExistsInDbTable($this->getTableName(), $primary_col_name) 
1,504✔
173
            ) {
174
                $msg = "ERROR: The Primary Key column name `{$primary_col_name}` supplied to " 
8✔
175
                        . static::class . '::' . __FUNCTION__ . '(...)'
8✔
176
                        . " does not exist as an actual column in the supplied table `{$this->getTableName()}`.";
8✔
177
                throw new \LeanOrm\Exceptions\BadModelPrimaryColumnNameException($msg);
8✔
178
            }
179

180
            /** @psalm-suppress MixedAssignment */
181
            foreach( $schema_definitions as $colname => $metadata_obj ) {
1,504✔
182

183
                /** @psalm-suppress MixedArrayOffset */
184
                $this->table_cols[$colname] = [];
1,504✔
185
                /** @psalm-suppress MixedArrayOffset */
186
                $this->table_cols[$colname]['name'] = $metadata_obj->name;
1,504✔
187
                /** @psalm-suppress MixedArrayOffset */
188
                $this->table_cols[$colname]['type'] = $metadata_obj->type;
1,504✔
189
                /** @psalm-suppress MixedArrayOffset */
190
                $this->table_cols[$colname]['size'] = $metadata_obj->size;
1,504✔
191
                /** @psalm-suppress MixedArrayOffset */
192
                $this->table_cols[$colname]['scale'] = $metadata_obj->scale;
1,504✔
193
                /** @psalm-suppress MixedArrayOffset */
194
                $this->table_cols[$colname]['notnull'] = $metadata_obj->notnull;
1,504✔
195
                /** @psalm-suppress MixedArrayOffset */
196
                $this->table_cols[$colname]['default'] = $metadata_obj->default;
1,504✔
197
                /** @psalm-suppress MixedArrayOffset */
198
                $this->table_cols[$colname]['autoinc'] = $metadata_obj->autoinc;
1,504✔
199
                /** @psalm-suppress MixedArrayOffset */
200
                $this->table_cols[$colname]['primary'] = $metadata_obj->primary;
1,504✔
201

202
                if( $this->getPrimaryCol() === '' && $metadata_obj->primary ) {
1,504✔
203

204
                    //this is a primary column
205
                    /** @psalm-suppress MixedArgument */
206
                    $this->setPrimaryCol($metadata_obj->name);
20✔
207

208
                } // $this->getPrimaryCol() === '' && $metadata_obj->primary
209
            } // foreach( $schema_definitions as $colname => $metadata_obj )
210
            
211
        } else { // $this->table_cols !== []
212

213
            if($this->getPrimaryCol() === '') {
136✔
214

215
                /** @psalm-suppress MixedAssignment */
216
                foreach ($this->table_cols as $colname => $col_metadata) {
12✔
217

218
                    /** @psalm-suppress MixedArrayAccess */
219
                    if($col_metadata['primary']) {
12✔
220

221
                        /** @psalm-suppress MixedArgumentTypeCoercion */
222
                        $this->setPrimaryCol($colname);
12✔
223
                        break;
12✔
224
                    }
225
                }
226
            } // if($this->getPrimaryCol() === '')
227
            
228
        }// if ( $this->table_cols === [] )
229

230
        //if $this->getPrimaryCol() is still '' at this point, throw an exception.
231
        if( $this->getPrimaryCol() === '' ) {
1,504✔
232

233
            throw new \GDAO\ModelPrimaryColNameNotSetDuringConstructionException($pri_col_not_set_exception_msg);
8✔
234
        }
235
    }
236
    
237
    /**
238
     * Detect if an unsupported DB Engine version is being used
239
     */
240
    protected function pdoServerVersionCheck(): void {
241

242
        if(strtolower($this->getPdoDriverName()) === 'sqlite') {
1,504✔
243

244
            $pdo_obj = $this->getPDO();
1,504✔
245

246
            /** @psalm-suppress MixedAssignment */
247
            $sqlite_version_number = $pdo_obj->getAttribute(\PDO::ATTR_SERVER_VERSION);
1,504✔
248

249
            /** @psalm-suppress MixedArgument */
250
            if(version_compare($sqlite_version_number, '3.7.10', '<=')) {
1,504✔
251

252
                $source = static::class . '::' . __FUNCTION__ . '(...)';
×
253
                $msg = "ERROR ({$source}): Sqlite version `{$sqlite_version_number}`"
×
254
                        . " detected. This package requires Sqlite version `3.7.11`"
×
255
                        . " or greater. Use a newer version of sqlite or use another"
×
256
                        . " DB server supported by this package." . PHP_EOL . 'Goodbye!!';
×
257

258
                throw new \LeanOrm\Exceptions\UnsupportedPdoServerVersionException($msg);
×
259

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

289
        $schema_class_name = '\\Rotexsoft\\SqlSchema\\' . ucfirst((string) $pdo_driver_name) . 'Schema';
1,504✔
290

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

343
        $selectObj = (new QueryFactory($this->getPdoDriverName()))->newSelect();
352✔
344

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

347
        /** @psalm-suppress LessSpecificReturnStatement */
348
        return $selectObj;
352✔
349
    }
350

351
    /**
352
     * {@inheritDoc}
353
     * 
354
     * @psalm-suppress LessSpecificReturnStatement
355
     */
356
    #[\Override]
357
    public function createNewCollection(\GDAO\Model\RecordInterface ...$list_of_records): \GDAO\Model\CollectionInterface {
358

359
        return ($this->collection_class_name === null || $this->collection_class_name === '')
224✔
360
                ? //default to creating new collection of type \LeanOrm\Model\Collection
224✔
361
                  new \LeanOrm\Model\Collection($this, ...$list_of_records)
8✔
362
                : new $this->collection_class_name($this, ...$list_of_records);
224✔
363
    }
364

365
    /**
366
     * {@inheritDoc}
367
     */
368
    #[\Override]
369
    public function createNewRecord(array $col_names_and_values = []): \GDAO\Model\RecordInterface {
370

371

372
        $result = ($this->record_class_name === null || $this->record_class_name === '')
528✔
373
                    ? //default to creating new record of type \LeanOrm\Model\Record
528✔
374
                      new \LeanOrm\Model\Record($col_names_and_values, $this)
8✔
375
                    : new $this->record_class_name($col_names_and_values, $this);
528✔
376
        
377
        /** @psalm-suppress LessSpecificReturnStatement */
378
        return $result;
528✔
379
    }
380

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

393
        if( $table_name === '' ) {
352✔
394

395
            $table_name = $this->getTableName();
352✔
396
        }
397

398
        if($initiallyNull || !$select_obj->hasCols()) {
352✔
399

400
            // We either just created the select object in this method or
401
            // there are no cols to select specified yet. 
402
            // Let's select all cols.
403
            $select_obj->cols([' ' . $table_name . '.* ']);
344✔
404
        }
405

406
        return $select_obj;
352✔
407
    }
408

409
    /**
410
     * @return mixed[]
411
     * @psalm-suppress PossiblyUnusedMethod
412
     */
413
    public function getDefaultColVals(): array {
414

415
        $default_colvals = [];
8✔
416

417
        /** @psalm-suppress MixedAssignment */
418
        foreach($this->table_cols as $col_name => $col_metadata) {
8✔
419
            
420
            /** @psalm-suppress MixedArrayAccess */
421
            $default_colvals[$col_name] = $col_metadata['default'];
8✔
422
        }
423

424
        return $default_colvals;
8✔
425
    }
426
    
427
    public function loadRelationshipData(
428
        string $rel_name, 
429
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data, 
430
        bool $wrap_each_row_in_a_record=false, 
431
        bool $wrap_records_in_collection=false
432
    ): static {
433

434
        /** @psalm-suppress MixedArrayAccess */
435
        if( 
436
            array_key_exists($rel_name, $this->relations) 
160✔
437
            && $this->relations[$rel_name]['relation_type'] === \GDAO\Model::RELATION_TYPE_HAS_MANY 
160✔
438
        ) {
439
            $this->loadHasMany($rel_name, $parent_data, $wrap_each_row_in_a_record, $wrap_records_in_collection);
160✔
440

441
        } else if (
442
            array_key_exists($rel_name, $this->relations) 
128✔
443
            && $this->relations[$rel_name]['relation_type'] === \GDAO\Model::RELATION_TYPE_HAS_MANY_THROUGH        
128✔
444
        ) {
445
            $this->loadHasManyThrough($rel_name, $parent_data, $wrap_each_row_in_a_record, $wrap_records_in_collection);
120✔
446

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

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

460
        return $this;
160✔
461
    }
462

463
    public function recursivelyStitchRelatedData(
464
        Model $model,
465
        array $relations_to_include,
466
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$fetched_data,
467
        bool $wrap_records_in_collection = false
468
    ): void {
469

470
        if ($relations_to_include !== []) {
296✔
471

472
            /** @psalm-suppress MixedAssignment */
473
            foreach ($relations_to_include as $potential_relation_name => $potential_array_of_relations_to_include_next) {
104✔
474

475
                $current_relation_name = $potential_relation_name;
104✔
476

477
                if (\is_numeric($potential_relation_name)) {
104✔
478

479
                    // $potential_array_of_relations_to_include_next must be a string containing the name of a relation to fetch
480
                    $current_relation_name = (string) $potential_array_of_relations_to_include_next; // value has to be relation name
104✔
481

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

485
                } elseif (
486
                    \is_array($potential_array_of_relations_to_include_next)
32✔
487
                    && \count($potential_array_of_relations_to_include_next) > 0
32✔
488
                ) {
489

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

507
                    /** @psalm-suppress MixedArgumentTypeCoercion */
508
                    $model->loadRelationshipData($current_relation_name, $fetched_data, true, $wrap_records_in_collection);
32✔
509

510
                    $model_obj_for_recursive_call = null;
32✔
511
                    $fetched_data_for_recursive_call = [];
32✔
512

513
                    /** @psalm-suppress MixedArgument */
514
                    if(
515
                        $fetched_data instanceof \GDAO\Model\RecordInterface
32✔
516
                        && 
517
                        (
518
                            ($fetched_data->{$current_relation_name} instanceof \GDAO\Model\RecordInterface)
32✔
519
                            ||
32✔
520
                            (
32✔
521
                                (
32✔
522
                                    $fetched_data->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface
32✔
523
                                    || \is_array($fetched_data->{$current_relation_name})
32✔
524
                                )
32✔
525
                                && count($fetched_data->{$current_relation_name}) > 0
32✔
526
                            )
32✔
527
                        )
528
                    ) {
529
                        $fetched_data_for_recursive_call = $fetched_data->{$current_relation_name};
8✔
530

531
                        if (
532
                            $fetched_data->{$current_relation_name} instanceof \GDAO\Model\RecordInterface 
8✔
533
                            || $fetched_data->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface
8✔
534
                        ) {
535
                            /** @psalm-suppress MixedMethodCall */
536
                            $model_obj_for_recursive_call = $fetched_data->{$current_relation_name}->getModel();
8✔
537

538
                        } else {
539

540
                            // $fetched_data->{$current_relation_name} is an array
541
                            /** @psalm-suppress MixedMethodCall */
542
                            $model_obj_for_recursive_call = reset($fetched_data->{$current_relation_name})->getModel();
×
543
                        }
544
                    } elseif(
545
                        (
546
                            $fetched_data instanceof \GDAO\Model\CollectionInterface
32✔
547
                            || \is_array($fetched_data)
32✔
548
                        )
549
                        && count($fetched_data) > 0
32✔
550
                    ) {
551
                        foreach ($fetched_data as $current_record) {
32✔
552

553
                            if (
554
                                ($current_record->{$current_relation_name} instanceof \GDAO\Model\RecordInterface)
32✔
555
                            ) {
556
                                $fetched_data_for_recursive_call[] = $current_record->{$current_relation_name};
4✔
557

558
                                if ($model_obj_for_recursive_call === null) {
4✔
559

560
                                    /** @psalm-suppress MixedMethodCall */
561
                                    $model_obj_for_recursive_call = $current_record->{$current_relation_name}->getModel();
4✔
562
                                }
563

564
                            } elseif (
565
                                (
566
                                    $current_record->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface 
32✔
567
                                    || \is_array($current_record->{$current_relation_name})
32✔
568
                                ) && count($current_record->{$current_relation_name}) > 0
32✔
569
                            ) {
570
                                foreach ($current_record->{$current_relation_name} as $current_related_record) {
32✔
571

572
                                    $fetched_data_for_recursive_call[] = $current_related_record;
32✔
573

574
                                    if ($model_obj_for_recursive_call === null) {
32✔
575

576
                                        /** @psalm-suppress MixedMethodCall */
577
                                        $model_obj_for_recursive_call = $current_related_record->getModel();
32✔
578

579
                                    } // if ($model_obj_for_recursive_call === null)
580
                                } // foreach ($current_record->{$current_relation_name} as $current_related_record)
581
                            } // if( ($current_record->{$current_relation_name} instanceof \GDAO\Model\RecordInterface) ) ......
582
                        } // foreach ($fetched_data as $current_record)
583
                    } // if( $fetched_data instanceof \GDAO\Model\RecordInterface .....
584

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

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

624
            throw new \LeanOrm\Exceptions\BadModelClassNameForFetchingRelatedDataException($msg);
32✔
625
        }
626
        
627
        return true;
1,504✔
628
    }
629
    
630
    /**
631
     * @psalm-suppress PossiblyUnusedReturnValue
632
     */
633
    protected function validateRelatedCollectionClassName(string $collection_class_name): bool {
634

635
        $parent_collection_class_name = \GDAO\Model\CollectionInterface::class;
1,504✔
636

637
        if( !is_subclass_of($collection_class_name, $parent_collection_class_name, true) ) {
1,504✔
638

639
            //throw exception
640
            $msg = "ERROR: '{$collection_class_name}' is not a subclass of "
32✔
641
                 . "'{$parent_collection_class_name}'. A collection class name specified"
32✔
642
                 . " for fetching related data must be the name of a class that"
32✔
643
                 . " is a sub-class of '{$parent_collection_class_name}'"
32✔
644
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
645
                 . PHP_EOL;
32✔
646

647
            throw new \LeanOrm\Exceptions\BadCollectionClassNameForFetchingRelatedDataException($msg);
32✔
648
        }
649

650
        return true;
1,504✔
651
    }
652

653
    /**
654
     * @psalm-suppress PossiblyUnusedReturnValue
655
     */
656
    protected function validateRelatedRecordClassName(string $record_class_name): bool {
657
        
658
        $parent_record_class_name = \GDAO\Model\RecordInterface::class;
1,504✔
659

660
        if( !is_subclass_of($record_class_name, $parent_record_class_name, true)  ) {
1,504✔
661

662
            //throw exception
663
            $msg = "ERROR: '{$record_class_name}' is not a subclass of "
32✔
664
                 . "'{$parent_record_class_name}'. A record class name specified for"
32✔
665
                 . " fetching related data must be the name of a class that"
32✔
666
                 . " is a sub-class of '{$parent_record_class_name}'"
32✔
667
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
668
                 . PHP_EOL;
32✔
669

670
            throw new \LeanOrm\Exceptions\BadRecordClassNameForFetchingRelatedDataException($msg);
32✔
671
        }
672

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

690
                -- $parent_data is a collection or array of records    
691
                SELECT {$foreign_table_name}.*
692
                  FROM {$foreign_table_name}
693
                 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} IN ( $fkey_col_in_my_table column values in $parent_data )
694

695
                -- OR
696

697
                -- $parent_data is a single record
698
                SELECT {$foreign_table_name}.*
699
                  FROM {$foreign_table_name}
700
                 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} = {$parent_data->$fkey_col_in_my_table}
701
            */
702
            [
160✔
703
                $fkey_col_in_foreign_table, $fkey_col_in_my_table, 
160✔
704
                $foreign_model_obj, $related_data
160✔
705
            ] = $this->getBelongsToOrHasOneOrHasManyData($rel_name, $parent_data);
160✔
706

707
            if ( 
708
                $parent_data instanceof \GDAO\Model\CollectionInterface
160✔
709
                || is_array($parent_data)
160✔
710
            ) {
711
                ///////////////////////////////////////////////////////////
712
                // Stitch the related data to the approriate parent records
713
                ///////////////////////////////////////////////////////////
714

715
                $fkey_val_to_related_data_keys = [];
96✔
716

717
                // Generate a map of 
718
                //      foreign key value => [keys of related rows in $related_data]
719
                /** @psalm-suppress MixedAssignment */
720
                foreach ($related_data as $curr_key => $related_datum) {
96✔
721

722
                    /** @psalm-suppress MixedArrayOffset */
723
                    $curr_fkey_val = $related_datum[$fkey_col_in_foreign_table];
96✔
724

725
                    /** @psalm-suppress MixedArgument */
726
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
96✔
727

728
                        /** @psalm-suppress MixedArrayOffset */
729
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
96✔
730
                    }
731

732
                    // Add current key in $related_data to sub array of keys for the 
733
                    // foreign key value in the current related row $related_datum
734
                    /** @psalm-suppress MixedArrayOffset */
735
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
96✔
736

737
                } // foreach ($related_data as $curr_key => $related_datum)
738

739
                // Now use $fkey_val_to_related_data_keys map to
740
                // look up related rows of data for each parent row of data
741
                /** @psalm-suppress MixedAssignment */
742
                foreach( $parent_data as $p_rec_key => $parent_row ) {
96✔
743

744
                    $matching_related_rows = [];
96✔
745

746
                    /** 
747
                     * @psalm-suppress MixedArgument 
748
                     * @psalm-suppress MixedArrayOffset 
749
                     */
750
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
96✔
751

752
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
96✔
753

754
                            $matching_related_rows[] = $related_data[$related_data_key];
96✔
755
                        }
756
                    }
757

758
                    /** @psalm-suppress MixedArgument */
759
                    $this->wrapRelatedDataInsideRecordsAndCollection(
96✔
760
                        $matching_related_rows, $foreign_model_obj, 
96✔
761
                        $wrap_each_row_in_a_record, $wrap_records_in_collection
96✔
762
                    );
96✔
763

764
                    //set the related data for the current parent row / record
765
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
96✔
766
                        /**
767
                         * @psalm-suppress MixedArrayTypeCoercion
768
                         * @psalm-suppress MixedArrayOffset
769
                         * @psalm-suppress MixedMethodCall
770
                         */
771
                        $parent_data[$p_rec_key]->setRelatedData($rel_name, $matching_related_rows);
80✔
772

773
                    } else {
774

775
                        //the current row must be an array
776
                        /**
777
                         * @psalm-suppress MixedArrayOffset
778
                         * @psalm-suppress MixedArrayAssignment
779
                         * @psalm-suppress InvalidArgument
780
                         */
781
                        $parent_data[$p_rec_key][$rel_name] = $matching_related_rows;
24✔
782
                    }
783
                } // foreach( $parent_data as $p_rec_key => $parent_record )
784

785
                ////////////////////////////////////////////////////////////////
786
                // End: Stitch the related data to the approriate parent records
787
                ////////////////////////////////////////////////////////////////
788

789
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
96✔
790

791
                /** @psalm-suppress MixedArgument */
792
                $this->wrapRelatedDataInsideRecordsAndCollection(
96✔
793
                    $related_data, $foreign_model_obj, 
96✔
794
                    $wrap_each_row_in_a_record, $wrap_records_in_collection
96✔
795
                );
96✔
796

797
                ///////////////////////////////////////////////
798
                //stitch the related data to the parent record
799
                ///////////////////////////////////////////////
800
                $parent_data->setRelatedData($rel_name, $related_data);
96✔
801

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

820
            /** 
821
             * @psalm-suppress MixedAssignment
822
             * @psalm-suppress MixedArgument
823
             */
824
            $foreign_table_name = Utils::arrayGet($rel_info, 'foreign_table');
120✔
825

826
            /** @psalm-suppress MixedAssignment */
827
            $fkey_col_in_foreign_table = 
120✔
828
                Utils::arrayGet($rel_info, 'col_in_foreign_table_linked_to_join_table');
120✔
829

830
            /** @psalm-suppress MixedAssignment */
831
            $foreign_models_class_name = 
120✔
832
                Utils::arrayGet($rel_info, 'foreign_models_class_name', \LeanOrm\Model::class);
120✔
833

834
            /** @psalm-suppress MixedAssignment */
835
            $pri_key_col_in_foreign_models_table = 
120✔
836
                Utils::arrayGet($rel_info, 'primary_key_col_in_foreign_table');
120✔
837

838
            /** @psalm-suppress MixedAssignment */
839
            $fkey_col_in_my_table = 
120✔
840
                    Utils::arrayGet($rel_info, 'col_in_my_table_linked_to_join_table');
120✔
841

842
            //join table params
843
            /** @psalm-suppress MixedAssignment */
844
            $join_table_name = Utils::arrayGet($rel_info, 'join_table');
120✔
845

846
            /** @psalm-suppress MixedAssignment */
847
            $col_in_join_table_linked_to_my_models_table = 
120✔
848
                Utils::arrayGet($rel_info, 'col_in_join_table_linked_to_my_table');
120✔
849

850
            /** @psalm-suppress MixedAssignment */
851
            $col_in_join_table_linked_to_foreign_models_table = 
120✔
852
                Utils::arrayGet($rel_info, 'col_in_join_table_linked_to_foreign_table');
120✔
853

854
            /** @psalm-suppress MixedAssignment */
855
            $sql_query_modifier = 
120✔
856
                Utils::arrayGet($rel_info, 'sql_query_modifier', null);
120✔
857

858
            /** @psalm-suppress MixedArgument */
859
            $foreign_model_obj = 
120✔
860
                $this->createRelatedModelObject(
120✔
861
                    $foreign_models_class_name,
120✔
862
                    $pri_key_col_in_foreign_models_table,
120✔
863
                    $foreign_table_name
120✔
864
                );
120✔
865

866
            /** @psalm-suppress MixedAssignment */
867
            $foreign_models_collection_class_name = 
120✔
868
                Utils::arrayGet($rel_info, 'foreign_models_collection_class_name', '');
120✔
869

870
            /** @psalm-suppress MixedAssignment */
871
            $foreign_models_record_class_name = 
120✔
872
                Utils::arrayGet($rel_info, 'foreign_models_record_class_name', '');
120✔
873

874
            if($foreign_models_collection_class_name !== '') {
120✔
875

876
                /** @psalm-suppress MixedArgument */
877
                $foreign_model_obj->setCollectionClassName($foreign_models_collection_class_name);
120✔
878
            }
879

880
            if($foreign_models_record_class_name !== '') {
120✔
881

882
                /** @psalm-suppress MixedArgument */
883
                $foreign_model_obj->setRecordClassName($foreign_models_record_class_name);
120✔
884
            }
885

886
            $query_obj = $foreign_model_obj->getSelect();
120✔
887

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

890
            /** @psalm-suppress MixedArgument */
891
            $query_obj->innerJoin(
120✔
892
                            $join_table_name, 
120✔
893
                            " {$join_table_name}.{$col_in_join_table_linked_to_foreign_models_table} = {$foreign_table_name}.{$fkey_col_in_foreign_table} "
120✔
894
                        );
120✔
895

896
            if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
120✔
897

898
                $query_obj->where(
32✔
899
                    " {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} = :leanorm_col_in_join_table_linked_to_my_models_table_val ",
32✔
900
                    ['leanorm_col_in_join_table_linked_to_my_models_table_val' => $parent_data->$fkey_col_in_my_table]
32✔
901
                );
32✔
902

903
            } else {
904

905
                //assume it's a collection or array
906
                /** @psalm-suppress MixedArgument */
907
                $col_vals = $this->getColValsFromArrayOrCollection(
88✔
908
                                $parent_data, $fkey_col_in_my_table
88✔
909
                            );
88✔
910

911
                if( $col_vals !== [] ) {
88✔
912

913
                    $this->addWhereInAndOrIsNullToQuery(
88✔
914
                        "{$join_table_name}.{$col_in_join_table_linked_to_my_models_table}", 
88✔
915
                        $col_vals, 
88✔
916
                        $query_obj
88✔
917
                    );
88✔
918
                }
919
            }
920

921
            if(\is_callable($sql_query_modifier)) {
120✔
922

923
                $sql_query_modifier = Utils::getClosureFromCallable($sql_query_modifier);
16✔
924
                // modify the query object before executing the query
925
                /** @psalm-suppress MixedAssignment */
926
                $query_obj = $sql_query_modifier($query_obj);
16✔
927
            }
928

929
            /** @psalm-suppress MixedAssignment */
930
            $params_2_bind_2_sql = $query_obj->getBindValues();
120✔
931

932
            /** @psalm-suppress MixedAssignment */
933
            $sql_2_get_related_data = $query_obj->__toString();
120✔
934

935
/*
936
-- SQL For Fetching the Related Data
937

938
-- $parent_data is a collection or array of records    
939
SELECT {$foreign_table_name}.*,
940
       {$join_table_name}.{$col_in_join_table_linked_to_my_models_table}
941
  FROM {$foreign_table_name}
942
  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}
943
 WHERE {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} IN ( $fkey_col_in_my_table column values in $parent_data )
944

945
OR
946

947
-- $parent_data is a single record
948
SELECT {$foreign_table_name}.*,
949
       {$join_table_name}.{$col_in_join_table_linked_to_my_models_table}
950
  FROM {$foreign_table_name}
951
  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}
952
 WHERE {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} = {$parent_data->$fkey_col_in_my_table}
953
*/
954
            //GRAB DA RELATED DATA
955
            /** @psalm-suppress MixedArgument */
956
            $related_data = 
120✔
957
                $this->db_connector
120✔
958
                     ->dbFetchAll($sql_2_get_related_data, $params_2_bind_2_sql, $this);
120✔
959

960
            if ( 
961
                $parent_data instanceof \GDAO\Model\CollectionInterface
120✔
962
                || is_array($parent_data)
120✔
963
            ) {
964
                ///////////////////////////////////////////////////////////
965
                // Stitch the related data to the approriate parent records
966
                ///////////////////////////////////////////////////////////
967

968
                $fkey_val_to_related_data_keys = [];
88✔
969

970
                // Generate a map of 
971
                //      foreign key value => [keys of related rows in $related_data]
972
                /** @psalm-suppress MixedAssignment */
973
                foreach ($related_data as $curr_key => $related_datum) {
88✔
974

975
                    /** @psalm-suppress MixedArrayOffset */
976
                    $curr_fkey_val = $related_datum[$col_in_join_table_linked_to_my_models_table];
88✔
977

978
                    /** @psalm-suppress MixedArgument */
979
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
88✔
980

981
                        /** @psalm-suppress MixedArrayOffset */
982
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
88✔
983
                    }
984

985
                    // Add current key in $related_data to sub array of keys for the 
986
                    // foreign key value in the current related row $related_datum
987
                    /** @psalm-suppress MixedArrayOffset */
988
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
88✔
989

990
                } // foreach ($related_data as $curr_key => $related_datum)
991

992
                // Now use $fkey_val_to_related_data_keys map to
993
                // look up related rows of data for each parent row of data
994
                /** @psalm-suppress MixedAssignment */
995
                foreach( $parent_data as $p_rec_key => $parent_row ) {
88✔
996

997
                    $matching_related_rows = [];
88✔
998

999
                    /** 
1000
                     * @psalm-suppress MixedArrayOffset
1001
                     * @psalm-suppress MixedArgument
1002
                     */
1003
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
88✔
1004

1005
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
88✔
1006

1007
                            $matching_related_rows[] = $related_data[$related_data_key];
88✔
1008
                        }
1009
                    }
1010

1011
                    $this->wrapRelatedDataInsideRecordsAndCollection(
88✔
1012
                        $matching_related_rows, $foreign_model_obj, 
88✔
1013
                        $wrap_each_row_in_a_record, $wrap_records_in_collection
88✔
1014
                    );
88✔
1015

1016
                    //set the related data for the current parent row / record
1017
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
88✔
1018

1019
                        /** 
1020
                         * @psalm-suppress MixedArrayOffset
1021
                         * @psalm-suppress MixedArrayTypeCoercion
1022
                         */
1023
                        $parent_data[$p_rec_key]->setRelatedData($rel_name, $matching_related_rows);
72✔
1024

1025
                    } else {
1026

1027
                        //the current row must be an array
1028
                        /** 
1029
                         * @psalm-suppress MixedArrayOffset
1030
                         * @psalm-suppress MixedArrayAssignment
1031
                         * @psalm-suppress InvalidArgument
1032
                         */
1033
                        $parent_data[$p_rec_key][$rel_name] = $matching_related_rows;
16✔
1034
                    }
1035

1036
                } // foreach( $parent_data as $p_rec_key => $parent_record )
1037

1038
                ////////////////////////////////////////////////////////////////
1039
                // End: Stitch the related data to the approriate parent records
1040
                ////////////////////////////////////////////////////////////////
1041

1042
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
32✔
1043

1044
                $this->wrapRelatedDataInsideRecordsAndCollection(
32✔
1045
                    $related_data, $foreign_model_obj, 
32✔
1046
                    $wrap_each_row_in_a_record, $wrap_records_in_collection
32✔
1047
                );
32✔
1048

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

1072
/*
1073
-- SQL For Fetching the Related Data
1074

1075
-- $parent_data is a collection or array of records    
1076
SELECT {$foreign_table_name}.*
1077
  FROM {$foreign_table_name}
1078
 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} IN ( $fkey_col_in_my_table column values in $parent_data )
1079

1080
OR
1081

1082
-- $parent_data is a single record
1083
SELECT {$foreign_table_name}.*
1084
  FROM {$foreign_table_name}
1085
 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} = {$parent_data->$fkey_col_in_my_table}
1086
*/
1087

1088
            if ( 
1089
                $parent_data instanceof \GDAO\Model\CollectionInterface
128✔
1090
                || is_array($parent_data)
128✔
1091
            ) {
1092
                ///////////////////////////////////////////////////////////
1093
                // Stitch the related data to the approriate parent records
1094
                ///////////////////////////////////////////////////////////
1095

1096
                $fkey_val_to_related_data_keys = [];
96✔
1097

1098
                // Generate a map of 
1099
                //      foreign key value => [keys of related rows in $related_data]
1100
                /** @psalm-suppress MixedAssignment */
1101
                foreach ($related_data as $curr_key => $related_datum) {
96✔
1102

1103
                    /** @psalm-suppress MixedArrayOffset */
1104
                    $curr_fkey_val = $related_datum[$fkey_col_in_foreign_table];
96✔
1105

1106
                    /** @psalm-suppress MixedArgument */
1107
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
96✔
1108

1109
                        /** @psalm-suppress MixedArrayOffset */
1110
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
96✔
1111
                    }
1112

1113
                    // Add current key in $related_data to sub array of keys for the 
1114
                    // foreign key value in the current related row $related_datum
1115
                    /** @psalm-suppress MixedArrayOffset */
1116
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
96✔
1117

1118
                } // foreach ($related_data as $curr_key => $related_datum)
1119

1120
                // Now use $fkey_val_to_related_data_keys map to
1121
                // look up related rows of data for each parent row of data
1122
                /** @psalm-suppress MixedAssignment */
1123
                foreach( $parent_data as $p_rec_key => $parent_row ) {
96✔
1124

1125
                    $matching_related_rows = [];
96✔
1126

1127
                    /** 
1128
                     * @psalm-suppress MixedArgument
1129
                     * @psalm-suppress MixedArrayOffset
1130
                     */
1131
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
96✔
1132

1133
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
96✔
1134

1135
                            // There should really only be one matching related 
1136
                            // record per parent record since this is a hasOne
1137
                            // relationship
1138
                            $matching_related_rows[] = $related_data[$related_data_key];
96✔
1139
                        }
1140
                    }
1141

1142
                    /** @psalm-suppress MixedArgument */
1143
                    $this->wrapRelatedDataInsideRecordsAndCollection(
96✔
1144
                        $matching_related_rows, $foreign_model_obj, 
96✔
1145
                        $wrap_row_in_a_record, false
96✔
1146
                    );
96✔
1147

1148
                    //set the related data for the current parent row / record
1149
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
96✔
1150

1151
                        // There should really only be one matching related 
1152
                        // record per parent record since this is a hasOne
1153
                        // relationship. That's why we are doing 
1154
                        // $matching_related_rows[0]
1155
                        /** 
1156
                         * @psalm-suppress MixedArrayTypeCoercion
1157
                         * @psalm-suppress MixedArrayOffset
1158
                         * @psalm-suppress MixedMethodCall
1159
                         */
1160
                        $parent_data[$p_rec_key]->setRelatedData(
72✔
1161
                                $rel_name, 
72✔
1162
                                (\count($matching_related_rows) > 0) 
72✔
1163
                                    ? $matching_related_rows[0] : []
72✔
1164
                            );
72✔
1165

1166
                    } else {
1167

1168
                        // There should really only be one matching related 
1169
                        // record per parent record since this is a hasOne
1170
                        // relationship. That's why we are doing 
1171
                        // $matching_related_rows[0]
1172

1173
                        //the current row must be an array
1174
                        /**
1175
                         * @psalm-suppress MixedArrayOffset
1176
                         * @psalm-suppress MixedArrayAssignment
1177
                         * @psalm-suppress MixedArgument
1178
                         * @psalm-suppress PossiblyInvalidArgument
1179
                         */
1180
                        $parent_data[$p_rec_key][$rel_name] = 
24✔
1181
                                (\count($matching_related_rows) > 0) 
24✔
1182
                                    ? $matching_related_rows[0] : [];
24✔
1183
                    }
1184
                } // foreach( $parent_data as $p_rec_key => $parent_record )
1185

1186
                ////////////////////////////////////////////////////////////////
1187
                // End: Stitch the related data to the approriate parent records
1188
                ////////////////////////////////////////////////////////////////
1189

1190
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
72✔
1191

1192
                /** @psalm-suppress MixedArgument */
1193
                $this->wrapRelatedDataInsideRecordsAndCollection(
72✔
1194
                            $related_data, $foreign_model_obj, 
72✔
1195
                            $wrap_row_in_a_record, false
72✔
1196
                        );
72✔
1197

1198
                //stitch the related data to the parent record
1199
                /** @psalm-suppress MixedArgument */
1200
                $parent_data->setRelatedData(
72✔
1201
                    $rel_name, 
72✔
1202
                    (\count($related_data) > 0) ? \array_shift($related_data) : []
72✔
1203
                );
72✔
1204
            } // else if ($parent_data instanceof \GDAO\Model\RecordInterface)
1205
        } // if( array_key_exists($rel_name, $this->relations) )
1206
    }
1207
    
1208
    protected function loadBelongsTo(
1209
        string $rel_name, 
1210
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data, 
1211
        bool $wrap_row_in_a_record=false
1212
    ): void {
1213

1214
        /** @psalm-suppress MixedArrayAccess */
1215
        if( 
1216
            array_key_exists($rel_name, $this->relations) 
128✔
1217
            && $this->relations[$rel_name]['relation_type']  === \GDAO\Model::RELATION_TYPE_BELONGS_TO
128✔
1218
        ) {
1219
            //quick hack
1220
            /** @psalm-suppress MixedArrayAssignment */
1221
            $this->relations[$rel_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_ONE;
128✔
1222

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

1228
            //undo quick hack
1229
            /** @psalm-suppress MixedArrayAssignment */
1230
            $this->relations[$rel_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_BELONGS_TO;
128✔
1231
        }
1232
    }
1233
    
1234
    /**
1235
     * @return mixed[]
1236
     */
1237
    protected function getBelongsToOrHasOneOrHasManyData(
1238
        string $rel_name, 
1239
        \GDAO\Model\RecordInterface|\GDAO\Model\CollectionInterface|array &$parent_data
1240
    ): array {
1241
        /** 
1242
         * @psalm-suppress MixedAssignment
1243
         */
1244
        $rel_info = $this->relations[$rel_name];
160✔
1245

1246
        /** 
1247
         * @psalm-suppress MixedAssignment
1248
         * @psalm-suppress MixedArgument
1249
         */
1250
        $foreign_table_name = Utils::arrayGet($rel_info, 'foreign_table');
160✔
1251

1252
        /** @psalm-suppress MixedAssignment */
1253
        $fkey_col_in_foreign_table = 
160✔
1254
            Utils::arrayGet($rel_info, 'foreign_key_col_in_foreign_table');
160✔
1255
        
1256
        /** @psalm-suppress MixedAssignment */
1257
        $foreign_models_class_name = 
160✔
1258
            Utils::arrayGet($rel_info, 'foreign_models_class_name', \LeanOrm\Model::class);
160✔
1259

1260
        /** @psalm-suppress MixedAssignment */
1261
        $pri_key_col_in_foreign_models_table = 
160✔
1262
            Utils::arrayGet($rel_info, 'primary_key_col_in_foreign_table');
160✔
1263

1264
        /** @psalm-suppress MixedAssignment */
1265
        $fkey_col_in_my_table = 
160✔
1266
                Utils::arrayGet($rel_info, 'foreign_key_col_in_my_table');
160✔
1267

1268
        /** @psalm-suppress MixedAssignment */
1269
        $sql_query_modifier = 
160✔
1270
                Utils::arrayGet($rel_info, 'sql_query_modifier', null);
160✔
1271

1272
        /** @psalm-suppress MixedArgument */
1273
        $foreign_model_obj = $this->createRelatedModelObject(
160✔
1274
                                        $foreign_models_class_name,
160✔
1275
                                        $pri_key_col_in_foreign_models_table,
160✔
1276
                                        $foreign_table_name
160✔
1277
                                    );
160✔
1278
        
1279
        /** @psalm-suppress MixedAssignment */
1280
        $foreign_models_collection_class_name = 
160✔
1281
            Utils::arrayGet($rel_info, 'foreign_models_collection_class_name', '');
160✔
1282

1283
        /** @psalm-suppress MixedAssignment */
1284
        $foreign_models_record_class_name = 
160✔
1285
            Utils::arrayGet($rel_info, 'foreign_models_record_class_name', '');
160✔
1286

1287
        if($foreign_models_collection_class_name !== '') {
160✔
1288
            
1289
            /** @psalm-suppress MixedArgument */
1290
            $foreign_model_obj->setCollectionClassName($foreign_models_collection_class_name);
160✔
1291
        }
1292

1293
        if($foreign_models_record_class_name !== '') {
160✔
1294
            
1295
            /** @psalm-suppress MixedArgument */
1296
            $foreign_model_obj->setRecordClassName($foreign_models_record_class_name);
160✔
1297
        }
1298

1299
        $query_obj = $foreign_model_obj->getSelect();
160✔
1300

1301
        if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
160✔
1302

1303
            $query_obj->where(
96✔
1304
                " {$foreign_table_name}.{$fkey_col_in_foreign_table} = :leanorm_fkey_col_in_foreign_table_val ",
96✔
1305
                ['leanorm_fkey_col_in_foreign_table_val' => $parent_data->$fkey_col_in_my_table]
96✔
1306
            );
96✔
1307

1308
        } else {
1309
            //assume it's a collection or array
1310
            /** @psalm-suppress MixedArgument */
1311
            $col_vals = $this->getColValsFromArrayOrCollection(
104✔
1312
                            $parent_data, $fkey_col_in_my_table
104✔
1313
                        );
104✔
1314

1315
            if( $col_vals !== [] ) {
104✔
1316
                
1317
                $this->addWhereInAndOrIsNullToQuery(
104✔
1318
                    "{$foreign_table_name}.{$fkey_col_in_foreign_table}", 
104✔
1319
                    $col_vals, 
104✔
1320
                    $query_obj
104✔
1321
                );
104✔
1322
            }
1323
        }
1324

1325
        if(\is_callable($sql_query_modifier)) {
160✔
1326

1327
            $sql_query_modifier = Utils::getClosureFromCallable($sql_query_modifier);
56✔
1328
            
1329
            // modify the query object before executing the query
1330
            /** @psalm-suppress MixedAssignment */
1331
            $query_obj = $sql_query_modifier($query_obj);
56✔
1332
        }
1333

1334
        if($query_obj->hasCols() === false){
160✔
1335

1336
            $query_obj->cols(["{$foreign_table_name}.*"]);
160✔
1337
        }
1338
        
1339
        /** @psalm-suppress MixedAssignment */
1340
        $params_2_bind_2_sql = $query_obj->getBindValues();
160✔
1341
        
1342
        /** @psalm-suppress MixedAssignment */
1343
        $sql_2_get_related_data = $query_obj->__toString();
160✔
1344
        
1345
        /** @psalm-suppress MixedArgument */
1346
        return [
160✔
1347
            $fkey_col_in_foreign_table, $fkey_col_in_my_table, $foreign_model_obj,
160✔
1348
            $this->db_connector->dbFetchAll($sql_2_get_related_data, $params_2_bind_2_sql, $this) // fetch the related data
160✔
1349
        ]; 
160✔
1350
    }
1351
    
1352
    /** @psalm-suppress MoreSpecificReturnType */
1353
    protected function createRelatedModelObject(
1354
        string $f_models_class_name, 
1355
        string $pri_key_col_in_f_models_table, 
1356
        string $f_table_name
1357
    ): Model {
1358
        //$foreign_models_class_name will never be empty it will default to \LeanOrm\Model
1359
        //$foreign_table_name will never be empty because it is needed for fetching the 
1360
        //related data
1361
        if( ($f_models_class_name === '') ) {
160✔
1362

1363
            $f_models_class_name = \LeanOrm\Model::class;
×
1364
        }
1365

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

1435
    /**
1436
     * @return mixed[]
1437
     */
1438
    protected function getColValsFromArrayOrCollection(
1439
        \GDAO\Model\CollectionInterface|array &$parent_data, 
1440
        string $fkey_col_in_my_table
1441
    ): array {
1442
        $col_vals = [];
104✔
1443

1444
        if ( is_array($parent_data) ) {
104✔
1445

1446
            /** @psalm-suppress MixedAssignment */
1447
            foreach($parent_data as $data) {
80✔
1448

1449
                /** 
1450
                 * @psalm-suppress MixedAssignment
1451
                 * @psalm-suppress MixedArrayAccess
1452
                 */
1453
                $col_vals[] = $data[$fkey_col_in_my_table];
80✔
1454
            }
1455

1456
        } elseif($parent_data instanceof \GDAO\Model\CollectionInterface) {
56✔
1457

1458
            $col_vals = $parent_data->getColVals($fkey_col_in_my_table);
56✔
1459
        }
1460

1461
        return $col_vals;
104✔
1462
    }
1463

1464
    /** @psalm-suppress ReferenceConstraintViolation */
1465
    protected function wrapRelatedDataInsideRecordsAndCollection(
1466
        array &$matching_related_records, Model $foreign_model_obj, 
1467
        bool $wrap_each_row_in_a_record, bool $wrap_records_in_collection
1468
    ): void {
1469
        
1470
        if( $wrap_each_row_in_a_record ) {
160✔
1471

1472
            //wrap into records of the appropriate class
1473
            /** @psalm-suppress MixedAssignment */
1474
            foreach ($matching_related_records as $key=>$rec_data) {
144✔
1475
                
1476
                // Mark as not new because this is a related row of data that 
1477
                // already exists in the db as opposed to a row of data that
1478
                // has never been saved to the db
1479
                /** @psalm-suppress MixedArgument */
1480
                $matching_related_records[$key] = 
144✔
1481
                    $foreign_model_obj->createNewRecord($rec_data)
144✔
1482
                                      ->markAsNotNew();
144✔
1483
            }
1484
        }
1485

1486
        if($wrap_records_in_collection) {
160✔
1487
            
1488
            /** @psalm-suppress MixedArgument */
1489
            $matching_related_records = $foreign_model_obj->createNewCollection(...$matching_related_records);
128✔
1490
        }
1491
    }
1492

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

1545
            if( $use_collections ) {
20✔
1546

1547
                $_result = ($use_p_k_val_as_key) 
20✔
1548
                            ? $this->fetchRecordsIntoCollectionKeyedOnPkVal($select_obj, $relations_to_include) 
8✔
1549
                            : $this->fetchRecordsIntoCollection($select_obj, $relations_to_include);
20✔
1550

1551
            } else {
1552

1553
                if( $use_records ) {
8✔
1554

1555
                    $_result = ($use_p_k_val_as_key) 
8✔
1556
                                ? $this->fetchRecordsIntoArrayKeyedOnPkVal($select_obj, $relations_to_include) 
8✔
1557
                                : $this->fetchRecordsIntoArray($select_obj, $relations_to_include);
8✔
1558
                } else {
1559

1560
                    //default
1561
                    $_result = ($use_p_k_val_as_key) 
8✔
1562
                                ? $this->fetchRowsIntoArrayKeyedOnPkVal($select_obj, $relations_to_include) 
8✔
1563
                                : $this->fetchRowsIntoArray($select_obj, $relations_to_include);
8✔
1564
                } // if( $use_records ) else ...
1565
            } // if( $use_collections ) else ...
1566
            
1567
            /** @psalm-suppress TypeDoesNotContainType */
1568
            if(!($_result instanceof \GDAO\Model\CollectionInterface) && !is_array($_result)) {
20✔
1569
               
1570
                return $use_collections ? $this->createNewCollection() : [];
×
1571
            } 
1572
            
1573
            return $_result;
20✔
1574
            
1575
        } // if( $ids !== [] )
1576

1577
        // return empty collection or array
1578
        return $use_collections ? $this->createNewCollection() : [];
8✔
1579
    }
1580

1581
    /**
1582
     * {@inheritDoc}
1583
     */
1584
    #[\Override]
1585
    public function fetchRecordsIntoCollection(?object $query=null, array $relations_to_include=[]): \GDAO\Model\CollectionInterface {
1586

1587
        return $this->doFetchRecordsIntoCollection(
112✔
1588
                    ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null, 
112✔
1589
                    $relations_to_include
112✔
1590
                );
112✔
1591
    }
1592

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

1595
        return $this->doFetchRecordsIntoCollection($select_obj, $relations_to_include, true);
36✔
1596
    }
1597

1598
    /**
1599
     * @psalm-suppress InvalidReturnType
1600
     */
1601
    protected function doFetchRecordsIntoCollection(
1602
        ?\Aura\SqlQuery\Common\Select $select_obj=null, 
1603
        array $relations_to_include=[], 
1604
        bool $use_p_k_val_as_key=false
1605
    ): \GDAO\Model\CollectionInterface {
1606
        $results = $this->createNewCollection();
132✔
1607
        $data = $this->getArrayOfRecordObjects($select_obj, $use_p_k_val_as_key);
132✔
1608

1609
        if($data !== [] ) {
132✔
1610

1611
            if($use_p_k_val_as_key) {
132✔
1612
                
1613
                foreach ($data as $pkey => $current_record) {
36✔
1614
                    
1615
                    $results[$pkey] = $current_record;
36✔
1616
                }
1617
                
1618
            } else {
1619
               
1620
                $results = $this->createNewCollection(...$data);
112✔
1621
            }
1622
            
1623
            $this->recursivelyStitchRelatedData(
132✔
1624
                model: $this,
132✔
1625
                relations_to_include: $relations_to_include, 
132✔
1626
                fetched_data: $results, 
132✔
1627
                wrap_records_in_collection: true
132✔
1628
            );
132✔
1629
        }
1630

1631
        /** @psalm-suppress InvalidReturnStatement */
1632
        return $results;
132✔
1633
    }
1634

1635
    /**
1636
     * {@inheritDoc}
1637
     */
1638
    #[\Override]
1639
    public function fetchRecordsIntoArray(?object $query=null, array $relations_to_include=[]): array {
1640
        
1641
        return $this->doFetchRecordsIntoArray(
28✔
1642
                    ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null, 
28✔
1643
                    $relations_to_include
28✔
1644
                );
28✔
1645
    }
1646

1647
    /**
1648
     * @return \GDAO\Model\RecordInterface[]
1649
     */
1650
    public function fetchRecordsIntoArrayKeyedOnPkVal(?\Aura\SqlQuery\Common\Select $select_obj=null, array $relations_to_include=[]): array {
1651
        
1652
        return $this->doFetchRecordsIntoArray($select_obj, $relations_to_include, true);
28✔
1653
    }
1654

1655
    /**
1656
     * @return \GDAO\Model\RecordInterface[]
1657
     * @psalm-suppress MixedReturnTypeCoercion
1658
     */
1659
    protected function doFetchRecordsIntoArray(
1660
        ?\Aura\SqlQuery\Common\Select $select_obj=null, 
1661
        array $relations_to_include=[], 
1662
        bool $use_p_k_val_as_key=false
1663
    ): array {
1664
        $results = $this->getArrayOfRecordObjects($select_obj, $use_p_k_val_as_key);
48✔
1665

1666
        if( $results !== [] ) {
48✔
1667
            
1668
            $this->recursivelyStitchRelatedData(
48✔
1669
                model: $this,
48✔
1670
                relations_to_include: $relations_to_include, 
48✔
1671
                fetched_data: $results, 
48✔
1672
                wrap_records_in_collection: false
48✔
1673
            );
48✔
1674
        }
1675

1676
        return $results;
48✔
1677
    }
1678

1679
    /**
1680
     * @return \GDAO\Model\RecordInterface[]
1681
     * @psalm-suppress MixedReturnTypeCoercion
1682
     */
1683
    protected function getArrayOfRecordObjects(?\Aura\SqlQuery\Common\Select $select_obj=null, bool $use_p_k_val_as_key=false): array {
1684

1685
        $results = $this->getArrayOfDbRows($select_obj, $use_p_k_val_as_key);
172✔
1686

1687
        /** @psalm-suppress MixedAssignment */
1688
        foreach ($results as $key=>$value) {
172✔
1689

1690
            /** @psalm-suppress MixedArgument */
1691
            $results[$key] = $this->createNewRecord($value)->markAsNotNew();
172✔
1692
        }
1693
        
1694
        return $results;
172✔
1695
    }
1696

1697
    /**
1698
     * @return mixed[]
1699
     */
1700
    protected function getArrayOfDbRows(?\Aura\SqlQuery\Common\Select $select_obj=null, bool $use_p_k_val_as_key=false): array {
1701

1702
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery($select_obj);
224✔
1703
        $sql = $query_obj->__toString();
224✔
1704
        $params_2_bind_2_sql = $query_obj->getBindValues();
224✔
1705

1706
        $results = $this->db_connector->dbFetchAll($sql, $params_2_bind_2_sql, $this);
224✔
1707
        
1708
        if( $use_p_k_val_as_key && $results !== [] && $this->getPrimaryCol() !== '' ) {
224✔
1709

1710
            $results_keyed_by_pk = [];
68✔
1711

1712
            /** @psalm-suppress MixedAssignment */
1713
            foreach( $results as $result ) {
68✔
1714

1715
                /** @psalm-suppress MixedArgument */
1716
                if( !array_key_exists($this->getPrimaryCol(), $result) ) {
68✔
1717

1718
                    $msg = "ERROR: Can't key fetch results by Primary Key value."
×
1719
                         . PHP_EOL . " One or more result rows has no Primary Key field (`{$this->getPrimaryCol()}`)" 
×
1720
                         . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).'
×
1721
                         . PHP_EOL . 'Fetch Results:' . PHP_EOL . var_export($results, true) . PHP_EOL
×
1722
                         . PHP_EOL . "Row without Primary Key field (`{$this->getPrimaryCol()}`):" . PHP_EOL . var_export($result, true) . PHP_EOL;
×
1723

1724
                    throw new \LeanOrm\Exceptions\KeyingFetchResultsByPrimaryKeyFailedException($msg);
×
1725
                }
1726

1727
                // key on primary key value
1728
                /** @psalm-suppress MixedArrayOffset */
1729
                $results_keyed_by_pk[$result[$this->getPrimaryCol()]] = $result;
68✔
1730
            }
1731

1732
            $results = $results_keyed_by_pk;
68✔
1733
        }
1734

1735
        return $results;
224✔
1736
    }
1737

1738
    /**
1739
     * {@inheritDoc}
1740
     */
1741
    #[\Override]
1742
    public function fetchRowsIntoArray(?object $query=null, array $relations_to_include=[]): array {
1743

1744
        return $this->doFetchRowsIntoArray(
84✔
1745
                    ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null, 
84✔
1746
                    $relations_to_include
84✔
1747
                );
84✔
1748
    }
1749

1750
    /**
1751
     * @return array[]
1752
     */
1753
    public function fetchRowsIntoArrayKeyedOnPkVal(?\Aura\SqlQuery\Common\Select $select_obj=null, array $relations_to_include=[]): array {
1754

1755
        return $this->doFetchRowsIntoArray($select_obj, $relations_to_include, true);
20✔
1756
    }
1757

1758
    /**
1759
     * @return array[]
1760
     * @psalm-suppress MixedReturnTypeCoercion
1761
     */
1762
    protected function doFetchRowsIntoArray(
1763
        ?\Aura\SqlQuery\Common\Select $select_obj=null, 
1764
        array $relations_to_include=[], 
1765
        bool $use_p_k_val_as_key=false
1766
    ): array {
1767
        $results = $this->getArrayOfDbRows($select_obj, $use_p_k_val_as_key);
92✔
1768
        
1769
        if( $results !== [] ) {
92✔
1770
            
1771
            /** @psalm-suppress MixedAssignment */
1772
            foreach( $relations_to_include as $key=>$rel_name ) {
92✔
1773

1774
                if(\is_array($rel_name) && \in_array($key, $this->getRelationNames())) {
32✔
1775
                    
1776
                    /////////////////////////////////////////////////////////////////
1777
                    // In case $relations_to_include contains nested relation names
1778
                    // only take the top level relations as doFetchRowsIntoArray
1779
                    // currently doesn't support nested eager fetching of related
1780
                    // data
1781
                    /////////////////////////////////////////////////////////////////
1782
                    $rel_name = $key;
8✔
1783
                    
1784
                } elseif(\is_array($rel_name) && !\in_array($key, $this->getRelationNames())) {
32✔
1785
                    
1786
                    continue;
8✔
1787
                } // else $rel_name is a potential relationship name
1788
                
1789
                /** @psalm-suppress MixedArgument */
1790
                $this->loadRelationshipData($rel_name, $results);
32✔
1791
            }
1792
        }
1793

1794
        return $results;
92✔
1795
    }
1796

1797
    #[\Override]
1798
    public function getPDO(): \PDO {
1799

1800
        //return pdo object associated with the current dsn
1801
        return DBConnector::getPdo($this->db_connector->getConnectionName()); 
1,504✔
1802
    }
1803

1804
    /**
1805
     * {@inheritDoc}
1806
     */
1807
    #[\Override]
1808
    public function deleteMatchingDbTableRows(array $cols_n_vals): int {
1809

1810
        $result = 0;
96✔
1811

1812
        if ( $cols_n_vals !== [] ) {
96✔
1813

1814
            //delete statement
1815
            $del_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newDelete();
96✔
1816
            $sel_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newSelect();
96✔
1817
            $del_qry_obj->from($this->getTableName());
96✔
1818
            $sel_qry_obj->from($this->getTableName());
96✔
1819
            $sel_qry_obj->cols([' count(*) ']);
96✔
1820
            $table_cols = $this->getTableColNames();
96✔
1821

1822
            /** @psalm-suppress MixedAssignment */
1823
            foreach ($cols_n_vals as $colname => $colval) {
96✔
1824

1825
                if(!in_array($colname, $table_cols)) {
96✔
1826

1827
                    // specified column is not a valid db table col, remove it
1828
                    unset($cols_n_vals[$colname]);
8✔
1829
                    continue;
8✔
1830
                }
1831

1832
                if (is_array($colval)) {
96✔
1833

1834
                    /** @psalm-suppress MixedAssignment */
1835
                    foreach($colval as $key=>$val) {
24✔
1836

1837
                        if(!$this->isAcceptableDeleteQueryValue($val)) {
24✔
1838

1839
                            $this->throwExceptionForInvalidDeleteQueryArg($val, $cols_n_vals);
8✔
1840
                        }
1841

1842
                        /** @psalm-suppress MixedAssignment */
1843
                        $colval[$key] = $this->stringifyIfStringable($val);
24✔
1844
                    }
1845

1846
                    $this->addWhereInAndOrIsNullToQuery(''.$colname, $colval, $del_qry_obj);
16✔
1847
                    $this->addWhereInAndOrIsNullToQuery(''.$colname, $colval, $sel_qry_obj);
16✔
1848

1849
                } else {
1850

1851
                    if(!$this->isAcceptableDeleteQueryValue($colval)) {
88✔
1852

1853
                        $this->throwExceptionForInvalidDeleteQueryArg($colval, $cols_n_vals);
8✔
1854
                    }
1855

1856
                    $del_qry_obj->where(" {$colname} = :{$colname} ", [$colname => $this->stringifyIfStringable($colval)]);
88✔
1857
                    $sel_qry_obj->where(" {$colname} = :{$colname} ", [$colname => $this->stringifyIfStringable($colval)]);
88✔
1858
                }
1859
            }
1860

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

1865
                $dlt_qry = $del_qry_obj->__toString();
80✔
1866
                $dlt_qry_params = $del_qry_obj->getBindValues();
80✔
1867

1868
                $matching_rows_before_delete = (int) $this->fetchValue($sel_qry_obj);
80✔
1869

1870
                $this->db_connector->runQuery($dlt_qry, $dlt_qry_params, $this);
80✔
1871

1872
                $matching_rows_after_delete = (int) $this->fetchValue($sel_qry_obj);
80✔
1873

1874
                //number of deleted rows
1875
                $result = $matching_rows_before_delete - $matching_rows_after_delete;
80✔
1876
            } // if($cols_n_vals !== []) 
1877
        } // if ( $cols_n_vals !== [] )
1878

1879
        return $result;
80✔
1880
    }
1881
    
1882
    protected function throwExceptionForInvalidDeleteQueryArg(mixed $val, array $cols_n_vals): never {
1883

1884
        $msg = "ERROR: the value "
16✔
1885
             . PHP_EOL . var_export($val, true) . PHP_EOL
16✔
1886
             . " you are trying to use to bulid the where clause for deleting from the table `{$this->getTableName()}`"
16✔
1887
             . " is not acceptable ('".  gettype($val) . "'"
16✔
1888
             . " supplied). Boolean, NULL, numeric or string value expected."
16✔
1889
             . PHP_EOL
16✔
1890
             . "Data supplied to "
16✔
1891
             . static::class . '::' . __FUNCTION__ . '(...).' 
16✔
1892
             . " for buiding the where clause for the deletion:"
16✔
1893
             . PHP_EOL . var_export($cols_n_vals, true) . PHP_EOL
16✔
1894
             . PHP_EOL;
16✔
1895

1896
        throw new \LeanOrm\Exceptions\InvalidArgumentException($msg);
16✔
1897
    }
1898
    
1899
    /**
1900
     * {@inheritDoc}
1901
     */
1902
    #[\Override]
1903
    public function deleteSpecifiedRecord(\GDAO\Model\RecordInterface $record): ?bool {
1904

1905
        $succesfully_deleted = null;
88✔
1906

1907
        if( $record instanceof \LeanOrm\Model\ReadOnlyRecord ) {
88✔
1908

1909
            $msg = "ERROR: Can't delete ReadOnlyRecord from the database in " 
8✔
1910
                 . static::class . '::' . __FUNCTION__ . '(...).'
8✔
1911
                 . PHP_EOL .'Undeleted record' . var_export($record, true) . PHP_EOL;
8✔
1912
            throw new \LeanOrm\Exceptions\CantDeleteReadOnlyRecordFromDBException($msg);
8✔
1913
        }
1914
        
1915
        if( 
1916
            $record->getModel()->getTableName() !== $this->getTableName() 
80✔
1917
            || $record->getModel()::class !== static::class  
80✔
1918
        ) {
1919
            $msg = "ERROR: Can't delete a record (an instance of `%s` belonging to the Model class `%s`) belonging to the database table `%s` " 
16✔
1920
                . "using a Model instance of `%s` belonging to the database table `%s` in " 
16✔
1921
                 . static::class . '::' . __FUNCTION__ . '(...).'
16✔
1922
                 . PHP_EOL .'Undeleted record: ' . PHP_EOL . var_export($record, true) . PHP_EOL; 
16✔
1923
            throw new \LeanOrm\Exceptions\InvalidArgumentException(
16✔
1924
                sprintf(
16✔
1925
                    $msg, $record::class, $record->getModel()::class, 
16✔
1926
                    $record->getModel()->getTableName(),
16✔
1927
                    static::class, $this->getTableName()
16✔
1928
                )
16✔
1929
            );
16✔
1930
        }
1931

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

1934
            /** @psalm-suppress MixedAssignment */
1935
            $pri_key_val = $record->getPrimaryVal();
64✔
1936
            $cols_n_vals = [$record->getPrimaryCol() => $pri_key_val];
64✔
1937

1938
            $succesfully_deleted = 
64✔
1939
                $this->deleteMatchingDbTableRows($cols_n_vals);
64✔
1940

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

1975
        return ( $succesfully_deleted >= 1 ) ? true : $succesfully_deleted;
64✔
1976
    }
1977

1978
    /**
1979
     * {@inheritDoc}
1980
     */
1981
    #[\Override]
1982
    public function fetchCol(?object $query=null): array {
1983

1984
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
56✔
1985
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
56✔
1986
        );
56✔
1987
        $sql = $query_obj->__toString();
56✔
1988
        $params_2_bind_2_sql = $query_obj->getBindValues();
56✔
1989

1990
        return $this->db_connector->dbFetchCol($sql, $params_2_bind_2_sql, $this);
56✔
1991
    }
1992

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

1999
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
136✔
2000
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
136✔
2001
        );
136✔
2002
        $query_obj->limit(1);
136✔
2003

2004
        $sql = $query_obj->__toString();
136✔
2005
        $params_2_bind_2_sql = $query_obj->getBindValues();
136✔
2006

2007
        /** @psalm-suppress MixedAssignment */
2008
        $result = $this->db_connector->dbFetchOne($sql, $params_2_bind_2_sql, $this);
136✔
2009

2010
        if( $result !== false && is_array($result) && $result !== [] ) {
136✔
2011

2012
            $result = $this->createNewRecord($result)->markAsNotNew();
136✔
2013
            
2014
            $this->recursivelyStitchRelatedData(
136✔
2015
                model: $this,
136✔
2016
                relations_to_include: $relations_to_include, 
136✔
2017
                fetched_data: $result, 
136✔
2018
                wrap_records_in_collection: true
136✔
2019
            );
136✔
2020
        }
2021
        
2022
        if(!($result instanceof \GDAO\Model\RecordInterface)) {
136✔
2023
            
2024
            /** @psalm-suppress ReferenceConstraintViolation */
2025
            $result = null;
40✔
2026
        }
2027

2028
        return $result;
136✔
2029
    }
2030
    
2031
    /**
2032
     * Convenience method to fetch one record by the specified primary key value.
2033
     * @param string[] $relations_to_include names of relations to include
2034
     * @psalm-suppress PossiblyUnusedMethod
2035
     */
2036
    public function fetchOneByPkey(string|int $id, array $relations_to_include = []): ?\GDAO\Model\RecordInterface {
2037
        
2038
        $select = $this->getSelect();
12✔
2039
        $query_placeholder = "leanorm_{$this->getTableName()}_{$this->getPrimaryCol()}_val";
12✔
2040
        $select->where(
12✔
2041
            " {$this->getPrimaryCol()} = :{$query_placeholder} ", 
12✔
2042
            [ $query_placeholder => $id]
12✔
2043
        );
12✔
2044
        
2045
        return $this->fetchOneRecord($select, $relations_to_include);
12✔
2046
    }
2047

2048
    /**
2049
     * {@inheritDoc}
2050
     */
2051
    #[\Override]
2052
    public function fetchPairs(?object $query=null): array {
2053

2054
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
8✔
2055
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
8✔
2056
        );
8✔
2057
        $sql = $query_obj->__toString();
8✔
2058
        $params_2_bind_2_sql = $query_obj->getBindValues();
8✔
2059

2060
        return $this->db_connector->dbFetchPairs($sql, $params_2_bind_2_sql, $this);
8✔
2061
    }
2062

2063
    /**
2064
     * {@inheritDoc}
2065
     */
2066
    #[\Override]
2067
    public function fetchValue(?object $query=null): mixed {
2068

2069
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
112✔
2070
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
112✔
2071
        );
112✔
2072
        $query_obj->limit(1);
112✔
2073

2074
        $query_obj_4_num_matching_rows = clone $query_obj;
112✔
2075

2076
        $sql = $query_obj->__toString();
112✔
2077
        $params_2_bind_2_sql = $query_obj->getBindValues();
112✔
2078

2079
        /** @psalm-suppress MixedAssignment */
2080
        $result = $this->db_connector->dbFetchValue($sql, $params_2_bind_2_sql, $this);
112✔
2081

2082
        // need to issue a second query to get the number of matching rows
2083
        // clear the cols part of the query above while preserving all the
2084
        // other parts of the query
2085
        $query_obj_4_num_matching_rows->resetCols();
112✔
2086
        $query_obj_4_num_matching_rows->cols([' COUNT(*) AS num_rows']);
112✔
2087

2088
        $sql = $query_obj_4_num_matching_rows->__toString();
112✔
2089
        $params_2_bind_2_sql = $query_obj_4_num_matching_rows->getBindValues();
112✔
2090

2091
        /** @psalm-suppress MixedAssignment */
2092
        $num_matching_rows = $this->db_connector->dbFetchOne($sql, $params_2_bind_2_sql, $this);
112✔
2093

2094
        //return null if there wasn't any matching row
2095
        /** @psalm-suppress MixedArrayAccess */
2096
        return (((int)$num_matching_rows['num_rows']) > 0) ? $result : null;
112✔
2097
    }
2098
    
2099
    protected function addTimestampToData(array &$data, ?string $timestamp_col_name, array $table_cols): void {
2100
        
2101
        if(
2102
            ($timestamp_col_name !== null && $timestamp_col_name !== '' )
124✔
2103
            && in_array($timestamp_col_name, $table_cols)
124✔
2104
            && 
2105
            (
2106
                !array_key_exists($timestamp_col_name, $data)
124✔
2107
                || empty($data[$timestamp_col_name])
124✔
2108
            )
2109
        ) {
2110
            //set timestamp to now
2111
            $data[$timestamp_col_name] = date('Y-m-d H:i:s');
72✔
2112
        }
2113
    }
2114
    
2115
    protected function stringifyIfStringable(mixed $col_val, string $col_name='', array $table_cols=[]): mixed {
2116
        
2117
        if(
2118
            ( 
2119
                ($col_name === '' && $table_cols === []) 
172✔
2120
                || in_array($col_name, $table_cols) 
172✔
2121
            )
2122
            && is_object($col_val) && method_exists($col_val, '__toString')
172✔
2123
        ) {
2124
            return $col_val->__toString();
24✔
2125
        }
2126
        
2127
        return $col_val;
172✔
2128
    }
2129
        
2130
    protected function isAcceptableInsertValue(mixed $val): bool {
2131
        
2132
        return is_bool($val) || is_null($val) || is_numeric($val) || is_string($val)
172✔
2133
               || ( is_object($val) && method_exists($val, '__toString') );
172✔
2134
    }
2135
    
2136
    protected function isAcceptableUpdateValue(mixed $val): bool {
2137
        
2138
        return $this->isAcceptableInsertValue($val);
148✔
2139
    }
2140
    
2141
    protected function isAcceptableUpdateQueryValue(mixed $val): bool {
2142
        
2143
        return $this->isAcceptableUpdateValue($val);
140✔
2144
    }
2145
    
2146
    protected function isAcceptableDeleteQueryValue(mixed $val): bool {
2147
        
2148
        return $this->isAcceptableUpdateQueryValue($val);
96✔
2149
    }
2150

2151
    protected function processRowOfDataToInsert(
2152
        array &$data, array &$table_cols, bool &$has_autoinc_pk_col=false
2153
    ): void {
2154

2155
        $this->addTimestampToData($data, $this->created_timestamp_column_name, $table_cols);
92✔
2156
        $this->addTimestampToData($data, $this->updated_timestamp_column_name, $table_cols);
92✔
2157

2158
        // remove non-existent table columns from the data and also
2159
        // converts object values for objects with __toString() to 
2160
        // their string value
2161
        /** @psalm-suppress MixedAssignment */
2162
        foreach ($data as $key => $val) {
92✔
2163

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

2167
            if ( !in_array($key, $table_cols) ) {
92✔
2168

2169
                unset($data[$key]);
24✔
2170
                // not in the table, so no need to check for autoinc
2171
                continue;
24✔
2172

2173
            } elseif( !$this->isAcceptableInsertValue($val) ) {
92✔
2174

2175
                $msg = "ERROR: the value "
16✔
2176
                     . PHP_EOL . var_export($val, true) . PHP_EOL
16✔
2177
                     . " you are trying to insert into `{$this->getTableName()}`."
16✔
2178
                     . "`{$key}` is not acceptable ('".  gettype($val) . "'"
16✔
2179
                     . " supplied). Boolean, NULL, numeric or string value expected."
16✔
2180
                     . PHP_EOL
16✔
2181
                     . "Data supplied to "
16✔
2182
                     . static::class . '::' . __FUNCTION__ . '(...).' 
16✔
2183
                     . " for insertion:"
16✔
2184
                     . PHP_EOL . var_export($data, true) . PHP_EOL
16✔
2185
                     . PHP_EOL;
16✔
2186

2187
                throw new \GDAO\ModelInvalidInsertValueSuppliedException($msg);
16✔
2188
            }
2189

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

2196
                unset($data[$key]);
×
2197

2198
            } // if ( $this->table_cols[$key]['autoinc'] && empty($val) )
2199
        } // foreach ($data as $key => $val)
2200

2201
        /** @psalm-suppress MixedAssignment */
2202
        foreach($this->table_cols as $col_name=>$col_info) {
76✔
2203

2204
            /** @psalm-suppress MixedArrayAccess */
2205
            if ( $col_info['autoinc'] === true && $col_info['primary'] === true ) {
76✔
2206

2207
                if(array_key_exists($col_name, $data)) {
×
2208

2209
                    //no need to add primary key value to the insert 
2210
                    //statement since the column is auto incrementing
2211
                    unset($data[$col_name]);
×
2212

2213
                } // if(array_key_exists($col_name, $data_2_insert))
2214

2215
                $has_autoinc_pk_col = true;
×
2216

2217
            } // if ( $col_info['autoinc'] === true && $col_info['primary'] === true )
2218
        } // foreach($this->table_cols as $col_name=>$col_info)
2219
    }
2220
    
2221
    protected function updateInsertDataArrayWithTheNewlyInsertedRecordFromDB(
2222
        array &$data_2_insert, array $table_cols
2223
    ): void {
2224
        
2225
        if(
2226
            array_key_exists($this->getPrimaryCol(), $data_2_insert)
60✔
2227
            && !empty($data_2_insert[$this->getPrimaryCol()])
60✔
2228
        ) {
2229
            $record = $this->fetchOneRecord(
8✔
2230
                        $this->getSelect()
8✔
2231
                             ->where(
8✔
2232
                                " {$this->getPrimaryCol()} = :{$this->getPrimaryCol()} ",
8✔
2233
                                [ $this->getPrimaryCol() => $data_2_insert[$this->getPrimaryCol()]]
8✔
2234
                             )
8✔
2235
                     );
8✔
2236
            $data_2_insert = ($record instanceof \GDAO\Model\RecordInterface) ? $record->getData() :  $data_2_insert;
8✔
2237
            
2238
        } else {
2239

2240
            // we don't have the primary key.
2241
            // Do a select using all the fields.
2242
            // If only one record is returned, we have found
2243
            // the record we just inserted, else we return $data_2_insert as is 
2244

2245
            $select = $this->getSelect();
60✔
2246

2247
            /** @psalm-suppress MixedAssignment */
2248
            foreach ($data_2_insert as $col => $val) {
60✔
2249

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

2253
                if(is_string($processed_val) || is_numeric($processed_val)) {
60✔
2254

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

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

2259
                    $select->where(" {$col} IS NULL ");
8✔
2260
                } // if(is_string($processed_val) || is_numeric($processed_val))
2261
            } // foreach ($data_2_insert as $col => $val)
2262

2263
            $matching_rows = $this->fetchRowsIntoArray($select);
60✔
2264

2265
            if(count($matching_rows) === 1) {
60✔
2266

2267
                /** @psalm-suppress MixedAssignment */
2268
                $data_2_insert = array_pop($matching_rows);
60✔
2269
            }
2270
        }
2271
    }
2272

2273
    /**
2274
     * {@inheritDoc}
2275
     */
2276
    #[\Override]
2277
    public function insert(array $data_2_insert = []): bool|array {
2278
        
2279
        $result = false;
68✔
2280

2281
        if ( $data_2_insert !== [] ) {
68✔
2282

2283
            $table_cols = $this->getTableColNames();
68✔
2284
            $has_autoinc_pkey_col=false;
68✔
2285

2286
            $this->processRowOfDataToInsert(
68✔
2287
                $data_2_insert, $table_cols, $has_autoinc_pkey_col
68✔
2288
            );
68✔
2289

2290
            // Do we still have anything left to save after removing items
2291
            // in the array that do not map to actual db table columns
2292
            /**
2293
             * @psalm-suppress RedundantCondition
2294
             * @psalm-suppress TypeDoesNotContainType
2295
             */
2296
            if( (is_countable($data_2_insert) ? count($data_2_insert) : 0) > 0 ) {
60✔
2297

2298
                //Insert statement
2299
                $insrt_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newInsert();
60✔
2300
                $insrt_qry_obj->into($this->getTableName())->cols($data_2_insert);
60✔
2301

2302
                $insrt_qry_sql = $insrt_qry_obj->__toString();
60✔
2303
                $insrt_qry_params = $insrt_qry_obj->getBindValues();
60✔
2304

2305
                if( $this->db_connector->runQuery($insrt_qry_sql, $insrt_qry_params, $this)->pdo_statement_execute_result ) {
60✔
2306

2307
                    // insert was successful, we are now going to try to 
2308
                    // fetch the inserted record from the db to get and 
2309
                    // return the db representation of the data
2310
                    if($has_autoinc_pkey_col) {
60✔
2311

2312
                        /** @psalm-suppress MixedAssignment */
2313
                        $last_insert_sequence_name = 
×
2314
                            $insrt_qry_obj->getLastInsertIdName($this->getPrimaryCol());
×
2315

2316
                        $pk_val_4_new_record = 
×
2317
                            $this->getPDO()->lastInsertId(is_string($last_insert_sequence_name) ? $last_insert_sequence_name : null);
×
2318

2319
                        // Add retrieved primary key value 
2320
                        // or null (if primary key value is empty) 
2321
                        // to the data to be returned.
2322
                        $data_2_insert[$this->primary_col] = 
×
2323
                            empty($pk_val_4_new_record) ? null : $pk_val_4_new_record;
×
2324

2325
                        $this->updateInsertDataArrayWithTheNewlyInsertedRecordFromDB(
×
2326
                            $data_2_insert, $table_cols
×
2327
                        );
×
2328

2329
                    } else {
2330

2331
                        $this->updateInsertDataArrayWithTheNewlyInsertedRecordFromDB(
60✔
2332
                            $data_2_insert, $table_cols
60✔
2333
                        );
60✔
2334

2335
                    } // if($has_autoinc_pkey_col)
2336

2337
                    //insert was successful
2338
                    $result = $data_2_insert;
60✔
2339

2340
                } // if( $this->db_connector->executeQuery($insrt_qry_sql, $insrt_qry_params) )
2341
            } // if(count($data_2_insert) > 0 ) 
2342
        } // if ( $data_2_insert !== [] )
2343
        
2344
        return $result;
60✔
2345
    }
2346

2347
    /**
2348
     * {@inheritDoc}
2349
     */
2350
    #[\Override]
2351
    public function insertMany(array $rows_of_data_2_insert = []): bool {
2352

2353
        $result = false;
36✔
2354

2355
        if ($rows_of_data_2_insert !== []) {
36✔
2356

2357
            $table_cols = $this->getTableColNames();
36✔
2358

2359
            foreach (array_keys($rows_of_data_2_insert) as $key) {
36✔
2360

2361
                if( !is_array($rows_of_data_2_insert[$key]) ) {
36✔
2362

2363
                    $item_type = gettype($rows_of_data_2_insert[$key]);
8✔
2364

2365
                    $msg = "ERROR: " . static::class . '::' . __FUNCTION__ . '(...)' 
8✔
2366
                         . " expects you to supply an array of arrays."
8✔
2367
                         . " One of the items in the array supplied is not an array."
8✔
2368
                         . PHP_EOL . " Item below of type `{$item_type}` is not an array: "
8✔
2369
                         . PHP_EOL . var_export($rows_of_data_2_insert[$key], true) 
8✔
2370
                         . PHP_EOL . PHP_EOL . "Data supplied to "
8✔
2371
                         . static::class . '::' . __FUNCTION__ . '(...).' 
8✔
2372
                         . " for insertion into the db table `{$this->getTableName()}`:"
8✔
2373
                         . PHP_EOL . var_export($rows_of_data_2_insert, true) . PHP_EOL
8✔
2374
                         . PHP_EOL;
8✔
2375

2376
                    throw new \GDAO\ModelInvalidInsertValueSuppliedException($msg);
8✔
2377
                }
2378

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

2381
                /** 
2382
                 * @psalm-suppress TypeDoesNotContainType
2383
                 * @psalm-suppress RedundantCondition
2384
                 */
2385
                if((is_countable($rows_of_data_2_insert[$key]) ? count($rows_of_data_2_insert[$key]) : 0) === 0) {
20✔
2386

2387
                    // all the keys in the curent row of data aren't valid
2388
                    // db table columns, remove the row of data from the 
2389
                    // data to be inserted into the DB.
2390
                    unset($rows_of_data_2_insert[$key]);
8✔
2391

2392
                } // if(count($rows_of_data_2_insert[$key]) === 0)
2393

2394
            } // foreach ($rows_of_data_2_insert as $key=>$row_2_insert)
2395

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

2399
                //Insert statement
2400
                $insrt_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newInsert();
20✔
2401

2402
                //Batch all the data into one insert query.
2403
                $insrt_qry_obj->into($this->getTableName())->addRows($rows_of_data_2_insert);           
20✔
2404
                $insrt_qry_sql = $insrt_qry_obj->__toString();
20✔
2405
                $insrt_qry_params = $insrt_qry_obj->getBindValues();
20✔
2406

2407
                $result = $this->db_connector
20✔
2408
                               ->runQuery($insrt_qry_sql, $insrt_qry_params, $this)
20✔
2409
                               ->pdo_statement_execute_result;
20✔
2410

2411
            } // if(count($rows_of_data_2_insert) > 0)
2412
        } // if ($rows_of_data_2_insert !== [])
2413

2414
        return $result;
20✔
2415
    }
2416
    
2417
    protected function throwExceptionForInvalidUpdateQueryArg(mixed $val, array $cols_n_vals): never {
2418

2419
        $msg = "ERROR: the value "
16✔
2420
             . PHP_EOL . var_export($val, true) . PHP_EOL
16✔
2421
             . " you are trying to use to bulid the where clause for updating the table `{$this->getTableName()}`"
16✔
2422
             . " is not acceptable ('".  gettype($val) . "'"
16✔
2423
             . " supplied). Boolean, NULL, numeric or string value expected."
16✔
2424
             . PHP_EOL
16✔
2425
             . "Data supplied to "
16✔
2426
             . static::class . '::' . __FUNCTION__ . '(...).' 
16✔
2427
             . " for buiding the where clause for the update:"
16✔
2428
             . PHP_EOL . var_export($cols_n_vals, true) . PHP_EOL
16✔
2429
             . PHP_EOL;
16✔
2430

2431
        throw new \LeanOrm\Exceptions\InvalidArgumentException($msg);
16✔
2432
    }
2433
    
2434
    /**
2435
     * {@inheritDoc}
2436
     * @psalm-suppress RedundantCondition
2437
     */
2438
    #[\Override]
2439
    public function updateMatchingDbTableRows(
2440
        array $col_names_n_values_2_save = [],
2441
        array $col_names_n_values_2_match = []
2442
    ): static {
2443
        $num_initial_match_items = count($col_names_n_values_2_match);
52✔
2444

2445
        if ($col_names_n_values_2_save !== []) {
52✔
2446

2447
            $table_cols = $this->getTableColNames();
52✔
2448
            $pkey_col_name = $this->getPrimaryCol();
52✔
2449
            $this->addTimestampToData(
52✔
2450
                $col_names_n_values_2_save, $this->updated_timestamp_column_name, $table_cols
52✔
2451
            );
52✔
2452

2453
            if(array_key_exists($pkey_col_name, $col_names_n_values_2_save)) {
52✔
2454

2455
                //don't update the primary key
2456
                unset($col_names_n_values_2_save[$pkey_col_name]);
28✔
2457
            }
2458

2459
            // remove non-existent table columns from the data
2460
            // and check that existent table columns have values of  
2461
            // the right data type: ie. Boolean, NULL, Number or String.
2462
            // Convert objects with a __toString to their string value.
2463
            /** @psalm-suppress MixedAssignment */
2464
            foreach ($col_names_n_values_2_save as $key => $val) {
52✔
2465

2466
                /** @psalm-suppress MixedAssignment */
2467
                $col_names_n_values_2_save[$key] = 
52✔
2468
                    $this->stringifyIfStringable($val, ''.$key, $table_cols);
52✔
2469

2470
                if ( !in_array($key, $table_cols) ) {
52✔
2471

2472
                    unset($col_names_n_values_2_save[$key]);
8✔
2473

2474
                } else if( !$this->isAcceptableUpdateValue($val) ) {
52✔
2475

2476
                    $msg = "ERROR: the value "
8✔
2477
                         . PHP_EOL . var_export($val, true) . PHP_EOL
8✔
2478
                         . " you are trying to update `{$this->getTableName()}`.`{$key}`."
8✔
2479
                         . "{$key} with is not acceptable ('".  gettype($val) . "'"
8✔
2480
                         . " supplied). Boolean, NULL, numeric or string value expected."
8✔
2481
                         . PHP_EOL
8✔
2482
                         . "Data supplied to "
8✔
2483
                         . static::class . '::' . __FUNCTION__ . '(...).' 
8✔
2484
                         . " for update:"
8✔
2485
                         . PHP_EOL . var_export($col_names_n_values_2_save, true) . PHP_EOL
8✔
2486
                         . PHP_EOL;
8✔
2487

2488
                    throw new \GDAO\ModelInvalidUpdateValueSuppliedException($msg);
8✔
2489
                } // if ( !in_array($key, $table_cols) )
2490
            } // foreach ($col_names_n_vals_2_save as $key => $val)
2491

2492
            // After filtering out non-table columns, if we have any table
2493
            // columns data left, we can do the update
2494
            if($col_names_n_values_2_save !== []) {
44✔
2495

2496
                //update statement
2497
                $update_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newUpdate();
44✔
2498
                $update_qry_obj->table($this->getTableName());
44✔
2499
                $update_qry_obj->cols($col_names_n_values_2_save);
44✔
2500

2501
                /** @psalm-suppress MixedAssignment */
2502
                foreach ($col_names_n_values_2_match as $colname => $colval) {
44✔
2503

2504
                    if(!in_array($colname, $table_cols)) {
44✔
2505

2506
                        //non-existent table column
2507
                        unset($col_names_n_values_2_match[$colname]);
8✔
2508
                        continue;
8✔
2509
                    }
2510

2511
                    if (is_array($colval)) {
44✔
2512

2513
                        if($colval !== []) {
16✔
2514

2515
                            /** @psalm-suppress MixedAssignment */
2516
                            foreach ($colval as $key=>$val) {
16✔
2517

2518
                                if(!$this->isAcceptableUpdateQueryValue($val)) {
16✔
2519

2520
                                    $this->throwExceptionForInvalidUpdateQueryArg(
8✔
2521
                                            $val, $col_names_n_values_2_match
8✔
2522
                                        );
8✔
2523
                                }
2524

2525
                                /** @psalm-suppress MixedAssignment */
2526
                                $colval[$key] = $this->stringifyIfStringable($val);
16✔
2527
                            }
2528

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

2531
                        } // if($colval !== []) 
2532

2533
                    } else {
2534

2535
                        if(!$this->isAcceptableUpdateQueryValue($colval)) {
44✔
2536

2537
                            $this->throwExceptionForInvalidUpdateQueryArg(
8✔
2538
                                    $colval, $col_names_n_values_2_match
8✔
2539
                                );
8✔
2540
                        }
2541

2542
                        if(is_null($colval)) {
44✔
2543

2544
                            $update_qry_obj->where(
8✔
2545
                                " {$colname} IS NULL "
8✔
2546
                            );
8✔
2547

2548
                        } else {
2549

2550
                            $update_qry_obj->where(
44✔
2551
                                " {$colname} = :{$colname}_for_where ",  // add the _for_where suffix to deconflict where bind value, 
44✔
2552
                                                                         // from the set bind value when a column in the where clause
2553
                                                                         // is also being set and the value we are setting it to is 
2554
                                                                         // different from the value we are using for the same column 
2555
                                                                         // in the where clause
2556
                                ["{$colname}_for_where" => $this->stringifyIfStringable($colval)] 
44✔
2557
                            );
44✔
2558
                        }
2559

2560
                    } // if (is_array($colval))
2561
                } // foreach ($col_names_n_vals_2_match as $colname => $colval)
2562

2563
                // If after filtering out non existing cols in $col_names_n_vals_2_match
2564
                // if there is still data left in $col_names_n_vals_2_match, then
2565
                // finish building the update query and do the update
2566
                if( 
2567
                    $col_names_n_values_2_match !== [] // there are valid db table cols in here
28✔
2568
                    || 
2569
                    (
2570
                        $num_initial_match_items === 0
28✔
2571
                        && $col_names_n_values_2_match === [] // empty match array passed, we are updating all rows
28✔
2572
                    )
2573
                ) {
2574
                    $updt_qry = $update_qry_obj->__toString();
28✔
2575
                    $updt_qry_params = $update_qry_obj->getBindValues();
28✔
2576
                    $this->db_connector->runQuery($updt_qry, $updt_qry_params, $this);
28✔
2577
                }
2578

2579
            } // if($col_names_n_vals_2_save !== [])
2580
        } // if ($col_names_n_vals_2_save !== [])
2581

2582
        return $this;
28✔
2583
    }
2584

2585
    /**
2586
     * {@inheritDoc}
2587
     * @psalm-suppress UnusedVariable
2588
     */
2589
    #[\Override]
2590
    public function updateSpecifiedRecord(\GDAO\Model\RecordInterface $record): static {
2591
        
2592
        if( $record instanceof \LeanOrm\Model\ReadOnlyRecord ) {
44✔
2593

2594
            $msg = "ERROR: Can't save a ReadOnlyRecord to the database in " 
8✔
2595
                 . static::class . '::' . __FUNCTION__ . '(...).'
8✔
2596
                 . PHP_EOL .'Unupdated record' . var_export($record, true) . PHP_EOL;
8✔
2597
            throw new \LeanOrm\Exceptions\CantSaveReadOnlyRecordException($msg);
8✔
2598
        }
2599
        
2600
        if( $record->getModel()->getTableName() !== $this->getTableName() ) {
36✔
2601
            
2602
            $msg = "ERROR: Can't update a record (an instance of `%s`) belonging to the database table `%s` " 
8✔
2603
                . "using a Model instance of `%s` belonging to the database table `%s` in " 
8✔
2604
                 . static::class . '::' . __FUNCTION__ . '(...).'
8✔
2605
                 . PHP_EOL .'Unupdated record: ' . PHP_EOL . var_export($record, true) . PHP_EOL; 
8✔
2606
            throw new \GDAO\ModelInvalidUpdateValueSuppliedException(
8✔
2607
                sprintf(
8✔
2608
                    $msg, $record::class, $record->getModel()->getTableName(),
8✔
2609
                    static::class, $this->getTableName()
8✔
2610
                )
8✔
2611
            );
8✔
2612
        }
2613

2614
        /** @psalm-suppress MixedAssignment */
2615
        $pri_key_val = $record->getPrimaryVal();
28✔
2616
        
2617
        /** @psalm-suppress MixedOperand */
2618
        if( 
2619
            count($record) > 0  // There is data in the record
28✔
2620
            && !$record->isNew() // This is not a new record that wasn't fetched from the DB
28✔
2621
            && !Utils::isEmptyString(''.$pri_key_val) // Record has a primary key value
28✔
2622
            && $record->isChanged() // The data in the record has changed from the state it was when initially fetched from DB
28✔
2623
        ) {
2624
            $cols_n_vals_2_match = [$record->getPrimaryCol()=>$pri_key_val];
28✔
2625

2626
            if($this->getUpdatedTimestampColumnName() !== null) {
28✔
2627

2628
                // Record has changed value(s) & must definitely be updated.
2629
                // Set the value of the $this->getUpdatedTimestampColumnName()
2630
                // field to an empty string, force updateMatchingDbTableRows
2631
                // to add a new updated timestamp value during the update.
2632
                $record->{$this->getUpdatedTimestampColumnName()} = '';
8✔
2633
            }
2634

2635
            $data_2_save = $record->getData();
28✔
2636
            $this->updateMatchingDbTableRows(
28✔
2637
                $data_2_save, 
28✔
2638
                $cols_n_vals_2_match
28✔
2639
            );
28✔
2640

2641
            // update the record with the new updated copy from the DB
2642
            // which will contain the new updated timestamp value.
2643
            $record = $this->fetchOneRecord(
28✔
2644
                        $this->getSelect()
28✔
2645
                             ->where(
28✔
2646
                                    " {$record->getPrimaryCol()} = :{$record->getPrimaryCol()} ", 
28✔
2647
                                    [$record->getPrimaryCol() => $record->getPrimaryVal()]
28✔
2648
                                )
28✔
2649
                    );
28✔
2650
        } // if( count($record) > 0 && !$record->isNew()........
2651

2652
        return $this;
28✔
2653
    }
2654

2655
    /**
2656
     * @psalm-suppress RedundantConditionGivenDocblockType
2657
     */
2658
    protected function addWhereInAndOrIsNullToQuery(
2659
        string $colname, array &$colvals, \Aura\SqlQuery\Common\WhereInterface $qry_obj
2660
    ): void {
2661
        
2662
        if($colvals !== []) { // make sure it's a non-empty array
128✔
2663
            
2664
            // if there are one or more null values in the array,
2665
            // we need to unset them and add an
2666
            //      OR $colname IS NULL 
2667
            // clause to the query
2668
            $unique_colvals = array_unique($colvals);
128✔
2669
            $keys_for_null_vals = array_keys($unique_colvals, null, true);
128✔
2670

2671
            foreach($keys_for_null_vals as $key_for_null_val) {
128✔
2672

2673
                // remove the null vals from $colval
2674
                unset($unique_colvals[$key_for_null_val]);
8✔
2675
            }
2676

2677
            if(
2678
                $keys_for_null_vals !== [] && $unique_colvals !== []
128✔
2679
            ) {
2680
                // Some values in the array are null and some are non-null
2681
                // Generate WHERE COL IN () OR COL IS NULL
2682
                $qry_obj->where(
8✔
2683
                    " {$colname} IN (:bar) ",
8✔
2684
                    [ 'bar' => $unique_colvals ]
8✔
2685
                )->orWhere(" {$colname} IS NULL ");
8✔
2686

2687
            } elseif (
2688
                $keys_for_null_vals !== []
128✔
2689
                && $unique_colvals === []
128✔
2690
            ) {
2691
                // All values in the array are null
2692
                // Only generate WHERE COL IS NULL
2693
                $qry_obj->where(" {$colname} IS NULL ");
8✔
2694

2695
            } else { // ($keys_for_null_vals === [] && $unique_colvals !== []) // no nulls found
2696
                
2697
                ////////////////////////////////////////////////////////////////
2698
                // NOTE: ($keys_for_null_vals === [] && $unique_colvals === [])  
2699
                // is impossible because we started with if($colvals !== [])
2700
                ////////////////////////////////////////////////////////////////
2701

2702
                // All values in the array are non-null
2703
                // Only generate WHERE COL IN ()
2704
                $qry_obj->where(       
128✔
2705
                    " {$colname} IN (:bar) ",
128✔
2706
                    [ 'bar' => $unique_colvals ]
128✔
2707
                );
128✔
2708
            }
2709
        }
2710
    }
2711
    
2712
    /**
2713
     * @return array{
2714
     *              database_server_info: mixed, 
2715
     *              driver_name: mixed, 
2716
     *              pdo_client_version: mixed, 
2717
     *              database_server_version: mixed, 
2718
     *              connection_status: mixed, 
2719
     *              connection_is_persistent: mixed
2720
     *          }
2721
     * 
2722
     * @psalm-suppress PossiblyUnusedMethod
2723
     */
2724
    public function getCurrentConnectionInfo(): array {
2725

2726
        $pdo_obj = $this->getPDO();
8✔
2727
        $attributes = [
8✔
2728
            'database_server_info' => 'SERVER_INFO',
8✔
2729
            'driver_name' => 'DRIVER_NAME',
8✔
2730
            'pdo_client_version' => 'CLIENT_VERSION',
8✔
2731
            'database_server_version' => 'SERVER_VERSION',
8✔
2732
            'connection_status' => 'CONNECTION_STATUS',
8✔
2733
            'connection_is_persistent' => 'PERSISTENT',
8✔
2734
        ];
8✔
2735

2736
        foreach ($attributes as $key => $value) {
8✔
2737
            
2738
            try {
2739
                /**
2740
                 * @psalm-suppress MixedAssignment
2741
                 * @psalm-suppress MixedArgument
2742
                 */
2743
                $attributes[ $key ] = $pdo_obj->getAttribute(constant(\PDO::class .'::ATTR_' . $value));
8✔
2744
                
2745
            } catch (\PDOException) {
8✔
2746
                
2747
                $attributes[ $key ] = 'Unsupported attribute for the current PDO driver';
8✔
2748
                continue;
8✔
2749
            }
2750

2751
            if( $value === 'PERSISTENT' ) {
8✔
2752

2753
                $attributes[ $key ] = var_export($attributes[ $key ], true);
8✔
2754
            }
2755
        }
2756

2757
        return $attributes;
8✔
2758
    }
2759

2760
    /**
2761
     * @psalm-suppress PossiblyUnusedMethod
2762
     */
2763
    public function clearQueryLog(): static {
2764

2765
        DBConnector::clearQueryLog($this->getDbConnector()->getConnectionName(), $this);
48✔
2766
        
2767
        return $this;
48✔
2768
    }
2769

2770
    /**
2771
     * @return mixed[]
2772
     * @psalm-suppress PossiblyUnusedMethod
2773
     */
2774
    public function getQueryLog(): array {
2775

2776
        return DBConnector::getQueryLog(
16✔
2777
            $this->getDbConnector()->getConnectionName(),
16✔
2778
            $this
16✔
2779
        );
16✔
2780
    }
2781

2782
    /**
2783
     * To get the log for all existing instances of this class & its subclasses
2784
     * that have the same connection name registered in DBConnector,
2785
     * call this method with only the first arg which can be gotten from
2786
     * $this->getDbConnector()->getConnectionName() ($this should be substituted 
2787
     * with an instance of this class)
2788
     * 
2789
     * To get the log for instances of a specific class (this class or a
2790
     * particular sub-class of this class), you must call this method with 
2791
     * an instance of the class whose log you want to get.
2792
     * 
2793
     * @return mixed[]
2794
     * @psalm-suppress PossiblyUnusedMethod
2795
     */
2796
    public static function getQueryLogForAllInstances(
2797
        string $dbconnector_connection_name,
2798
        ?\GDAO\Model $obj=null
2799
    ): array {
2800

2801
        return DBConnector::getQueryLog($dbconnector_connection_name, $obj);
52✔
2802
    }
2803
    
2804
    /**
2805
     * To clear the log for all existing instances of this class & its subclasses
2806
     * that have the same connection name registered in DBConnector,
2807
     * call this method with only the first arg which can be gotten from
2808
     * $this->getDbConnector()->getConnectionName() ($this should be substituted 
2809
     * with an instance of this class)
2810
     * 
2811
     * To clear the log for instances of a specific class (this class or a
2812
     * particular sub-class of this class), you must call this method with 
2813
     * an instance of the class whose log you want to clear.
2814
     * 
2815
     * @psalm-suppress PossiblyUnusedMethod
2816
     */
2817
    public static function clearQueryLogForAllInstances(
2818
        string $dbconnector_connection_name,
2819
        ?\GDAO\Model $obj=null
2820
    ): void {
2821
        
2822
        DBConnector::clearQueryLog($dbconnector_connection_name, $obj);
72✔
2823
    }
2824

2825
    /**
2826
     * @psalm-suppress PossiblyUnusedMethod
2827
     */
2828
    public function hasAnyDataInTable(): bool {
2829

2830
        return $this->fetchOneRecord() instanceof \GDAO\Model\RecordInterface;
8✔
2831
    }
2832

2833
    ////////////////////////////////
2834
    // Metadata retreiving methods.
2835
    ////////////////////////////////
2836

2837
    /**
2838
     * @psalm-suppress PossiblyUnusedMethod
2839
     */
2840
    public function getFieldDefaultValue(string $fieldName): mixed {
2841

2842
        $fieldDefaultValue= null;
16✔
2843
        $fieldMetaData = $this->getFieldMetadata($fieldName);
16✔
2844

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

2847
            /** @psalm-suppress MixedAssignment */
2848
            $fieldDefaultValue = $fieldMetaData['default'];
16✔
2849
        }
2850

2851
        return $fieldDefaultValue;
16✔
2852
    }
2853

2854
    /**
2855
     * @psalm-suppress PossiblyUnusedMethod
2856
     */
2857
    public function getFieldLength(string $fieldName): ?int {
2858

2859
        $fieldLength= null;
16✔
2860
        $fieldMetaData = $this->getFieldMetadata($fieldName);
16✔
2861

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

2864
            /** @psalm-suppress MixedAssignment */
2865
            $fieldLength = $fieldMetaData['size'] ?? $fieldLength;
16✔
2866
        }
2867

2868
        /** @psalm-suppress MixedReturnStatement */
2869
        return $fieldLength;
16✔
2870
    }
2871
    
2872
    public function getFieldMetadata(string $fieldName): array {
2873

2874
        $fieldMetaData = [];
144✔
2875

2876
        if(
2877
            $this->isAnActualTableCol($fieldName)
144✔
2878
            && \array_key_exists($fieldName, $this->getTableCols())
144✔
2879
        ) {
2880
            /** @psalm-suppress MixedAssignment */
2881
            $fieldMetaData = $this->getTableCols()[$fieldName]; 
144✔
2882
        }
2883

2884
        /** @psalm-suppress MixedReturnStatement */
2885
        return $fieldMetaData;
144✔
2886
    }
2887

2888
    public function isAnActualTableCol(string $columnName): bool {
2889

2890
        return \in_array($columnName, $this->getTableColNames());
152✔
2891
    }
2892

2893
    public function isAutoIncrementingField(string $fieldName): bool {
2894

2895
        $isAutoIncing= false;
80✔
2896
        $fieldMetaData = $this->getFieldMetadata($fieldName);
80✔
2897

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

2900
            $isAutoIncing = (bool)$fieldMetaData['autoinc'];
80✔
2901
        }
2902

2903
        return $isAutoIncing;
80✔
2904
    }
2905

2906
    /**
2907
     * @psalm-suppress PossiblyUnusedMethod
2908
     */
2909
    public function isPrimaryKeyField(string $fieldName): bool {
2910

2911
        return $this->getPrimaryCol() === $fieldName;
16✔
2912
    }
2913

2914
    /**
2915
     * @psalm-suppress PossiblyUnusedMethod
2916
     */
2917
    public function isRequiredField(string $fieldName): bool {
2918

2919
        $isRequired= false;
16✔
2920
        $fieldMetaData = $this->getFieldMetadata($fieldName);
16✔
2921

2922
        if(
2923
            $fieldMetaData !== []
16✔
2924
            && \array_key_exists('notnull', $fieldMetaData)
16✔
2925
        ) {
2926
            $isRequired = (bool)$fieldMetaData['notnull'];
16✔
2927
        }
2928

2929
        return $isRequired;
16✔
2930
    }
2931

2932
    ///////////////////////////////////////
2933
    // Methods for defining relationships
2934
    ///////////////////////////////////////
2935
    
2936
    /**
2937
     * @psalm-suppress PossiblyUnusedMethod
2938
     */
2939
    public function hasOne(
2940
        string $relation_name,  // name of the relation, via which the related data
2941
                                // will be accessed as a property with the same name 
2942
                                // on record objects for this model class or array key 
2943
                                // for the related data when data is fetched into arrays 
2944
                                // via this model
2945
        
2946
        string $relationship_col_in_my_table,
2947
        
2948
        string $relationship_col_in_foreign_table,
2949
        
2950
        string $foreign_table_name='',   // If empty, the value set in the $table_name property
2951
                                         // of the model class specified in $foreign_models_class_name
2952
                                         // will be used if $foreign_models_class_name !== '' 
2953
                                         // and the value of the $table_name property is not ''
2954
        
2955
        string $primary_key_col_in_foreign_table='',   // If empty, the value set in the $primary_col property
2956
                                                       // of the model class specified in $foreign_models_class_name
2957
                                                       // will be used if $foreign_models_class_name !== '' 
2958
                                                       // and the value of the $primary_col property is not ''
2959
        
2960
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
2961
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
2962
        
2963
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
2964
                                                       // or the value of the $record_class_name property
2965
                                                       // in the class specfied in $foreign_models_class_name
2966
        
2967
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
2968
                                                            // or the value of the $collection_class_name property
2969
                                                            // in the class specfied in $foreign_models_class_name
2970
        
2971
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
2972
    ): static {
2973
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
312✔
2974
        $this->setRelationshipDefinitionDefaultsIfNeeded (
304✔
2975
            $foreign_models_class_name,
304✔
2976
            $foreign_table_name,
304✔
2977
            $primary_key_col_in_foreign_table,
304✔
2978
            $foreign_models_record_class_name,
304✔
2979
            $foreign_models_collection_class_name
304✔
2980
        );
304✔
2981
        
2982
        if($foreign_models_collection_class_name !== '') {
264✔
2983
            
2984
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
264✔
2985
        }
2986
        
2987
        if($foreign_models_record_class_name !== '') {
256✔
2988
            
2989
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
256✔
2990
        }
2991
        
2992
        $this->validateTableName($foreign_table_name);
248✔
2993
        
2994
        $this->validateThatTableHasColumn($this->getTableName(), $relationship_col_in_my_table);
240✔
2995
        $this->validateThatTableHasColumn($foreign_table_name, $relationship_col_in_foreign_table);
232✔
2996
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
224✔
2997
        
2998
        $this->relations[$relation_name] = [];
216✔
2999
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_ONE;
216✔
3000
        $this->relations[$relation_name]['foreign_key_col_in_my_table'] = $relationship_col_in_my_table;
216✔
3001
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
216✔
3002
        $this->relations[$relation_name]['foreign_key_col_in_foreign_table'] = $relationship_col_in_foreign_table;
216✔
3003
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
216✔
3004

3005
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
216✔
3006
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
216✔
3007
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
216✔
3008

3009
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
216✔
3010

3011
        return $this;
216✔
3012
    }
3013
    
3014
    /**
3015
     * @psalm-suppress PossiblyUnusedMethod
3016
     */
3017
    public function belongsTo(
3018
        string $relation_name,  // name of the relation, via which the related data
3019
                                // will be accessed as a property with the same name 
3020
                                // on record objects for this model class or array key 
3021
                                // for the related data when data is fetched into arrays 
3022
                                // via this model
3023
        
3024
        string $relationship_col_in_my_table,
3025
            
3026
        string $relationship_col_in_foreign_table,
3027
        
3028
        string $foreign_table_name = '', // If empty, the value set in the $table_name property
3029
                                         // of the model class specified in $foreign_models_class_name
3030
                                         // will be used if $foreign_models_class_name !== '' 
3031
                                         // and the value of the $table_name property is not ''
3032
        
3033
        string $primary_key_col_in_foreign_table = '', // If empty, the value set in the $primary_col property
3034
                                                       // of the model class specified in $foreign_models_class_name
3035
                                                       // will be used if $foreign_models_class_name !== '' 
3036
                                                       // and the value of the $primary_col property is not ''
3037
        
3038
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
3039
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
3040
        
3041
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
3042
                                                       // or the value of the $record_class_name property
3043
                                                       // in the class specfied in $foreign_models_class_name
3044
        
3045
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
3046
                                                            // or the value of the $collection_class_name property
3047
                                                            // in the class specfied in $foreign_models_class_name
3048
        
3049
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
3050
    ): static {
3051
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
376✔
3052
        $this->setRelationshipDefinitionDefaultsIfNeeded (
368✔
3053
            $foreign_models_class_name,
368✔
3054
            $foreign_table_name,
368✔
3055
            $primary_key_col_in_foreign_table,
368✔
3056
            $foreign_models_record_class_name,
368✔
3057
            $foreign_models_collection_class_name
368✔
3058
        );
368✔
3059
        
3060
        if($foreign_models_collection_class_name !== '') {
328✔
3061
        
3062
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
328✔
3063
        }
3064
        
3065
        if($foreign_models_record_class_name !== '') {
320✔
3066
            
3067
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
320✔
3068
        }
3069
        
3070
        $this->validateTableName($foreign_table_name);
312✔
3071
        
3072
        $this->validateThatTableHasColumn($this->getTableName(), $relationship_col_in_my_table);
304✔
3073
        $this->validateThatTableHasColumn($foreign_table_name, $relationship_col_in_foreign_table);
296✔
3074
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
288✔
3075
        
3076
        $this->relations[$relation_name] = [];
280✔
3077
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_BELONGS_TO;
280✔
3078
        $this->relations[$relation_name]['foreign_key_col_in_my_table'] = $relationship_col_in_my_table;
280✔
3079
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
280✔
3080
        $this->relations[$relation_name]['foreign_key_col_in_foreign_table'] = $relationship_col_in_foreign_table;
280✔
3081
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
280✔
3082

3083
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
280✔
3084
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
280✔
3085
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
280✔
3086

3087
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
280✔
3088

3089
        return $this;
280✔
3090
    }
3091
    
3092
    /**
3093
     * @psalm-suppress PossiblyUnusedMethod
3094
     * 
3095
     */
3096
    public function hasMany(
3097
        string $relation_name,  // name of the relation, via which the related data
3098
                                // will be accessed as a property with the same name 
3099
                                // on record objects for this model class or array key 
3100
                                // for the related data when data is fetched into arrays 
3101
                                // via this model
3102
        
3103
        string $relationship_col_in_my_table,
3104
        
3105
        string $relationship_col_in_foreign_table,
3106
        
3107
        string $foreign_table_name='',   // If empty, the value set in the $table_name property
3108
                                         // of the model class specified in $foreign_models_class_name
3109
                                         // will be used if $foreign_models_class_name !== '' 
3110
                                         // and the value of the $table_name property is not ''
3111
        
3112
        string $primary_key_col_in_foreign_table='',   // If empty, the value set in the $primary_col property
3113
                                                       // of the model class specified in $foreign_models_class_name
3114
                                                       // will be used if $foreign_models_class_name !== '' 
3115
                                                       // and the value of the $primary_col property is not ''
3116
        
3117
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
3118
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
3119
        
3120
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
3121
                                                       // or the value of the $record_class_name property
3122
                                                       // in the class specfied in $foreign_models_class_name
3123
        
3124
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
3125
                                                            // or the value of the $collection_class_name property
3126
                                                            // in the class specfied in $foreign_models_class_name
3127
        
3128
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
3129
    ): static {
3130
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
1,504✔
3131
        $this->setRelationshipDefinitionDefaultsIfNeeded (
1,504✔
3132
            $foreign_models_class_name,
1,504✔
3133
            $foreign_table_name,
1,504✔
3134
            $primary_key_col_in_foreign_table,
1,504✔
3135
            $foreign_models_record_class_name,
1,504✔
3136
            $foreign_models_collection_class_name
1,504✔
3137
        );
1,504✔
3138
        
3139
        if($foreign_models_collection_class_name !== '') {
1,504✔
3140
            
3141
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
1,504✔
3142
        }
3143
            
3144
        if($foreign_models_record_class_name !== '') {
1,504✔
3145
            
3146
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
1,504✔
3147
        }
3148
            
3149
        
3150
        $this->validateTableName($foreign_table_name);
1,504✔
3151
        
3152
        $this->validateThatTableHasColumn($this->getTableName(), $relationship_col_in_my_table);
1,504✔
3153
        $this->validateThatTableHasColumn($foreign_table_name, $relationship_col_in_foreign_table);
1,504✔
3154
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
1,504✔
3155
        
3156
        $this->relations[$relation_name] = [];
1,504✔
3157
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_MANY;
1,504✔
3158
        $this->relations[$relation_name]['foreign_key_col_in_my_table'] = $relationship_col_in_my_table;
1,504✔
3159
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
1,504✔
3160
        $this->relations[$relation_name]['foreign_key_col_in_foreign_table'] = $relationship_col_in_foreign_table;
1,504✔
3161
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
1,504✔
3162

3163
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
1,504✔
3164
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
1,504✔
3165
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
1,504✔
3166

3167
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
1,504✔
3168

3169
        return $this;
1,504✔
3170
    }
3171

3172
    /**
3173
     * @psalm-suppress PossiblyUnusedMethod
3174
     */
3175
    public function hasManyThrough(
3176
        string $relation_name,  // name of the relation, via which the related data
3177
                                // will be accessed as a property with the same name 
3178
                                // on record objects for this model class or array key 
3179
                                // for the related data when data is fetched into arrays 
3180
                                // via this model
3181
        
3182
        string $col_in_my_table_linked_to_join_table,
3183
        string $join_table,
3184
        string $col_in_join_table_linked_to_my_table,
3185
        string $col_in_join_table_linked_to_foreign_table,
3186
        string $col_in_foreign_table_linked_to_join_table,
3187
        
3188
        string $foreign_table_name = '', // If empty, the value set in the $table_name property
3189
                                         // of the model class specified in $foreign_models_class_name
3190
                                         // will be used if $foreign_models_class_name !== '' 
3191
                                         // and the value of the $table_name property is not ''
3192
            
3193
        string $primary_key_col_in_foreign_table = '', // If empty, the value set in the $primary_col property
3194
                                                       // of the model class specified in $foreign_models_class_name
3195
                                                       // will be used if $foreign_models_class_name !== '' 
3196
                                                       // and the value of the $primary_col property is not ''
3197
            
3198
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
3199
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
3200
        
3201
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
3202
                                                       // or the value of the $record_class_name property
3203
                                                       // in the class specfied in $foreign_models_class_name
3204
        
3205
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
3206
                                                            // or the value of the $collection_class_name property
3207
                                                            // in the class specfied in $foreign_models_class_name
3208
        
3209
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
3210
    ): static {
3211
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
352✔
3212
        $this->setRelationshipDefinitionDefaultsIfNeeded (
344✔
3213
            $foreign_models_class_name,
344✔
3214
            $foreign_table_name,
344✔
3215
            $primary_key_col_in_foreign_table,
344✔
3216
            $foreign_models_record_class_name,
344✔
3217
            $foreign_models_collection_class_name
344✔
3218
        );
344✔
3219
        
3220
        if ($foreign_models_collection_class_name !== '') {
304✔
3221
            
3222
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
304✔
3223
        }
3224
        
3225
        if ($foreign_models_record_class_name !== '') {
296✔
3226
            
3227
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
296✔
3228
        }
3229
        
3230
        $this->validateTableName($foreign_table_name);
288✔
3231
        $this->validateTableName($join_table);
280✔
3232
        
3233
        $this->validateThatTableHasColumn($this->getTableName(), $col_in_my_table_linked_to_join_table);
272✔
3234
        $this->validateThatTableHasColumn($join_table, $col_in_join_table_linked_to_my_table);
264✔
3235
        $this->validateThatTableHasColumn($join_table, $col_in_join_table_linked_to_foreign_table);
256✔
3236
        $this->validateThatTableHasColumn($foreign_table_name, $col_in_foreign_table_linked_to_join_table);
248✔
3237
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
240✔
3238
        
3239
        $this->relations[$relation_name] = [];
232✔
3240
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_MANY_THROUGH;
232✔
3241
        $this->relations[$relation_name]['col_in_my_table_linked_to_join_table'] = $col_in_my_table_linked_to_join_table;
232✔
3242
        $this->relations[$relation_name]['join_table'] = $join_table;
232✔
3243
        $this->relations[$relation_name]['col_in_join_table_linked_to_my_table'] = $col_in_join_table_linked_to_my_table;
232✔
3244
        $this->relations[$relation_name]['col_in_join_table_linked_to_foreign_table'] = $col_in_join_table_linked_to_foreign_table;
232✔
3245
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
232✔
3246
        $this->relations[$relation_name]['col_in_foreign_table_linked_to_join_table'] = $col_in_foreign_table_linked_to_join_table;
232✔
3247
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
232✔
3248

3249
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
232✔
3250
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
232✔
3251
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
232✔
3252

3253
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
232✔
3254

3255
        return $this;
232✔
3256
    }
3257
    
3258
    /**
3259
     * @psalm-suppress MixedAssignment
3260
     */
3261
    protected function setRelationshipDefinitionDefaultsIfNeeded (
3262
        string &$foreign_models_class_name,
3263
        string &$foreign_table_name,
3264
        string &$primary_key_col_in_foreign_table,
3265
        string &$foreign_models_record_class_name,
3266
        string &$foreign_models_collection_class_name,
3267
    ): void {
3268
        
3269
        if($foreign_models_class_name !== '' && $foreign_models_class_name !== \LeanOrm\Model::class) {
1,504✔
3270
           
3271
            $this->validateRelatedModelClassName($foreign_models_class_name);
1,504✔
3272
            
3273
            /**
3274
             * @psalm-suppress ArgumentTypeCoercion
3275
             */
3276
            $ref_class = new \ReflectionClass($foreign_models_class_name);
1,504✔
3277
            
3278
            if($foreign_table_name === '') {
1,504✔
3279
                
3280
                // Try to set it using the default value of the table_name property 
3281
                // in the specified foreign model class $foreign_models_class_name
3282
                $reflected_foreign_table_name = 
64✔
3283
                        $ref_class->getProperty('table_name')->getDefaultValue();
64✔
3284

3285
                if($reflected_foreign_table_name === '' || $reflected_foreign_table_name === null) {
64✔
3286
                    
3287
                    $msg = "ERROR: '\$foreign_table_name' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3288
                         . $foreign_models_class_name . "'"
32✔
3289
                         . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3290

3291
                    // we can't use Reflection to figure out this table name
3292
                    throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3293
                }
3294
                
3295
                $foreign_table_name = $reflected_foreign_table_name;
32✔
3296
            }
3297
            
3298
            if($primary_key_col_in_foreign_table === '') {
1,504✔
3299

3300
                // Try to set it using the default value of the primary_col property 
3301
                // in the specified foreign model class $foreign_models_class_name
3302
                $reflected_foreign_primary_key_col = 
64✔
3303
                        $ref_class->getProperty('primary_col')->getDefaultValue();
64✔
3304

3305
                if($reflected_foreign_primary_key_col === '' || $reflected_foreign_primary_key_col === null) {
64✔
3306

3307
                    $msg = "ERROR: '\$primary_key_col_in_foreign_table' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3308
                         . $foreign_models_class_name . "'"
32✔
3309
                         . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3310

3311
                    // we can't use Reflection to figure out this primary key column name
3312
                    throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3313
                }
3314

3315
                // set it to the reflected value
3316
                $primary_key_col_in_foreign_table = $reflected_foreign_primary_key_col;
32✔
3317
            }
3318
            
3319
            $reflected_record_class_name = $ref_class->getProperty('record_class_name')->getDefaultValue();
1,504✔
3320
            
3321
            if(
3322
                $foreign_models_record_class_name === ''
1,504✔
3323
                && $reflected_record_class_name !== ''
1,504✔
3324
                && $reflected_record_class_name !== null
1,504✔
3325
            ) {
3326
                $foreign_models_record_class_name = $reflected_record_class_name;
32✔
3327
            }
3328
            
3329
            $reflected_collection_class_name = $ref_class->getProperty('collection_class_name')->getDefaultValue();
1,504✔
3330
            
3331
            if(
3332
                $foreign_models_collection_class_name === ''
1,504✔
3333
                && $reflected_collection_class_name !== ''
1,504✔
3334
                && $reflected_collection_class_name !== null
1,504✔
3335
            ) {
3336
                $foreign_models_collection_class_name = $reflected_collection_class_name;
32✔
3337
            }
3338
            
3339
        } else {
3340
            
3341
            $foreign_models_class_name = \LeanOrm\Model::class;
248✔
3342
            
3343
            if($foreign_table_name === '') {
248✔
3344
                
3345
                $msg = "ERROR: '\$foreign_table_name' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3346
                     . \LeanOrm\Model::class . "'"
32✔
3347
                     . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3348
                
3349
                // we can't use Reflection to figure out this table name
3350
                // because \LeanOrm\Model->table_name has a default value of ''
3351
                throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3352
            }
3353
            
3354
            // $foreign_table_name !== '' if we got this far
3355
            if($primary_key_col_in_foreign_table === '') {
216✔
3356

3357
                $msg = "ERROR: '\$primary_key_col_in_foreign_table' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3358
                     . \LeanOrm\Model::class . "'"
32✔
3359
                     . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3360

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

3365
            }
3366
        } // if($foreign_models_class_name !== '' && $foreign_models_class_name !== \LeanOrm\Model::class)
3367
        
3368
        if($foreign_models_record_class_name === '') {
1,504✔
3369
            
3370
            $foreign_models_record_class_name = \LeanOrm\Model\Record::class;
184✔
3371
        }
3372
        
3373
        if($foreign_models_collection_class_name === '') {
1,504✔
3374
            
3375
            $foreign_models_collection_class_name = \LeanOrm\Model\Collection::class;
184✔
3376
        }
3377
    }
3378
    
3379
    protected function checkThatRelationNameIsNotAnActualColumnName(string $relationName): void {
3380

3381
        $tableCols = $this->getTableColNames();
1,504✔
3382

3383

3384
        $tableColsLowerCase = array_map(strtolower(...), $tableCols);
1,504✔
3385

3386
        if( in_array(strtolower($relationName), $tableColsLowerCase) ) {
1,504✔
3387

3388
            //Error trying to add a relation whose name collides with an actual
3389
            //name of a column in the db table associated with this model.
3390
            $msg = sprintf("ERROR: You cannont add a relationship with the name '%s' ", $relationName)
32✔
3391
                 . " to the Model (".static::class."). The database table "
32✔
3392
                 . sprintf(" '%s' associated with the ", $this->getTableName())
32✔
3393
                 . " model (".static::class.") already contains"
32✔
3394
                 . " a column with the same name."
32✔
3395
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
3396
                 . PHP_EOL;
32✔
3397

3398
            throw new \GDAO\Model\RecordRelationWithSameNameAsAnExistingDBTableColumnNameException($msg);
32✔
3399
        } // if( in_array(strtolower($relationName), $tableColsLowerCase) ) 
3400
    }
3401
    
3402
    /**
3403
     * @psalm-suppress PossiblyUnusedReturnValue
3404
     */
3405
    protected function validateTableName(string $table_name): bool {
3406
        
3407
        if(!$this->tableExistsInDB($table_name)) {
1,504✔
3408
            
3409
            //throw exception
3410
            $msg = "ERROR: The specified table `{$table_name}` does not exist in the DB."
40✔
3411
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
40✔
3412
                 . PHP_EOL;
40✔
3413
            throw new \LeanOrm\Exceptions\BadModelTableNameException($msg);
40✔
3414
        } // if(!$this->tableExistsInDB($table_name))
3415
        
3416
        return true;
1,504✔
3417
    }
3418
    
3419
    /**
3420
     * @psalm-suppress PossiblyUnusedReturnValue
3421
     */
3422
    protected function validateThatTableHasColumn(string $table_name, string $column_name): bool {
3423
        
3424
        if(!$this->columnExistsInDbTable($table_name, $column_name)) {
1,504✔
3425

3426
            //throw exception
3427
            $msg = "ERROR: The specified table `{$table_name}` in the DB"
112✔
3428
                 . " does not contain the specified column `{$column_name}`."
112✔
3429
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
112✔
3430
                 . PHP_EOL;
112✔
3431
            throw new \LeanOrm\Exceptions\BadModelColumnNameException($msg);
112✔
3432
        } // if(!$this->columnExistsInDbTable($table_name, $column_name))
3433
        
3434
        return true;
1,504✔
3435
    }
3436
}
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