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

rotexsoft / leanorm / 23554692447

25 Mar 2026 05:21PM UTC coverage: 96.132% (+0.09%) from 96.042%
23554692447

push

github

rotimi
Documentation update in progress

1690 of 1758 relevant lines covered (96.13%)

188.48 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,484✔
48
    }
49

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

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

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

68
        $this->can_log_queries = true;
92✔
69
        $this->db_connector->enableQueryLogging();
92✔
70

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

77
        $this->can_log_queries = false;
76✔
78
        $this->db_connector->disableQueryLogging();
76✔
79
        
80
        return $this;
76✔
81
    }
82

83
    protected ?LoggerInterface $logger = null;
84

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

109
        try {
110

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

113
        } catch (\GDAO\ModelPrimaryColNameNotSetDuringConstructionException $e) {
48✔
114

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

124
        if( $pdo_driver_opts !== [] ) {
1,484✔
125

126
            DBConnector::configure(DBConnector::CONFIG_KEY_DRIVER_OPTS, $pdo_driver_opts, $dsn);//use $dsn as connection name in 3rd parameter
8✔
127
        }
128

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

135
        ////////////////////////////////////////////////////////
136
        //Get and Set Table Schema Meta Data if Not Already Set
137
        ////////////////////////////////////////////////////////
138
        if ( $this->table_cols === [] ) {
1,484✔
139

140
            /** @var array $dsn_n_tname_to_schema_def_map */
141
            static $dsn_n_tname_to_schema_def_map;
1,484✔
142

143
            if( !$dsn_n_tname_to_schema_def_map ) {
1,484✔
144

145
                $dsn_n_tname_to_schema_def_map = [];
4✔
146
            }
147

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

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

154
            } else {
155

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

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

165
                $this->table_cols = [];
36✔
166
                $schema_definitions = $this->fetchTableColsFromDB($this->getTableName());
36✔
167

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

171
            } // if( array_key_exists($dsn.$this->getTableName(), $dsn_n_tname_to_schema_def_map) )
172

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

183
            /** @psalm-suppress MixedAssignment */
184
            foreach( $schema_definitions as $colname => $metadata_obj ) {
1,484✔
185

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

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

207
                    //this is a primary column
208
                    /** @psalm-suppress MixedArgument */
209
                    $this->setPrimaryCol($metadata_obj->name);
20✔
210

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

216
            if($this->getPrimaryCol() === '') {
136✔
217

218
                /** @psalm-suppress MixedAssignment */
219
                foreach ($this->table_cols as $colname => $col_metadata) {
12✔
220

221
                    /** @psalm-suppress MixedArrayAccess */
222
                    if($col_metadata['primary']) {
12✔
223

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

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

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

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

247
            $pdo_obj = $this->getPDO();
1,484✔
248

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

252
            /** @psalm-suppress MixedArgument */
253
            if(version_compare($sqlite_version_number, '3.7.10', '<=')) {
1,484✔
254

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

261
                throw new \LeanOrm\Exceptions\UnsupportedPdoServerVersionException($msg);
×
262

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

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

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

346
        $selectObj = (new QueryFactory($this->getPdoDriverName()))->newSelect();
352✔
347

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

350
        /** @psalm-suppress LessSpecificReturnStatement */
351
        return $selectObj;
352✔
352
    }
353

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

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

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

374

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

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

396
        if( $table_name === '' ) {
352✔
397

398
            $table_name = $this->getTableName();
352✔
399
        }
400

401
        if($initiallyNull || !$select_obj->hasCols()) {
352✔
402

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

409
        return $select_obj;
352✔
410
    }
411

412
    /**
413
     * @return mixed[]
414
     * @psalm-suppress PossiblyUnusedMethod
415
     */
416
    public function getDefaultColVals(): array {
417

418
        $default_colvals = [];
8✔
419

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

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

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

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

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

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

463
        return $this;
160✔
464
    }
465

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

473
        if ($relations_to_include !== []) {
296✔
474

475
            /** @psalm-suppress MixedAssignment */
476
            foreach ($relations_to_include as $potential_relation_name => $potential_array_of_relations_to_include_next) {
104✔
477

478
                $current_relation_name = $potential_relation_name;
104✔
479

480
                if (\is_numeric($potential_relation_name)) {
104✔
481

482
                    // $potential_array_of_relations_to_include_next must be a string containing the name of a relation to fetch
483
                    $current_relation_name = (string) $potential_array_of_relations_to_include_next; // value has to be relation name
104✔
484

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

488
                } elseif (
489
                    \is_array($potential_array_of_relations_to_include_next)
32✔
490
                    && \count($potential_array_of_relations_to_include_next) > 0
32✔
491
                ) {
492

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

510
                    /** @psalm-suppress MixedArgumentTypeCoercion */
511
                    $model->loadRelationshipData($current_relation_name, $fetched_data, true, $wrap_records_in_collection);
32✔
512

513
                    $model_obj_for_recursive_call = null;
32✔
514
                    $fetched_data_for_recursive_call = [];
32✔
515

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

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

541
                        } else {
542

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

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

561
                                if ($model_obj_for_recursive_call === null) {
4✔
562

563
                                    /** @psalm-suppress MixedMethodCall */
564
                                    $model_obj_for_recursive_call = $current_record->{$current_relation_name}->getModel();
4✔
565
                                }
566

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

575
                                    $fetched_data_for_recursive_call[] = $current_related_record;
32✔
576

577
                                    if ($model_obj_for_recursive_call === null) {
32✔
578

579
                                        /** @psalm-suppress MixedMethodCall */
580
                                        $model_obj_for_recursive_call = $current_related_record->getModel();
32✔
581

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

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

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

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

638
        $parent_collection_class_name = \GDAO\Model\CollectionInterface::class;
1,484✔
639

640
        if( !is_subclass_of($collection_class_name, $parent_collection_class_name, true) ) {
1,484✔
641

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

650
            throw new \LeanOrm\Exceptions\BadCollectionClassNameForFetchingRelatedDataException($msg);
32✔
651
        }
652

653
        return true;
1,484✔
654
    }
655

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

663
        if( !is_subclass_of($record_class_name, $parent_record_class_name, true)  ) {
1,484✔
664

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

673
            throw new \LeanOrm\Exceptions\BadRecordClassNameForFetchingRelatedDataException($msg);
32✔
674
        }
675

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

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

698
                -- OR
699

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

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

718
                $fkey_val_to_related_data_keys = [];
96✔
719

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

725
                    /** @psalm-suppress MixedArrayOffset */
726
                    $curr_fkey_val = $related_datum[$fkey_col_in_foreign_table];
96✔
727

728
                    /** @psalm-suppress MixedArgument */
729
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
96✔
730

731
                        /** @psalm-suppress MixedArrayOffset */
732
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
96✔
733
                    }
734

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

740
                } // foreach ($related_data as $curr_key => $related_datum)
741

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

747
                    $matching_related_rows = [];
96✔
748

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

755
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
96✔
756

757
                            $matching_related_rows[] = $related_data[$related_data_key];
96✔
758
                        }
759
                    }
760

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

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

776
                    } else {
777

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

788
                ////////////////////////////////////////////////////////////////
789
                // End: Stitch the related data to the approriate parent records
790
                ////////////////////////////////////////////////////////////////
791

792
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
96✔
793

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

800
                ///////////////////////////////////////////////
801
                //stitch the related data to the parent record
802
                ///////////////////////////////////////////////
803
                $parent_data->setRelatedData($rel_name, $related_data);
96✔
804

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

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

829
            /** @psalm-suppress MixedAssignment */
830
            $fkey_col_in_foreign_table = 
120✔
831
                Utils::arrayGet($rel_info, 'col_in_foreign_table_linked_to_join_table');
120✔
832

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

837
            /** @psalm-suppress MixedAssignment */
838
            $pri_key_col_in_foreign_models_table = 
120✔
839
                Utils::arrayGet($rel_info, 'primary_key_col_in_foreign_table');
120✔
840

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

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

849
            /** @psalm-suppress MixedAssignment */
850
            $col_in_join_table_linked_to_my_models_table = 
120✔
851
                Utils::arrayGet($rel_info, 'col_in_join_table_linked_to_my_table');
120✔
852

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

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

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

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

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

877
            if($foreign_models_collection_class_name !== '') {
120✔
878

879
                /** @psalm-suppress MixedArgument */
880
                $foreign_model_obj->setCollectionClassName($foreign_models_collection_class_name);
120✔
881
            }
882

883
            if($foreign_models_record_class_name !== '') {
120✔
884

885
                /** @psalm-suppress MixedArgument */
886
                $foreign_model_obj->setRecordClassName($foreign_models_record_class_name);
120✔
887
            }
888

889
            $query_obj = $foreign_model_obj->getSelect();
120✔
890

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

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

899
            if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
120✔
900

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

906
            } else {
907

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

914
                if( $col_vals !== [] ) {
88✔
915

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

924
            if(\is_callable($sql_query_modifier)) {
120✔
925

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

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

935
            /** @psalm-suppress MixedAssignment */
936
            $sql_2_get_related_data = $query_obj->__toString();
120✔
937

938
/*
939
-- SQL For Fetching the Related Data
940

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

948
OR
949

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

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

970
                $fkey_val_to_related_data_keys = [];
88✔
971

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

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

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

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

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

992
                } // foreach ($related_data as $curr_key => $related_datum)
993

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

999
                    $matching_related_rows = [];
88✔
1000

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

1007
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
88✔
1008

1009
                            $matching_related_rows[] = $related_data[$related_data_key];
88✔
1010
                        }
1011
                    }
1012

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

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

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

1027
                    } else {
1028

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

1038
                } // foreach( $parent_data as $p_rec_key => $parent_record )
1039

1040
                ////////////////////////////////////////////////////////////////
1041
                // End: Stitch the related data to the approriate parent records
1042
                ////////////////////////////////////////////////////////////////
1043

1044
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
32✔
1045

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

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

1074
/*
1075
-- SQL For Fetching the Related Data
1076

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

1082
OR
1083

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

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

1098
                $fkey_val_to_related_data_keys = [];
96✔
1099

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

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

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

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

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

1120
                } // foreach ($related_data as $curr_key => $related_datum)
1121

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

1127
                    $matching_related_rows = [];
96✔
1128

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

1135
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
96✔
1136

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

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

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

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

1168
                    } else {
1169

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

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

1188
                ////////////////////////////////////////////////////////////////
1189
                // End: Stitch the related data to the approriate parent records
1190
                ////////////////////////////////////////////////////////////////
1191

1192
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
72✔
1193

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1301
        $query_obj = $foreign_model_obj->getSelect();
160✔
1302

1303
        if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
160✔
1304

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

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

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

1327
        if(\is_callable($sql_query_modifier)) {
160✔
1328

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

1336
        if($query_obj->hasCols() === false){
160✔
1337

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

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

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

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

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

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

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

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

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

1462
        return $col_vals;
104✔
1463
    }
1464

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

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

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

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

1546
            if( $use_collections ) {
20✔
1547

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

1552
            } else {
1553

1554
                if( $use_records ) {
8✔
1555

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1677
        return $results;
48✔
1678
    }
1679

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

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

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

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

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

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

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

1711
            $results_keyed_by_pk = [];
68✔
1712

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

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

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

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

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

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

1736
        return $results;
224✔
1737
    }
1738

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

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

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

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

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

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

1795
        return $results;
92✔
1796
    }
1797

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

1801
        //return pdo object associated with the current dsn
1802
        return DBConnector::getDb($this->dsn); 
1,484✔
1803
    }
1804

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

1811
        $result = 0;
96✔
1812

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

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

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

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

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

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

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

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

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

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

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

1850
                } else {
1851

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

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

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

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

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

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

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

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

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

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

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

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

1906
        $succesfully_deleted = null;
88✔
1907

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2075
        $query_obj_4_num_matching_rows = clone $query_obj;
112✔
2076

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2216
                $has_autoinc_pk_col = true;
×
2217

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2330
                    } else {
2331

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

2336
                    } // if($has_autoinc_pkey_col)
2337

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

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

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

2354
        $result = false;
36✔
2355

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2534
                    } else {
2535

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

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

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

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

2549
                        } else {
2550

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

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

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

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

2583
        return $this;
28✔
2584
    }
2585

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

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

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

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

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

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

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

2653
        return $this;
28✔
2654
    }
2655

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

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

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

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

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

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

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

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

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

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

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

2758
        return $attributes;
8✔
2759
    }
2760

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

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

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

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

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

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

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

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

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

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

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

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

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

2852
        return $fieldDefaultValue;
16✔
2853
    }
2854

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

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

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

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

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

2875
        $fieldMetaData = [];
144✔
2876

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

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

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

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

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

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

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

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

2904
        return $isAutoIncing;
80✔
2905
    }
2906

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

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

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

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

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

2930
        return $isRequired;
16✔
2931
    }
2932

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

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

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

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

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

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

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

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

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

3170
        return $this;
1,484✔
3171
    }
3172

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3382
        $tableCols = $this->getTableColNames();
1,484✔
3383

3384

3385
        $tableColsLowerCase = array_map(strtolower(...), $tableCols);
1,484✔
3386

3387
        if( in_array(strtolower($relationName), $tableColsLowerCase) ) {
1,484✔
3388

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

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

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