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

rotexsoft / leanorm / 23415383344

22 Mar 2026 11:43PM UTC coverage: 92.954% (-2.7%) from 95.613%
23415383344

push

github

rotexdegba
Minimum PHP 8.2 refactoring, updated db versions for multi-db testing

1504 of 1618 relevant lines covered (92.95%)

167.07 hits per line

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

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

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

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

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

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

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

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

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

55
    // Query Logging related properties
56
    protected bool $can_log_queries = false;
57

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

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

63
        $this->can_log_queries = true;
56✔
64
        return $this;
56✔
65
    }
66
    
67
    /** @psalm-suppress PossiblyUnusedMethod */
68
    public function disableQueryLogging(): static {
69

70
        $this->can_log_queries = false;
40✔
71
        return $this;
40✔
72
    }
73

74
    /**
75
     * @var array<string, array>
76
     */
77
    protected array $query_log = [];
78

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

84
    protected ?LoggerInterface $logger = null;
85

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

108
        try {
109

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

112
        } catch (\GDAO\ModelPrimaryColNameNotSetDuringConstructionException $e) {
44✔
113

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

123
        if( $pdo_driver_opts !== [] ) {
1,316✔
124

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

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

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

139
            /** @var array $dsn_n_tname_to_schema_def_map */
140
            static $dsn_n_tname_to_schema_def_map;
1,316✔
141

142
            if( !$dsn_n_tname_to_schema_def_map ) {
1,316✔
143

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

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

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

153
            } else {
154

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

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

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

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

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

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

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

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

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

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

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

215
            if($this->getPrimaryCol() === '') {
96✔
216

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

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

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

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

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

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

246
            $pdo_obj = $this->getPDO();
1,316✔
247

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

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

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

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

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

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

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

345
        $selectObj = (new QueryFactory($this->getPdoDriverName()))->newSelect();
304✔
346

347
        $selectObj->from($this->getTableName());
304✔
348

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

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

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

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

373

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

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

395
        if( $table_name === '' ) {
304✔
396

397
            $table_name = $this->getTableName();
304✔
398
        }
399

400
        if($initiallyNull || !$select_obj->hasCols()) {
304✔
401

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

408
        return $select_obj;
304✔
409
    }
410

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

417
        $default_colvals = [];
8✔
418

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

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

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

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

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

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

462
        return $this;
120✔
463
    }
464

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

472
        if ($relations_to_include !== []) {
256✔
473

474
            foreach ($relations_to_include as $potential_relation_name => $potential_array_of_relations_to_include_next) {
72✔
475

476
                $current_relation_name = $potential_relation_name;
72✔
477

478
                if (\is_numeric($potential_relation_name)) {
72✔
479

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

483
                    // no need for recursion here, just load data for current relation name
484
                    $model->loadRelationshipData($current_relation_name, $fetched_data, true, $wrap_records_in_collection);
72✔
485
                    
486
                } elseif (
487
                    \is_array($potential_array_of_relations_to_include_next)
×
488
                    && \count($potential_array_of_relations_to_include_next) > 0
×
489
                ) {
490

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

508
                    $model->loadRelationshipData($current_relation_name, $fetched_data, true, $wrap_records_in_collection);
×
509

510
                    $model_obj_for_recursive_call = null;
×
511
                    $fetched_data_for_recursive_call = [];
×
512

513
                    if(
514
                        $fetched_data instanceof \GDAO\Model\RecordInterface
×
515
                        && 
516
                        (
517
                            ($fetched_data->{$current_relation_name} instanceof \GDAO\Model\RecordInterface)
×
518
                            ||
×
519
                            (
×
520
                                (
×
521
                                    $fetched_data->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface
×
522
                                    || \is_array($fetched_data->{$current_relation_name})
×
523
                                )
×
524
                                && count($fetched_data->{$current_relation_name}) > 0
×
525
                            )
×
526
                        )
527
                    ) {
528
                        $fetched_data_for_recursive_call = $fetched_data->{$current_relation_name};
×
529

530
                        if (
531
                            $fetched_data->{$current_relation_name} instanceof \GDAO\Model\RecordInterface 
×
532
                            || $fetched_data->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface
×
533
                        ) {
534
                            $model_obj_for_recursive_call = $fetched_data->{$current_relation_name}->getModel();
×
535
                            
536
                        } else {
537
                            
538
                            // $fetched_data->{$current_relation_name} is an array
539
                            $model_obj_for_recursive_call = reset($fetched_data->{$current_relation_name})->getModel();
×
540
                        }
541
                    } elseif(
542
                        (
543
                            $fetched_data instanceof \GDAO\Model\CollectionInterface
×
544
                            || \is_array($fetched_data)
×
545
                        )
546
                        && count($fetched_data) > 0
×
547
                    ) {
548
                        foreach ($fetched_data as $current_record) {
×
549

550
                            if (
551
                                ($current_record->{$current_relation_name} instanceof \GDAO\Model\RecordInterface)
×
552
                            ) {
553
                                $fetched_data_for_recursive_call[] = $current_record->{$current_relation_name};
×
554

555
                                if ($model_obj_for_recursive_call === null) {
×
556

557
                                    $model_obj_for_recursive_call = $current_record->{$current_relation_name}->getModel();
×
558
                                }
559
                                
560
                            } elseif (
561
                                (
562
                                    $current_record->{$current_relation_name} instanceof \GDAO\Model\CollectionInterface 
×
563
                                    || \is_array($current_record->{$current_relation_name})
×
564
                                ) && count($current_record->{$current_relation_name}) > 0
×
565
                            ) {
566
                                foreach ($current_record->{$current_relation_name} as $current_related_record) {
×
567

568
                                    $fetched_data_for_recursive_call[] = $current_related_record;
×
569

570
                                    if ($model_obj_for_recursive_call === null) {
×
571

572
                                        $model_obj_for_recursive_call = $current_related_record->getModel();
×
573
                                        
574
                                    } // if ($model_obj_for_recursive_call === null)
575
                                } // foreach ($current_record->{$current_relation_name} as $current_related_record)
576
                            } // if( ($current_record->{$current_relation_name} instanceof \GDAO\Model\RecordInterface) ) ......
577
                        } // foreach ($fetched_data as $current_record)
578
                    } // if( $fetched_data instanceof \GDAO\Model\RecordInterface .....
579

580
                    if (
581
                        $model_obj_for_recursive_call !== null
×
582
                        && $fetched_data_for_recursive_call !== []
×
583
                        && $model_obj_for_recursive_call instanceof Model
×
584
                    ) {
585
                        // do recursive call
586
                        $this->recursivelyStitchRelatedData(
×
587
                                $model_obj_for_recursive_call,
×
588
                                $potential_array_of_relations_to_include_next,
×
589
                                $fetched_data_for_recursive_call,
×
590
                                $wrap_records_in_collection
×
591
                        );
×
592
                    } // if ($model_obj_for_recursive_call !== null && $fetched_data_for_recursive_call !== [])
593
                } // if (\is_numeric($potential_relation_name)) ..elseif (\is_array($potential_array_of_next_level_relation_names))
594
            } // foreach ($relations_to_include as $potential_relation_name => $potential_array_of_next_level_relation_names)
595
        } // if ($relations_to_include !== []) 
596
    }
597

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

618
            throw new \LeanOrm\Exceptions\BadModelClassNameForFetchingRelatedDataException($msg);
32✔
619
        }
620
        
621
        return true;
1,316✔
622
    }
623
    
624
    /**
625
     * @psalm-suppress PossiblyUnusedReturnValue
626
     */
627
    protected function validateRelatedCollectionClassName(string $collection_class_name): bool {
628

629
        $parent_collection_class_name = \GDAO\Model\CollectionInterface::class;
1,316✔
630

631
        if( !is_subclass_of($collection_class_name, $parent_collection_class_name, true) ) {
1,316✔
632

633
            //throw exception
634
            $msg = "ERROR: '{$collection_class_name}' is not a subclass of "
32✔
635
                 . "'{$parent_collection_class_name}'. A collection class name specified"
32✔
636
                 . " for fetching related data must be the name of a class that"
32✔
637
                 . " is a sub-class of '{$parent_collection_class_name}'"
32✔
638
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
639
                 . PHP_EOL;
32✔
640

641
            throw new \LeanOrm\Exceptions\BadCollectionClassNameForFetchingRelatedDataException($msg);
32✔
642
        }
643

644
        return true;
1,316✔
645
    }
646

647
    /**
648
     * @psalm-suppress PossiblyUnusedReturnValue
649
     */
650
    protected function validateRelatedRecordClassName(string $record_class_name): bool {
651
        
652
        $parent_record_class_name = \GDAO\Model\RecordInterface::class;
1,316✔
653

654
        if( !is_subclass_of($record_class_name, $parent_record_class_name, true)  ) {
1,316✔
655

656
            //throw exception
657
            $msg = "ERROR: '{$record_class_name}' is not a subclass of "
32✔
658
                 . "'{$parent_record_class_name}'. A record class name specified for"
32✔
659
                 . " fetching related data must be the name of a class that"
32✔
660
                 . " is a sub-class of '{$parent_record_class_name}'"
32✔
661
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
662
                 . PHP_EOL;
32✔
663

664
            throw new \LeanOrm\Exceptions\BadRecordClassNameForFetchingRelatedDataException($msg);
32✔
665
        }
666

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

684
                -- $parent_data is a collection or array of records    
685
                SELECT {$foreign_table_name}.*
686
                  FROM {$foreign_table_name}
687
                 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} IN ( $fkey_col_in_my_table column values in $parent_data )
688

689
                -- OR
690

691
                -- $parent_data is a single record
692
                SELECT {$foreign_table_name}.*
693
                  FROM {$foreign_table_name}
694
                 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} = {$parent_data->$fkey_col_in_my_table}
695
            */
696
            [
120✔
697
                $fkey_col_in_foreign_table, $fkey_col_in_my_table, 
120✔
698
                $foreign_model_obj, $related_data
120✔
699
            ] = $this->getBelongsToOrHasOneOrHasManyData($rel_name, $parent_data);
120✔
700

701
            if ( 
702
                $parent_data instanceof \GDAO\Model\CollectionInterface
120✔
703
                || is_array($parent_data)
120✔
704
            ) {
705
                ///////////////////////////////////////////////////////////
706
                // Stitch the related data to the approriate parent records
707
                ///////////////////////////////////////////////////////////
708

709
                $fkey_val_to_related_data_keys = [];
64✔
710

711
                // Generate a map of 
712
                //      foreign key value => [keys of related rows in $related_data]
713
                /** @psalm-suppress MixedAssignment */
714
                foreach ($related_data as $curr_key => $related_datum) {
64✔
715

716
                    /** @psalm-suppress MixedArrayOffset */
717
                    $curr_fkey_val = $related_datum[$fkey_col_in_foreign_table];
64✔
718

719
                    /** @psalm-suppress MixedArgument */
720
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
64✔
721

722
                        /** @psalm-suppress MixedArrayOffset */
723
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
64✔
724
                    }
725

726
                    // Add current key in $related_data to sub array of keys for the 
727
                    // foreign key value in the current related row $related_datum
728
                    /** @psalm-suppress MixedArrayOffset */
729
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
64✔
730

731
                } // foreach ($related_data as $curr_key => $related_datum)
732

733
                // Now use $fkey_val_to_related_data_keys map to
734
                // look up related rows of data for each parent row of data
735
                /** @psalm-suppress MixedAssignment */
736
                foreach( $parent_data as $p_rec_key => $parent_row ) {
64✔
737

738
                    $matching_related_rows = [];
64✔
739

740
                    /** 
741
                     * @psalm-suppress MixedArgument 
742
                     * @psalm-suppress MixedArrayOffset 
743
                     */
744
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
64✔
745

746
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
64✔
747

748
                            $matching_related_rows[] = $related_data[$related_data_key];
64✔
749
                        }
750
                    }
751

752
                    /** @psalm-suppress MixedArgument */
753
                    $this->wrapRelatedDataInsideRecordsAndCollection(
64✔
754
                        $matching_related_rows, $foreign_model_obj, 
64✔
755
                        $wrap_each_row_in_a_record, $wrap_records_in_collection
64✔
756
                    );
64✔
757

758
                    //set the related data for the current parent row / record
759
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
64✔
760
                        /**
761
                         * @psalm-suppress MixedArrayTypeCoercion
762
                         * @psalm-suppress MixedArrayOffset
763
                         * @psalm-suppress MixedMethodCall
764
                         */
765
                        $parent_data[$p_rec_key]->setRelatedData($rel_name, $matching_related_rows);
48✔
766

767
                    } else {
768

769
                        //the current row must be an array
770
                        /**
771
                         * @psalm-suppress MixedArrayOffset
772
                         * @psalm-suppress MixedArrayAssignment
773
                         * @psalm-suppress InvalidArgument
774
                         */
775
                        $parent_data[$p_rec_key][$rel_name] = $matching_related_rows;
24✔
776
                    }
777
                } // foreach( $parent_data as $p_rec_key => $parent_record )
778

779
                ////////////////////////////////////////////////////////////////
780
                // End: Stitch the related data to the approriate parent records
781
                ////////////////////////////////////////////////////////////////
782

783
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
56✔
784

785
                /** @psalm-suppress MixedArgument */
786
                $this->wrapRelatedDataInsideRecordsAndCollection(
56✔
787
                    $related_data, $foreign_model_obj, 
56✔
788
                    $wrap_each_row_in_a_record, $wrap_records_in_collection
56✔
789
                );
56✔
790

791
                ///////////////////////////////////////////////
792
                //stitch the related data to the parent record
793
                ///////////////////////////////////////////////
794
                $parent_data->setRelatedData($rel_name, $related_data);
56✔
795

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

814
            /** 
815
             * @psalm-suppress MixedAssignment
816
             * @psalm-suppress MixedArgument
817
             */
818
            $foreign_table_name = Utils::arrayGet($rel_info, 'foreign_table');
88✔
819

820
            /** @psalm-suppress MixedAssignment */
821
            $fkey_col_in_foreign_table = 
88✔
822
                Utils::arrayGet($rel_info, 'col_in_foreign_table_linked_to_join_table');
88✔
823

824
            /** @psalm-suppress MixedAssignment */
825
            $foreign_models_class_name = 
88✔
826
                Utils::arrayGet($rel_info, 'foreign_models_class_name', \LeanOrm\Model::class);
88✔
827

828
            /** @psalm-suppress MixedAssignment */
829
            $pri_key_col_in_foreign_models_table = 
88✔
830
                Utils::arrayGet($rel_info, 'primary_key_col_in_foreign_table');
88✔
831

832
            /** @psalm-suppress MixedAssignment */
833
            $fkey_col_in_my_table = 
88✔
834
                    Utils::arrayGet($rel_info, 'col_in_my_table_linked_to_join_table');
88✔
835

836
            //join table params
837
            /** @psalm-suppress MixedAssignment */
838
            $join_table_name = Utils::arrayGet($rel_info, 'join_table');
88✔
839

840
            /** @psalm-suppress MixedAssignment */
841
            $col_in_join_table_linked_to_my_models_table = 
88✔
842
                Utils::arrayGet($rel_info, 'col_in_join_table_linked_to_my_table');
88✔
843

844
            /** @psalm-suppress MixedAssignment */
845
            $col_in_join_table_linked_to_foreign_models_table = 
88✔
846
                Utils::arrayGet($rel_info, 'col_in_join_table_linked_to_foreign_table');
88✔
847

848
            /** @psalm-suppress MixedAssignment */
849
            $sql_query_modifier = 
88✔
850
                Utils::arrayGet($rel_info, 'sql_query_modifier', null);
88✔
851

852
            /** @psalm-suppress MixedArgument */
853
            $foreign_model_obj = 
88✔
854
                $this->createRelatedModelObject(
88✔
855
                    $foreign_models_class_name,
88✔
856
                    $pri_key_col_in_foreign_models_table,
88✔
857
                    $foreign_table_name
88✔
858
                );
88✔
859

860
            /** @psalm-suppress MixedAssignment */
861
            $foreign_models_collection_class_name = 
88✔
862
                Utils::arrayGet($rel_info, 'foreign_models_collection_class_name', '');
88✔
863

864
            /** @psalm-suppress MixedAssignment */
865
            $foreign_models_record_class_name = 
88✔
866
                Utils::arrayGet($rel_info, 'foreign_models_record_class_name', '');
88✔
867

868
            if($foreign_models_collection_class_name !== '') {
88✔
869

870
                /** @psalm-suppress MixedArgument */
871
                $foreign_model_obj->setCollectionClassName($foreign_models_collection_class_name);
88✔
872
            }
873

874
            if($foreign_models_record_class_name !== '') {
88✔
875

876
                /** @psalm-suppress MixedArgument */
877
                $foreign_model_obj->setRecordClassName($foreign_models_record_class_name);
88✔
878
            }
879

880
            $query_obj = $foreign_model_obj->getSelect();
88✔
881

882
            $query_obj->cols( [" {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} ", " {$foreign_table_name}.* "] );
88✔
883

884
            /** @psalm-suppress MixedArgument */
885
            $query_obj->innerJoin(
88✔
886
                            $join_table_name, 
88✔
887
                            " {$join_table_name}.{$col_in_join_table_linked_to_foreign_models_table} = {$foreign_table_name}.{$fkey_col_in_foreign_table} "
88✔
888
                        );
88✔
889

890
            if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
88✔
891

892
                $query_obj->where(
32✔
893
                    " {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} = :leanorm_col_in_join_table_linked_to_my_models_table_val ",
32✔
894
                    ['leanorm_col_in_join_table_linked_to_my_models_table_val' => $parent_data->$fkey_col_in_my_table]
32✔
895
                );
32✔
896

897
            } else {
898

899
                //assume it's a collection or array
900
                /** @psalm-suppress MixedArgument */
901
                $col_vals = $this->getColValsFromArrayOrCollection(
56✔
902
                                $parent_data, $fkey_col_in_my_table
56✔
903
                            );
56✔
904

905
                if( $col_vals !== [] ) {
56✔
906

907
                    $this->addWhereInAndOrIsNullToQuery(
56✔
908
                        "{$join_table_name}.{$col_in_join_table_linked_to_my_models_table}", 
56✔
909
                        $col_vals, 
56✔
910
                        $query_obj
56✔
911
                    );
56✔
912
                }
913
            }
914

915
            if(\is_callable($sql_query_modifier)) {
88✔
916

917
                $sql_query_modifier = Utils::getClosureFromCallable($sql_query_modifier);
16✔
918
                // modify the query object before executing the query
919
                /** @psalm-suppress MixedAssignment */
920
                $query_obj = $sql_query_modifier($query_obj);
16✔
921
            }
922

923
            /** @psalm-suppress MixedAssignment */
924
            $params_2_bind_2_sql = $query_obj->getBindValues();
88✔
925

926
            /** @psalm-suppress MixedAssignment */
927
            $sql_2_get_related_data = $query_obj->__toString();
88✔
928

929
/*
930
-- SQL For Fetching the Related Data
931

932
-- $parent_data is a collection or array of records    
933
SELECT {$foreign_table_name}.*,
934
       {$join_table_name}.{$col_in_join_table_linked_to_my_models_table}
935
  FROM {$foreign_table_name}
936
  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}
937
 WHERE {$join_table_name}.{$col_in_join_table_linked_to_my_models_table} IN ( $fkey_col_in_my_table column values in $parent_data )
938

939
OR
940

941
-- $parent_data is a single record
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} = {$parent_data->$fkey_col_in_my_table}
947
*/
948
            /** @psalm-suppress MixedArgument */
949
            $this->logQuery($sql_2_get_related_data, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
88✔
950

951
            //GRAB DA RELATED DATA
952
            $related_data = 
88✔
953
                $this->db_connector
88✔
954
                     ->dbFetchAll($sql_2_get_related_data, $params_2_bind_2_sql);
88✔
955

956
            if ( 
957
                $parent_data instanceof \GDAO\Model\CollectionInterface
88✔
958
                || is_array($parent_data)
88✔
959
            ) {
960
                ///////////////////////////////////////////////////////////
961
                // Stitch the related data to the approriate parent records
962
                ///////////////////////////////////////////////////////////
963

964
                $fkey_val_to_related_data_keys = [];
56✔
965

966
                // Generate a map of 
967
                //      foreign key value => [keys of related rows in $related_data]
968
                /** @psalm-suppress MixedAssignment */
969
                foreach ($related_data as $curr_key => $related_datum) {
56✔
970

971
                    /** @psalm-suppress MixedArrayOffset */
972
                    $curr_fkey_val = $related_datum[$col_in_join_table_linked_to_my_models_table];
56✔
973

974
                    /** @psalm-suppress MixedArgument */
975
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
56✔
976

977
                        /** @psalm-suppress MixedArrayOffset */
978
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
56✔
979
                    }
980

981
                    // Add current key in $related_data to sub array of keys for the 
982
                    // foreign key value in the current related row $related_datum
983
                    /** @psalm-suppress MixedArrayOffset */
984
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
56✔
985

986
                } // foreach ($related_data as $curr_key => $related_datum)
987

988
                // Now use $fkey_val_to_related_data_keys map to
989
                // look up related rows of data for each parent row of data
990
                /** @psalm-suppress MixedAssignment */
991
                foreach( $parent_data as $p_rec_key => $parent_row ) {
56✔
992

993
                    $matching_related_rows = [];
56✔
994

995
                    /** 
996
                     * @psalm-suppress MixedArrayOffset
997
                     * @psalm-suppress MixedArgument
998
                     */
999
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
56✔
1000

1001
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
56✔
1002

1003
                            $matching_related_rows[] = $related_data[$related_data_key];
56✔
1004
                        }
1005
                    }
1006

1007
                    $this->wrapRelatedDataInsideRecordsAndCollection(
56✔
1008
                        $matching_related_rows, $foreign_model_obj, 
56✔
1009
                        $wrap_each_row_in_a_record, $wrap_records_in_collection
56✔
1010
                    );
56✔
1011

1012
                    //set the related data for the current parent row / record
1013
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
56✔
1014

1015
                        /** 
1016
                         * @psalm-suppress MixedArrayOffset
1017
                         * @psalm-suppress MixedArrayTypeCoercion
1018
                         */
1019
                        $parent_data[$p_rec_key]->setRelatedData($rel_name, $matching_related_rows);
40✔
1020

1021
                    } else {
1022

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

1032
                } // foreach( $parent_data as $p_rec_key => $parent_record )
1033

1034
                ////////////////////////////////////////////////////////////////
1035
                // End: Stitch the related data to the approriate parent records
1036
                ////////////////////////////////////////////////////////////////
1037

1038
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
32✔
1039

1040
                $this->wrapRelatedDataInsideRecordsAndCollection(
32✔
1041
                    $related_data, $foreign_model_obj, 
32✔
1042
                    $wrap_each_row_in_a_record, $wrap_records_in_collection
32✔
1043
                );
32✔
1044

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

1068
/*
1069
-- SQL For Fetching the Related Data
1070

1071
-- $parent_data is a collection or array of records    
1072
SELECT {$foreign_table_name}.*
1073
  FROM {$foreign_table_name}
1074
 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} IN ( $fkey_col_in_my_table column values in $parent_data )
1075

1076
OR
1077

1078
-- $parent_data is a single record
1079
SELECT {$foreign_table_name}.*
1080
  FROM {$foreign_table_name}
1081
 WHERE {$foreign_table_name}.{$fkey_col_in_foreign_table} = {$parent_data->$fkey_col_in_my_table}
1082
*/
1083

1084
            if ( 
1085
                $parent_data instanceof \GDAO\Model\CollectionInterface
88✔
1086
                || is_array($parent_data)
88✔
1087
            ) {
1088
                ///////////////////////////////////////////////////////////
1089
                // Stitch the related data to the approriate parent records
1090
                ///////////////////////////////////////////////////////////
1091

1092
                $fkey_val_to_related_data_keys = [];
56✔
1093

1094
                // Generate a map of 
1095
                //      foreign key value => [keys of related rows in $related_data]
1096
                /** @psalm-suppress MixedAssignment */
1097
                foreach ($related_data as $curr_key => $related_datum) {
56✔
1098

1099
                    /** @psalm-suppress MixedArrayOffset */
1100
                    $curr_fkey_val = $related_datum[$fkey_col_in_foreign_table];
56✔
1101

1102
                    /** @psalm-suppress MixedArgument */
1103
                    if(!array_key_exists($curr_fkey_val, $fkey_val_to_related_data_keys)) {
56✔
1104

1105
                        /** @psalm-suppress MixedArrayOffset */
1106
                        $fkey_val_to_related_data_keys[$curr_fkey_val] = [];
56✔
1107
                    }
1108

1109
                    // Add current key in $related_data to sub array of keys for the 
1110
                    // foreign key value in the current related row $related_datum
1111
                    /** @psalm-suppress MixedArrayOffset */
1112
                    $fkey_val_to_related_data_keys[$curr_fkey_val][] = $curr_key;
56✔
1113

1114
                } // foreach ($related_data as $curr_key => $related_datum)
1115

1116
                // Now use $fkey_val_to_related_data_keys map to
1117
                // look up related rows of data for each parent row of data
1118
                /** @psalm-suppress MixedAssignment */
1119
                foreach( $parent_data as $p_rec_key => $parent_row ) {
56✔
1120

1121
                    $matching_related_rows = [];
56✔
1122

1123
                    /** 
1124
                     * @psalm-suppress MixedArgument
1125
                     * @psalm-suppress MixedArrayOffset
1126
                     */
1127
                    if(array_key_exists($parent_row[$fkey_col_in_my_table], $fkey_val_to_related_data_keys)) {
56✔
1128

1129
                        foreach ($fkey_val_to_related_data_keys[$parent_row[$fkey_col_in_my_table]] as $related_data_key) {
56✔
1130

1131
                            // There should really only be one matching related 
1132
                            // record per parent record since this is a hasOne
1133
                            // relationship
1134
                            $matching_related_rows[] = $related_data[$related_data_key];
56✔
1135
                        }
1136
                    }
1137

1138
                    /** @psalm-suppress MixedArgument */
1139
                    $this->wrapRelatedDataInsideRecordsAndCollection(
56✔
1140
                        $matching_related_rows, $foreign_model_obj, 
56✔
1141
                        $wrap_row_in_a_record, false
56✔
1142
                    );
56✔
1143

1144
                    //set the related data for the current parent row / record
1145
                    if( $parent_row instanceof \GDAO\Model\RecordInterface ) {
56✔
1146

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

1162
                    } else {
1163

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

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

1182
                ////////////////////////////////////////////////////////////////
1183
                // End: Stitch the related data to the approriate parent records
1184
                ////////////////////////////////////////////////////////////////
1185

1186
            } else if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
32✔
1187

1188
                /** @psalm-suppress MixedArgument */
1189
                $this->wrapRelatedDataInsideRecordsAndCollection(
32✔
1190
                            $related_data, $foreign_model_obj, 
32✔
1191
                            $wrap_row_in_a_record, false
32✔
1192
                        );
32✔
1193

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

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

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

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

1242
        /** 
1243
         * @psalm-suppress MixedAssignment
1244
         * @psalm-suppress MixedArgument
1245
         */
1246
        $foreign_table_name = Utils::arrayGet($rel_info, 'foreign_table');
120✔
1247

1248
        /** @psalm-suppress MixedAssignment */
1249
        $fkey_col_in_foreign_table = 
120✔
1250
            Utils::arrayGet($rel_info, 'foreign_key_col_in_foreign_table');
120✔
1251
        
1252
        /** @psalm-suppress MixedAssignment */
1253
        $foreign_models_class_name = 
120✔
1254
            Utils::arrayGet($rel_info, 'foreign_models_class_name', \LeanOrm\Model::class);
120✔
1255

1256
        /** @psalm-suppress MixedAssignment */
1257
        $pri_key_col_in_foreign_models_table = 
120✔
1258
            Utils::arrayGet($rel_info, 'primary_key_col_in_foreign_table');
120✔
1259

1260
        /** @psalm-suppress MixedAssignment */
1261
        $fkey_col_in_my_table = 
120✔
1262
                Utils::arrayGet($rel_info, 'foreign_key_col_in_my_table');
120✔
1263

1264
        /** @psalm-suppress MixedAssignment */
1265
        $sql_query_modifier = 
120✔
1266
                Utils::arrayGet($rel_info, 'sql_query_modifier', null);
120✔
1267

1268
        /** @psalm-suppress MixedArgument */
1269
        $foreign_model_obj = $this->createRelatedModelObject(
120✔
1270
                                        $foreign_models_class_name,
120✔
1271
                                        $pri_key_col_in_foreign_models_table,
120✔
1272
                                        $foreign_table_name
120✔
1273
                                    );
120✔
1274
        
1275
        /** @psalm-suppress MixedAssignment */
1276
        $foreign_models_collection_class_name = 
120✔
1277
            Utils::arrayGet($rel_info, 'foreign_models_collection_class_name', '');
120✔
1278

1279
        /** @psalm-suppress MixedAssignment */
1280
        $foreign_models_record_class_name = 
120✔
1281
            Utils::arrayGet($rel_info, 'foreign_models_record_class_name', '');
120✔
1282

1283
        if($foreign_models_collection_class_name !== '') {
120✔
1284
            
1285
            /** @psalm-suppress MixedArgument */
1286
            $foreign_model_obj->setCollectionClassName($foreign_models_collection_class_name);
120✔
1287
        }
1288

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

1295
        $query_obj = $foreign_model_obj->getSelect();
120✔
1296

1297
        if ( $parent_data instanceof \GDAO\Model\RecordInterface ) {
120✔
1298

1299
            $query_obj->where(
56✔
1300
                " {$foreign_table_name}.{$fkey_col_in_foreign_table} = :leanorm_fkey_col_in_foreign_table_val ",
56✔
1301
                ['leanorm_fkey_col_in_foreign_table_val' => $parent_data->$fkey_col_in_my_table]
56✔
1302
            );
56✔
1303

1304
        } else {
1305
            //assume it's a collection or array
1306
            /** @psalm-suppress MixedArgument */
1307
            $col_vals = $this->getColValsFromArrayOrCollection(
64✔
1308
                            $parent_data, $fkey_col_in_my_table
64✔
1309
                        );
64✔
1310

1311
            if( $col_vals !== [] ) {
64✔
1312
                
1313
                $this->addWhereInAndOrIsNullToQuery(
64✔
1314
                    "{$foreign_table_name}.{$fkey_col_in_foreign_table}", 
64✔
1315
                    $col_vals, 
64✔
1316
                    $query_obj
64✔
1317
                );
64✔
1318
            }
1319
        }
1320

1321
        if(\is_callable($sql_query_modifier)) {
120✔
1322

1323
            $sql_query_modifier = Utils::getClosureFromCallable($sql_query_modifier);
32✔
1324
            
1325
            // modify the query object before executing the query
1326
            /** @psalm-suppress MixedAssignment */
1327
            $query_obj = $sql_query_modifier($query_obj);
32✔
1328
        }
1329

1330
        if($query_obj->hasCols() === false){
120✔
1331

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

1361
            $f_models_class_name = \LeanOrm\Model::class;
×
1362
        }
1363

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

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

1442
        if ( is_array($parent_data) ) {
64✔
1443

1444
            /** @psalm-suppress MixedAssignment */
1445
            foreach($parent_data as $data) {
40✔
1446

1447
                /** 
1448
                 * @psalm-suppress MixedAssignment
1449
                 * @psalm-suppress MixedArrayAccess
1450
                 */
1451
                $col_vals[] = $data[$fkey_col_in_my_table];
40✔
1452
            }
1453

1454
        } elseif($parent_data instanceof \GDAO\Model\CollectionInterface) {
32✔
1455

1456
            $col_vals = $parent_data->getColVals($fkey_col_in_my_table);
32✔
1457
        }
1458

1459
        return $col_vals;
64✔
1460
    }
1461

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

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

1484
        if($wrap_records_in_collection) {
120✔
1485
            
1486
            /** @psalm-suppress MixedArgument */
1487
            $matching_related_records = $foreign_model_obj->createNewCollection(...$matching_related_records);
88✔
1488
        }
1489
    }
1490

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

1543
            if( $use_collections ) {
8✔
1544

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

1549
            } else {
1550

1551
                if( $use_records ) {
8✔
1552

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

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

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

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

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

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

1593
        return $this->doFetchRecordsIntoCollection($select_obj, $relations_to_include, true);
32✔
1594
    }
1595

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

1607
        if($data !== [] ) {
104✔
1608

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

1629
        /** @psalm-suppress InvalidReturnStatement */
1630
        return $results;
104✔
1631
    }
1632

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

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

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

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

1674
        return $results;
40✔
1675
    }
1676

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

1683
        $results = $this->getArrayOfDbRows($select_obj, $use_p_k_val_as_key);
136✔
1684

1685
        /** @psalm-suppress MixedAssignment */
1686
        foreach ($results as $key=>$value) {
136✔
1687

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

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

1700
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery($select_obj);
176✔
1701
        $sql = $query_obj->__toString();
176✔
1702
        $params_2_bind_2_sql = $query_obj->getBindValues();
176✔
1703
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
176✔
1704
        
1705
        $results = $this->db_connector->dbFetchAll($sql, $params_2_bind_2_sql);
176✔
1706
        
1707
        if( $use_p_k_val_as_key && $results !== [] && $this->getPrimaryCol() !== '' ) {
176✔
1708

1709
            $results_keyed_by_pk = [];
56✔
1710

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

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

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

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

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

1731
            $results = $results_keyed_by_pk;
56✔
1732
        }
1733

1734
        return $results;
176✔
1735
    }
1736

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

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

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

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

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

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

1793
        return $results;
52✔
1794
    }
1795

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

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

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

1809
        $result = 0;
48✔
1810

1811
        if ( $cols_n_vals !== [] ) {
48✔
1812

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

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

1824
                if(!in_array($colname, $table_cols)) {
48✔
1825

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

1831
                if (is_array($colval)) {
48✔
1832

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

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

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

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

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

1848
                } else {
1849

1850
                    if(!$this->isAcceptableDeleteQueryValue($colval)) {
40✔
1851

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

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

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

1864
                $dlt_qry = $del_qry_obj->__toString();
32✔
1865
                $dlt_qry_params = $del_qry_obj->getBindValues();
32✔
1866
                $this->logQuery($dlt_qry, $dlt_qry_params, __METHOD__, '' . __LINE__);
32✔
1867

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

1870
                $this->db_connector->executeQuery($dlt_qry, $dlt_qry_params, true);
32✔
1871

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

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

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

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

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

1905
        $succesfully_deleted = null;
40✔
1906

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

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

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

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

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

1941
            if ( $succesfully_deleted === 1 ) {
16✔
1942
                
1943
                $record->markAsNew();
16✔
1944
                
1945
                /** @psalm-suppress MixedAssignment */
1946
                foreach ($this->getRelationNames() as $relation_name) {
16✔
1947
                    
1948
                    // Remove all the related data since the primary key of the 
1949
                    // record may change or there may be ON DELETE CASACADE 
1950
                    // constraints that may have triggred those records being 
1951
                    // deleted from the db because of the deletion of this record
1952
                    /** @psalm-suppress MixedArrayOffset */
1953
                    unset($record[$relation_name]);
×
1954
                }
1955
                
1956
                /** @psalm-suppress MixedArrayAccess */
1957
                if( $this->table_cols[$record->getPrimaryCol()]['autoinc'] ) {
16✔
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(
1970
                count($this->fetch([$pri_key_val], null, [], true, true)) >= 1 
×
1971
            ) {
1972
                
1973
                //we were still able to fetch the record from the db, so delete failed
1974
                $succesfully_deleted = false;
×
1975
            }
1976
        }
1977

1978
        return ( $succesfully_deleted >= 1 ) ? true : $succesfully_deleted;
16✔
1979
    }
1980

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

1987
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
56✔
1988
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
56✔
1989
        );
56✔
1990
        $sql = $query_obj->__toString();
56✔
1991
        $params_2_bind_2_sql = $query_obj->getBindValues();
56✔
1992
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
56✔
1993

1994
        return $this->db_connector->dbFetchCol($sql, $params_2_bind_2_sql);
56✔
1995
    }
1996

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

2003
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
124✔
2004
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
124✔
2005
        );
124✔
2006
        $query_obj->limit(1);
124✔
2007

2008
        $sql = $query_obj->__toString();
124✔
2009
        $params_2_bind_2_sql = $query_obj->getBindValues();
124✔
2010
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
124✔
2011

2012
        /** @psalm-suppress MixedAssignment */
2013
        $result = $this->db_connector->dbFetchOne($sql, $params_2_bind_2_sql);
124✔
2014

2015
        if( $result !== false && is_array($result) && $result !== [] ) {
124✔
2016

2017
            $result = $this->createNewRecord($result)->markAsNotNew();
124✔
2018
            
2019
            $this->recursivelyStitchRelatedData(
124✔
2020
                model: $this,
124✔
2021
                relations_to_include: $relations_to_include, 
124✔
2022
                fetched_data: $result, 
124✔
2023
                wrap_records_in_collection: true
124✔
2024
            );
124✔
2025
        }
2026
        
2027
        if(!($result instanceof \GDAO\Model\RecordInterface)) {
124✔
2028
            
2029
            $result = null;
32✔
2030
        }
2031

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

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

2058
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
8✔
2059
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
8✔
2060
        );
8✔
2061
        $sql = $query_obj->__toString();
8✔
2062
        $params_2_bind_2_sql = $query_obj->getBindValues();
8✔
2063
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
8✔
2064

2065
        return $this->db_connector->dbFetchPairs($sql, $params_2_bind_2_sql);
8✔
2066
    }
2067

2068
    /**
2069
     * {@inheritDoc}
2070
     */
2071
    #[\Override]
2072
    public function fetchValue(?object $query=null): mixed {
2073

2074
        $query_obj = $this->createQueryObjectIfNullAndAddColsToQuery(
64✔
2075
            ($query instanceof \Aura\SqlQuery\Common\Select) ? $query : null
64✔
2076
        );
64✔
2077
        $query_obj->limit(1);
64✔
2078

2079
        $query_obj_4_num_matching_rows = clone $query_obj;
64✔
2080

2081
        $sql = $query_obj->__toString();
64✔
2082
        $params_2_bind_2_sql = $query_obj->getBindValues();
64✔
2083
        $this->logQuery($sql, $params_2_bind_2_sql, __METHOD__, '' . __LINE__);
64✔
2084

2085
        /** @psalm-suppress MixedAssignment */
2086
        $result = $this->db_connector->dbFetchValue($sql, $params_2_bind_2_sql);
64✔
2087

2088
        // need to issue a second query to get the number of matching rows
2089
        // clear the cols part of the query above while preserving all the
2090
        // other parts of the query
2091
        $query_obj_4_num_matching_rows->resetCols();
64✔
2092
        $query_obj_4_num_matching_rows->cols([' COUNT(*) AS num_rows']);
64✔
2093

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

2098
        /** @psalm-suppress MixedAssignment */
2099
        $num_matching_rows = $this->db_connector->dbFetchOne($sql, $params_2_bind_2_sql);
64✔
2100

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

2158
    protected function processRowOfDataToInsert(
2159
        array &$data, array &$table_cols, bool &$has_autoinc_pk_col=false
2160
    ): void {
2161

2162
        $this->addTimestampToData($data, $this->created_timestamp_column_name, $table_cols);
52✔
2163
        $this->addTimestampToData($data, $this->updated_timestamp_column_name, $table_cols);
52✔
2164

2165
        // remove non-existent table columns from the data and also
2166
        // converts object values for objects with __toString() to 
2167
        // their string value
2168
        /** @psalm-suppress MixedAssignment */
2169
        foreach ($data as $key => $val) {
52✔
2170

2171
            /** @psalm-suppress MixedAssignment */
2172
            $data[$key] = $this->stringifyIfStringable($val, ''.$key, $table_cols);
52✔
2173

2174
            if ( !in_array($key, $table_cols) ) {
52✔
2175

2176
                unset($data[$key]);
24✔
2177
                // not in the table, so no need to check for autoinc
2178
                continue;
24✔
2179

2180
            } elseif( !$this->isAcceptableInsertValue($val) ) {
52✔
2181

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

2194
                throw new \GDAO\ModelInvalidInsertValueSuppliedException($msg);
16✔
2195
            }
2196

2197
            // Code below was lifted from Solar_Sql_Model::insert()
2198
            // remove empty autoinc columns to soothe postgres, which won't
2199
            // take explicit NULLs in SERIAL cols.
2200
            /** @psalm-suppress MixedArrayAccess */
2201
            if ( $this->table_cols[$key]['autoinc'] && empty($val) ) {
52✔
2202

2203
                unset($data[$key]);
×
2204

2205
            } // if ( $this->table_cols[$key]['autoinc'] && empty($val) )
2206
        } // foreach ($data as $key => $val)
2207

2208
        /** @psalm-suppress MixedAssignment */
2209
        foreach($this->table_cols as $col_name=>$col_info) {
36✔
2210

2211
            /** @psalm-suppress MixedArrayAccess */
2212
            if ( $col_info['autoinc'] === true && $col_info['primary'] === true ) {
36✔
2213

2214
                if(array_key_exists($col_name, $data)) {
×
2215

2216
                    //no need to add primary key value to the insert 
2217
                    //statement since the column is auto incrementing
2218
                    unset($data[$col_name]);
×
2219

2220
                } // if(array_key_exists($col_name, $data_2_insert))
2221

2222
                $has_autoinc_pk_col = true;
×
2223

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

2247
            // we don't have the primary key.
2248
            // Do a select using all the fields.
2249
            // If only one record is returned, we have found
2250
            // the record we just inserted, else we return $data_2_insert as is 
2251

2252
            $select = $this->getSelect();
20✔
2253

2254
            /** @psalm-suppress MixedAssignment */
2255
            foreach ($data_2_insert as $col => $val) {
20✔
2256

2257
                /** @psalm-suppress MixedAssignment */
2258
                $processed_val = $this->stringifyIfStringable($val, ''.$col, $table_cols);
20✔
2259

2260
                if(is_string($processed_val) || is_numeric($processed_val)) {
20✔
2261

2262
                    $select->where(" {$col} = :{$col} ", [$col=>$val]);
20✔
2263

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

2266
                    $select->where(" {$col} IS NULL ");
8✔
2267
                } // if(is_string($processed_val) || is_numeric($processed_val))
2268
            } // foreach ($data_2_insert as $col => $val)
2269

2270
            $matching_rows = $this->fetchRowsIntoArray($select);
20✔
2271

2272
            if(count($matching_rows) === 1) {
20✔
2273

2274
                /** @psalm-suppress MixedAssignment */
2275
                $data_2_insert = array_pop($matching_rows);
20✔
2276
            }
2277
        }
2278
    }
2279

2280
    /**
2281
     * {@inheritDoc}
2282
     */
2283
    #[\Override]
2284
    public function insert(array $data_2_insert = []): bool|array {
2285
        
2286
        $result = false;
28✔
2287

2288
        if ( $data_2_insert !== [] ) {
28✔
2289

2290
            $table_cols = $this->getTableColNames();
28✔
2291
            $has_autoinc_pkey_col=false;
28✔
2292

2293
            $this->processRowOfDataToInsert(
28✔
2294
                $data_2_insert, $table_cols, $has_autoinc_pkey_col
28✔
2295
            );
28✔
2296

2297
            // Do we still have anything left to save after removing items
2298
            // in the array that do not map to actual db table columns
2299
            /**
2300
             * @psalm-suppress RedundantCondition
2301
             * @psalm-suppress TypeDoesNotContainType
2302
             */
2303
            if( (is_countable($data_2_insert) ? count($data_2_insert) : 0) > 0 ) {
20✔
2304

2305
                //Insert statement
2306
                $insrt_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newInsert();
20✔
2307
                $insrt_qry_obj->into($this->getTableName())->cols($data_2_insert);
20✔
2308

2309
                $insrt_qry_sql = $insrt_qry_obj->__toString();
20✔
2310
                $insrt_qry_params = $insrt_qry_obj->getBindValues();
20✔
2311
                $this->logQuery($insrt_qry_sql, $insrt_qry_params, __METHOD__, '' . __LINE__);
20✔
2312

2313
                if( $this->db_connector->executeQuery($insrt_qry_sql, $insrt_qry_params) ) {
20✔
2314

2315
                    // insert was successful, we are now going to try to 
2316
                    // fetch the inserted record from the db to get and 
2317
                    // return the db representation of the data
2318
                    if($has_autoinc_pkey_col) {
20✔
2319

2320
                        /** @psalm-suppress MixedAssignment */
2321
                        $last_insert_sequence_name = 
×
2322
                            $insrt_qry_obj->getLastInsertIdName($this->getPrimaryCol());
×
2323

2324
                        $pk_val_4_new_record = 
×
2325
                            $this->getPDO()->lastInsertId(is_string($last_insert_sequence_name) ? $last_insert_sequence_name : null);
×
2326

2327
                        // Add retrieved primary key value 
2328
                        // or null (if primary key value is empty) 
2329
                        // to the data to be returned.
2330
                        $data_2_insert[$this->primary_col] = 
×
2331
                            empty($pk_val_4_new_record) ? null : $pk_val_4_new_record;
×
2332

2333
                        $this->updateInsertDataArrayWithTheNewlyInsertedRecordFromDB(
×
2334
                            $data_2_insert, $table_cols
×
2335
                        );
×
2336

2337
                    } else {
2338

2339
                        $this->updateInsertDataArrayWithTheNewlyInsertedRecordFromDB(
20✔
2340
                            $data_2_insert, $table_cols
20✔
2341
                        );
20✔
2342

2343
                    } // if($has_autoinc_pkey_col)
2344

2345
                    //insert was successful
2346
                    $result = $data_2_insert;
20✔
2347

2348
                } // if( $this->db_connector->executeQuery($insrt_qry_sql, $insrt_qry_params) )
2349
            } // if(count($data_2_insert) > 0 ) 
2350
        } // if ( $data_2_insert !== [] )
2351
        
2352
        return $result;
20✔
2353
    }
2354

2355
    /**
2356
     * {@inheritDoc}
2357
     */
2358
    #[\Override]
2359
    public function insertMany(array $rows_of_data_2_insert = []): bool {
2360

2361
        $result = false;
36✔
2362

2363
        if ($rows_of_data_2_insert !== []) {
36✔
2364

2365
            $table_cols = $this->getTableColNames();
36✔
2366

2367
            foreach (array_keys($rows_of_data_2_insert) as $key) {
36✔
2368

2369
                if( !is_array($rows_of_data_2_insert[$key]) ) {
36✔
2370

2371
                    $item_type = gettype($rows_of_data_2_insert[$key]);
8✔
2372

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

2384
                    throw new \GDAO\ModelInvalidInsertValueSuppliedException($msg);
8✔
2385
                }
2386

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

2389
                /** 
2390
                 * @psalm-suppress TypeDoesNotContainType
2391
                 * @psalm-suppress RedundantCondition
2392
                 */
2393
                if((is_countable($rows_of_data_2_insert[$key]) ? count($rows_of_data_2_insert[$key]) : 0) === 0) {
20✔
2394

2395
                    // all the keys in the curent row of data aren't valid
2396
                    // db table columns, remove the row of data from the 
2397
                    // data to be inserted into the DB.
2398
                    unset($rows_of_data_2_insert[$key]);
8✔
2399

2400
                } // if(count($rows_of_data_2_insert[$key]) === 0)
2401

2402
            } // foreach ($rows_of_data_2_insert as $key=>$row_2_insert)
2403

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

2407
                //Insert statement
2408
                $insrt_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newInsert();
20✔
2409

2410
                //Batch all the data into one insert query.
2411
                $insrt_qry_obj->into($this->getTableName())->addRows($rows_of_data_2_insert);           
20✔
2412
                $insrt_qry_sql = $insrt_qry_obj->__toString();
20✔
2413
                $insrt_qry_params = $insrt_qry_obj->getBindValues();
20✔
2414

2415
                $this->logQuery($insrt_qry_sql, $insrt_qry_params, __METHOD__, '' . __LINE__);
20✔
2416
                $result = (bool) $this->db_connector->executeQuery($insrt_qry_sql, $insrt_qry_params);
20✔
2417

2418
            } // if(count($rows_of_data_2_insert) > 0)
2419
        } // if ($rows_of_data_2_insert !== [])
2420

2421
        return $result;
20✔
2422
    }
2423
    
2424
    protected function throwExceptionForInvalidUpdateQueryArg(mixed $val, array $cols_n_vals): never {
2425

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

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

2452
        if ($col_names_n_values_2_save !== []) {
52✔
2453

2454
            $table_cols = $this->getTableColNames();
52✔
2455
            $pkey_col_name = $this->getPrimaryCol();
52✔
2456
            $this->addTimestampToData(
52✔
2457
                $col_names_n_values_2_save, $this->updated_timestamp_column_name, $table_cols
52✔
2458
            );
52✔
2459

2460
            if(array_key_exists($pkey_col_name, $col_names_n_values_2_save)) {
52✔
2461

2462
                //don't update the primary key
2463
                unset($col_names_n_values_2_save[$pkey_col_name]);
28✔
2464
            }
2465

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

2473
                /** @psalm-suppress MixedAssignment */
2474
                $col_names_n_values_2_save[$key] = 
52✔
2475
                    $this->stringifyIfStringable($val, ''.$key, $table_cols);
52✔
2476

2477
                if ( !in_array($key, $table_cols) ) {
52✔
2478

2479
                    unset($col_names_n_values_2_save[$key]);
8✔
2480

2481
                } else if( !$this->isAcceptableUpdateValue($val) ) {
52✔
2482

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

2495
                    throw new \GDAO\ModelInvalidUpdateValueSuppliedException($msg);
8✔
2496
                } // if ( !in_array($key, $table_cols) )
2497
            } // foreach ($col_names_n_vals_2_save as $key => $val)
2498

2499
            // After filtering out non-table columns, if we have any table
2500
            // columns data left, we can do the update
2501
            if($col_names_n_values_2_save !== []) {
44✔
2502

2503
                //update statement
2504
                $update_qry_obj = (new QueryFactory($this->getPdoDriverName()))->newUpdate();
44✔
2505
                $update_qry_obj->table($this->getTableName());
44✔
2506
                $update_qry_obj->cols($col_names_n_values_2_save);
44✔
2507

2508
                /** @psalm-suppress MixedAssignment */
2509
                foreach ($col_names_n_values_2_match as $colname => $colval) {
44✔
2510

2511
                    if(!in_array($colname, $table_cols)) {
44✔
2512

2513
                        //non-existent table column
2514
                        unset($col_names_n_values_2_match[$colname]);
8✔
2515
                        continue;
8✔
2516
                    }
2517

2518
                    if (is_array($colval)) {
44✔
2519

2520
                        if($colval !== []) {
16✔
2521

2522
                            /** @psalm-suppress MixedAssignment */
2523
                            foreach ($colval as $key=>$val) {
16✔
2524

2525
                                if(!$this->isAcceptableUpdateQueryValue($val)) {
16✔
2526

2527
                                    $this->throwExceptionForInvalidUpdateQueryArg(
8✔
2528
                                            $val, $col_names_n_values_2_match
8✔
2529
                                        );
8✔
2530
                                }
2531

2532
                                /** @psalm-suppress MixedAssignment */
2533
                                $colval[$key] = $this->stringifyIfStringable($val);
16✔
2534
                            }
2535

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

2538
                        } // if($colval !== []) 
2539

2540
                    } else {
2541

2542
                        if(!$this->isAcceptableUpdateQueryValue($colval)) {
44✔
2543

2544
                            $this->throwExceptionForInvalidUpdateQueryArg(
8✔
2545
                                    $colval, $col_names_n_values_2_match
8✔
2546
                                );
8✔
2547
                        }
2548

2549
                        if(is_null($colval)) {
44✔
2550

2551
                            $update_qry_obj->where(
8✔
2552
                                " {$colname} IS NULL "
8✔
2553
                            );
8✔
2554

2555
                        } else {
2556

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

2567
                    } // if (is_array($colval))
2568
                } // foreach ($col_names_n_vals_2_match as $colname => $colval)
2569

2570
                // If after filtering out non existing cols in $col_names_n_vals_2_match
2571
                // if there is still data left in $col_names_n_vals_2_match, then
2572
                // finish building the update query and do the update
2573
                if( 
2574
                    $col_names_n_values_2_match !== [] // there are valid db table cols in here
28✔
2575
                    || 
2576
                    (
2577
                        $num_initial_match_items === 0
28✔
2578
                        && $col_names_n_values_2_match === [] // empty match array passed, we are updating all rows
28✔
2579
                    )
2580
                ) {
2581
                    $updt_qry = $update_qry_obj->__toString();
28✔
2582
                    $updt_qry_params = $update_qry_obj->getBindValues();
28✔
2583
                    $this->logQuery($updt_qry, $updt_qry_params, __METHOD__, '' . __LINE__);
28✔
2584

2585
                    $this->db_connector->executeQuery($updt_qry, $updt_qry_params, true);
28✔
2586
                }
2587

2588
            } // if($col_names_n_vals_2_save !== [])
2589
        } // if ($col_names_n_vals_2_save !== [])
2590

2591
        return $this;
28✔
2592
    }
2593

2594
    /**
2595
     * {@inheritDoc}
2596
     * @psalm-suppress UnusedVariable
2597
     */
2598
    #[\Override]
2599
    public function updateSpecifiedRecord(\GDAO\Model\RecordInterface $record): static {
2600
        
2601
        if( $record instanceof \LeanOrm\Model\ReadOnlyRecord ) {
44✔
2602

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

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

2635
            if($this->getUpdatedTimestampColumnName() !== null) {
28✔
2636

2637
                // Record has changed value(s) & must definitely be updated.
2638
                // Set the value of the $this->getUpdatedTimestampColumnName()
2639
                // field to an empty string, force updateMatchingDbTableRows
2640
                // to add a new updated timestamp value during the update.
2641
                $record->{$this->getUpdatedTimestampColumnName()} = '';
8✔
2642
            }
2643

2644
            $data_2_save = $record->getData();
28✔
2645
            $this->updateMatchingDbTableRows(
28✔
2646
                $data_2_save, 
28✔
2647
                $cols_n_vals_2_match
28✔
2648
            );
28✔
2649

2650
            // update the record with the new updated copy from the DB
2651
            // which will contain the new updated timestamp value.
2652
            $record = $this->fetchOneRecord(
28✔
2653
                        $this->getSelect()
28✔
2654
                             ->where(
28✔
2655
                                    " {$record->getPrimaryCol()} = :{$record->getPrimaryCol()} ", 
28✔
2656
                                    [$record->getPrimaryCol() => $record->getPrimaryVal()]
28✔
2657
                                )
28✔
2658
                    );
28✔
2659
        } // if( count($record) > 0 && !$record->isNew()........
2660

2661
        return $this;
28✔
2662
    }
2663

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

2680
            foreach($keys_for_null_vals as $key_for_null_val) {
88✔
2681

2682
                // remove the null vals from $colval
2683
                unset($unique_colvals[$key_for_null_val]);
8✔
2684
            }
2685

2686
            if(
2687
                $keys_for_null_vals !== [] && $unique_colvals !== []
88✔
2688
            ) {
2689
                // Some values in the array are null and some are non-null
2690
                // Generate WHERE COL IN () OR COL IS NULL
2691
                $qry_obj->where(
8✔
2692
                    " {$colname} IN (:bar) ",
8✔
2693
                    [ 'bar' => $unique_colvals ]
8✔
2694
                )->orWhere(" {$colname} IS NULL ");
8✔
2695

2696
            } elseif (
2697
                $keys_for_null_vals !== []
88✔
2698
                && $unique_colvals === []
88✔
2699
            ) {
2700
                // All values in the array are null
2701
                // Only generate WHERE COL IS NULL
2702
                $qry_obj->where(" {$colname} IS NULL ");
8✔
2703

2704
            } else { // ($keys_for_null_vals === [] && $unique_colvals !== []) // no nulls found
2705
                
2706
                ////////////////////////////////////////////////////////////////
2707
                // NOTE: ($keys_for_null_vals === [] && $unique_colvals === [])  
2708
                // is impossible because we started with if($colvals !== [])
2709
                ////////////////////////////////////////////////////////////////
2710

2711
                // All values in the array are non-null
2712
                // Only generate WHERE COL IN ()
2713
                $qry_obj->where(       
88✔
2714
                    " {$colname} IN (:bar) ",
88✔
2715
                    [ 'bar' => $unique_colvals ]
88✔
2716
                );
88✔
2717
            }
2718
        }
2719
    }
2720
    
2721
    /**
2722
     * @return array{
2723
     *              database_server_info: mixed, 
2724
     *              driver_name: mixed, 
2725
     *              pdo_client_version: mixed, 
2726
     *              database_server_version: mixed, 
2727
     *              connection_status: mixed, 
2728
     *              connection_is_persistent: mixed
2729
     *          }
2730
     * 
2731
     * @psalm-suppress PossiblyUnusedMethod
2732
     */
2733
    public function getCurrentConnectionInfo(): array {
2734

2735
        $pdo_obj = $this->getPDO();
8✔
2736
        $attributes = [
8✔
2737
            'database_server_info' => 'SERVER_INFO',
8✔
2738
            'driver_name' => 'DRIVER_NAME',
8✔
2739
            'pdo_client_version' => 'CLIENT_VERSION',
8✔
2740
            'database_server_version' => 'SERVER_VERSION',
8✔
2741
            'connection_status' => 'CONNECTION_STATUS',
8✔
2742
            'connection_is_persistent' => 'PERSISTENT',
8✔
2743
        ];
8✔
2744

2745
        foreach ($attributes as $key => $value) {
8✔
2746
            
2747
            try {
2748
                /**
2749
                 * @psalm-suppress MixedAssignment
2750
                 * @psalm-suppress MixedArgument
2751
                 */
2752
                $attributes[ $key ] = $pdo_obj->getAttribute(constant(\PDO::class .'::ATTR_' . $value));
8✔
2753
                
2754
            } catch (\PDOException) {
8✔
2755
                
2756
                $attributes[ $key ] = 'Unsupported attribute for the current PDO driver';
8✔
2757
                continue;
8✔
2758
            }
2759

2760
            if( $value === 'PERSISTENT' ) {
8✔
2761

2762
                $attributes[ $key ] = var_export($attributes[ $key ], true);
8✔
2763
            }
2764
        }
2765

2766
        return $attributes;
8✔
2767
    }
2768

2769
    /**
2770
     * @return mixed[]
2771
     * @psalm-suppress PossiblyUnusedMethod
2772
     */
2773
    public function clearQueryLog(): static {
2774

2775
        $this->query_log = [];
8✔
2776
        
2777
        return $this;
8✔
2778
    }
2779

2780
    /**
2781
     * @return mixed[]
2782
     * @psalm-suppress PossiblyUnusedMethod
2783
     */
2784
    public function getQueryLog(): array {
2785

2786
        return $this->query_log;
16✔
2787
    }
2788

2789
    /**
2790
     * To get the log for all existing instances of this class & its subclasses,
2791
     * call this method with no args or with null.
2792
     * 
2793
     * To get the log for instances of a specific class (this class or a
2794
     * particular sub-class of this class), you must call this method with 
2795
     * an instance of the class whose log you want to get.
2796
     * 
2797
     * @return mixed[]
2798
     * @psalm-suppress PossiblyUnusedMethod
2799
     */
2800
    public static function getQueryLogForAllInstances(?\GDAO\Model $obj=null): array {
2801
        
2802
        $key = ($obj instanceof \GDAO\Model) ? static::createLoggingKey($obj) : '';
16✔
2803
        
2804
        return ($obj instanceof \GDAO\Model)
16✔
2805
                ?
16✔
2806
                (
16✔
2807
                    array_key_exists($key, static::$all_instances_query_log) 
8✔
2808
                    ? static::$all_instances_query_log[$key] : [] 
8✔
2809
                )
16✔
2810
                : static::$all_instances_query_log 
16✔
2811
                ;
16✔
2812
    }
2813
    
2814
    /**
2815
     * @psalm-suppress PossiblyUnusedMethod
2816
     */
2817
    public static function clearQueryLogForAllInstances(): void {
2818
        
2819
        static::$all_instances_query_log = [];
32✔
2820
    }
2821

2822
    protected static function createLoggingKey(\GDAO\Model $obj): string {
2823
        
2824
        return "{$obj->getDsn()}::" . $obj::class;
40✔
2825
    }
2826
    
2827
    protected function logQuery(string $sql, array $bind_params, string $calling_method='', string $calling_line=''): static {
2828

2829
        if( $this->can_log_queries ) {
304✔
2830

2831
            $key = static::createLoggingKey($this);
40✔
2832
            
2833
            if(!array_key_exists($key, static::$all_instances_query_log)) {
40✔
2834

2835
                static::$all_instances_query_log[$key] = [];
40✔
2836
            }
2837

2838
            $log_record = [
40✔
2839
                'sql' => $sql,
40✔
2840
                'bind_params' => $bind_params,
40✔
2841
                'date_executed' => date('Y-m-d H:i:s'),
40✔
2842
                'class_method' => $calling_method,
40✔
2843
                'line_of_execution' => $calling_line,
40✔
2844
            ];
40✔
2845
            
2846
            /** @psalm-suppress InvalidPropertyAssignmentValue */
2847
            $this->query_log[] = $log_record;
40✔
2848
            static::$all_instances_query_log[$key][] = $log_record;
40✔
2849

2850
            if($this->logger instanceof \Psr\Log\LoggerInterface) {
40✔
2851

2852
                $this->logger->info(
8✔
2853
                    PHP_EOL . PHP_EOL .
8✔
2854
                    'SQL:' . PHP_EOL . "{$sql}" . PHP_EOL . PHP_EOL . PHP_EOL .
8✔
2855
                    'BIND PARAMS:' . PHP_EOL . var_export($bind_params, true) .
8✔
2856
                    PHP_EOL . "Calling Method: `{$calling_method}`" . PHP_EOL .
8✔
2857
                    "Line of Execution: `{$calling_line}`" . PHP_EOL .
8✔
2858
                     PHP_EOL . PHP_EOL . PHP_EOL
8✔
2859
                );
8✔
2860
            }                    
2861
        }
2862

2863
        return $this;
304✔
2864
    }
2865

2866
    ///////////////////////////////////////
2867
    // Methods for defining relationships
2868
    ///////////////////////////////////////
2869
    
2870
    /**
2871
     * @psalm-suppress PossiblyUnusedMethod
2872
     */
2873
    public function hasOne(
2874
        string $relation_name,  // name of the relation, via which the related data
2875
                                // will be accessed as a property with the same name 
2876
                                // on record objects for this model class or array key 
2877
                                // for the related data when data is fetched into arrays 
2878
                                // via this model
2879
        
2880
        string $relationship_col_in_my_table,
2881
        
2882
        string $relationship_col_in_foreign_table,
2883
        
2884
        string $foreign_table_name='',   // If empty, the value set in the $table_name property
2885
                                         // of the model class specified in $foreign_models_class_name
2886
                                         // will be used if $foreign_models_class_name !== '' 
2887
                                         // and the value of the $table_name property is not ''
2888
        
2889
        string $primary_key_col_in_foreign_table='',   // If empty, the value set in the $primary_col property
2890
                                                       // of the model class specified in $foreign_models_class_name
2891
                                                       // will be used if $foreign_models_class_name !== '' 
2892
                                                       // and the value of the $primary_col property is not ''
2893
        
2894
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
2895
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
2896
        
2897
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
2898
                                                       // or the value of the $record_class_name property
2899
                                                       // in the class specfied in $foreign_models_class_name
2900
        
2901
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
2902
                                                            // or the value of the $collection_class_name property
2903
                                                            // in the class specfied in $foreign_models_class_name
2904
        
2905
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
2906
    ): static {
2907
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
272✔
2908
        $this->setRelationshipDefinitionDefaultsIfNeeded (
264✔
2909
            $foreign_models_class_name,
264✔
2910
            $foreign_table_name,
264✔
2911
            $primary_key_col_in_foreign_table,
264✔
2912
            $foreign_models_record_class_name,
264✔
2913
            $foreign_models_collection_class_name
264✔
2914
        );
264✔
2915
        
2916
        if($foreign_models_collection_class_name !== '') {
224✔
2917
            
2918
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
224✔
2919
        }
2920
        
2921
        if($foreign_models_record_class_name !== '') {
216✔
2922
            
2923
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
216✔
2924
        }
2925
        
2926
        $this->validateTableName($foreign_table_name);
208✔
2927
        
2928
        $this->validateThatTableHasColumn($this->getTableName(), $relationship_col_in_my_table);
200✔
2929
        $this->validateThatTableHasColumn($foreign_table_name, $relationship_col_in_foreign_table);
192✔
2930
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
184✔
2931
        
2932
        $this->relations[$relation_name] = [];
176✔
2933
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_ONE;
176✔
2934
        $this->relations[$relation_name]['foreign_key_col_in_my_table'] = $relationship_col_in_my_table;
176✔
2935
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
176✔
2936
        $this->relations[$relation_name]['foreign_key_col_in_foreign_table'] = $relationship_col_in_foreign_table;
176✔
2937
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
176✔
2938

2939
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
176✔
2940
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
176✔
2941
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
176✔
2942

2943
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
176✔
2944

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

3017
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
240✔
3018
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
240✔
3019
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
240✔
3020

3021
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
240✔
3022

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

3097
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
1,316✔
3098
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
1,316✔
3099
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
1,316✔
3100

3101
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
1,316✔
3102

3103
        return $this;
1,316✔
3104
    }
3105

3106
    /**
3107
     * @psalm-suppress PossiblyUnusedMethod
3108
     */
3109
    public function hasManyThrough(
3110
        string $relation_name,  // name of the relation, via which the related data
3111
                                // will be accessed as a property with the same name 
3112
                                // on record objects for this model class or array key 
3113
                                // for the related data when data is fetched into arrays 
3114
                                // via this model
3115
        
3116
        string $col_in_my_table_linked_to_join_table,
3117
        string $join_table,
3118
        string $col_in_join_table_linked_to_my_table,
3119
        string $col_in_join_table_linked_to_foreign_table,
3120
        string $col_in_foreign_table_linked_to_join_table,
3121
        
3122
        string $foreign_table_name = '', // If empty, the value set in the $table_name property
3123
                                         // of the model class specified in $foreign_models_class_name
3124
                                         // will be used if $foreign_models_class_name !== '' 
3125
                                         // and the value of the $table_name property is not ''
3126
            
3127
        string $primary_key_col_in_foreign_table = '', // If empty, the value set in the $primary_col property
3128
                                                       // of the model class specified in $foreign_models_class_name
3129
                                                       // will be used if $foreign_models_class_name !== '' 
3130
                                                       // and the value of the $primary_col property is not ''
3131
            
3132
        string $foreign_models_class_name = '', // If empty, defaults to \LeanOrm\Model::class.
3133
                                                // If empty, you must specify $foreign_table_name & $primary_key_col_in_foreign_table
3134
        
3135
        string $foreign_models_record_class_name = '', // If empty will default to \LeanOrm\Model\Record 
3136
                                                       // or the value of the $record_class_name property
3137
                                                       // in the class specfied in $foreign_models_class_name
3138
        
3139
        string $foreign_models_collection_class_name = '',  // If empty will default to \LeanOrm\Model\Collection
3140
                                                            // or the value of the $collection_class_name property
3141
                                                            // in the class specfied in $foreign_models_class_name
3142
        
3143
        ?callable $sql_query_modifier = null // optional callback to modify the query object used to fetch the related data
3144
    ): static {
3145
        $this->checkThatRelationNameIsNotAnActualColumnName($relation_name);
312✔
3146
        $this->setRelationshipDefinitionDefaultsIfNeeded (
304✔
3147
            $foreign_models_class_name,
304✔
3148
            $foreign_table_name,
304✔
3149
            $primary_key_col_in_foreign_table,
304✔
3150
            $foreign_models_record_class_name,
304✔
3151
            $foreign_models_collection_class_name
304✔
3152
        );
304✔
3153
        
3154
        if ($foreign_models_collection_class_name !== '') {
264✔
3155
            
3156
            $this->validateRelatedCollectionClassName($foreign_models_collection_class_name);
264✔
3157
        }
3158
        
3159
        if ($foreign_models_record_class_name !== '') {
256✔
3160
            
3161
            $this->validateRelatedRecordClassName($foreign_models_record_class_name);
256✔
3162
        }
3163
        
3164
        $this->validateTableName($foreign_table_name);
248✔
3165
        $this->validateTableName($join_table);
240✔
3166
        
3167
        $this->validateThatTableHasColumn($this->getTableName(), $col_in_my_table_linked_to_join_table);
232✔
3168
        $this->validateThatTableHasColumn($join_table, $col_in_join_table_linked_to_my_table);
224✔
3169
        $this->validateThatTableHasColumn($join_table, $col_in_join_table_linked_to_foreign_table);
216✔
3170
        $this->validateThatTableHasColumn($foreign_table_name, $col_in_foreign_table_linked_to_join_table);
208✔
3171
        $this->validateThatTableHasColumn($foreign_table_name, $primary_key_col_in_foreign_table);
200✔
3172
        
3173
        $this->relations[$relation_name] = [];
192✔
3174
        $this->relations[$relation_name]['relation_type'] = \GDAO\Model::RELATION_TYPE_HAS_MANY_THROUGH;
192✔
3175
        $this->relations[$relation_name]['col_in_my_table_linked_to_join_table'] = $col_in_my_table_linked_to_join_table;
192✔
3176
        $this->relations[$relation_name]['join_table'] = $join_table;
192✔
3177
        $this->relations[$relation_name]['col_in_join_table_linked_to_my_table'] = $col_in_join_table_linked_to_my_table;
192✔
3178
        $this->relations[$relation_name]['col_in_join_table_linked_to_foreign_table'] = $col_in_join_table_linked_to_foreign_table;
192✔
3179
        $this->relations[$relation_name]['foreign_table'] = $foreign_table_name;
192✔
3180
        $this->relations[$relation_name]['col_in_foreign_table_linked_to_join_table'] = $col_in_foreign_table_linked_to_join_table;
192✔
3181
        $this->relations[$relation_name]['primary_key_col_in_foreign_table'] = $primary_key_col_in_foreign_table;
192✔
3182

3183
        $this->relations[$relation_name]['foreign_models_class_name'] = $foreign_models_class_name;
192✔
3184
        $this->relations[$relation_name]['foreign_models_record_class_name'] = $foreign_models_record_class_name;
192✔
3185
        $this->relations[$relation_name]['foreign_models_collection_class_name'] = $foreign_models_collection_class_name;
192✔
3186

3187
        $this->relations[$relation_name]['sql_query_modifier'] = $sql_query_modifier;
192✔
3188

3189
        return $this;
192✔
3190
    }
3191
    
3192
    /**
3193
     * @psalm-suppress MixedAssignment
3194
     */
3195
    protected function setRelationshipDefinitionDefaultsIfNeeded (
3196
        string &$foreign_models_class_name,
3197
        string &$foreign_table_name,
3198
        string &$primary_key_col_in_foreign_table,
3199
        string &$foreign_models_record_class_name,
3200
        string &$foreign_models_collection_class_name,
3201
    ): void {
3202
        
3203
        if($foreign_models_class_name !== '' && $foreign_models_class_name !== \LeanOrm\Model::class) {
1,316✔
3204
           
3205
            $this->validateRelatedModelClassName($foreign_models_class_name);
1,316✔
3206
            
3207
            /**
3208
             * @psalm-suppress ArgumentTypeCoercion
3209
             */
3210
            $ref_class = new \ReflectionClass($foreign_models_class_name);
1,316✔
3211
            
3212
            if($foreign_table_name === '') {
1,316✔
3213
                
3214
                // Try to set it using the default value of the table_name property 
3215
                // in the specified foreign model class $foreign_models_class_name
3216
                $reflected_foreign_table_name = 
64✔
3217
                        $ref_class->getProperty('table_name')->getDefaultValue();
64✔
3218

3219
                if($reflected_foreign_table_name === '' || $reflected_foreign_table_name === null) {
64✔
3220
                    
3221
                    $msg = "ERROR: '\$foreign_table_name' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3222
                         . $foreign_models_class_name . "'"
32✔
3223
                         . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3224

3225
                    // we can't use Reflection to figure out this table name
3226
                    throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3227
                }
3228
                
3229
                $foreign_table_name = $reflected_foreign_table_name;
32✔
3230
            }
3231
            
3232
            if($primary_key_col_in_foreign_table === '') {
1,316✔
3233

3234
                // Try to set it using the default value of the primary_col property 
3235
                // in the specified foreign model class $foreign_models_class_name
3236
                $reflected_foreign_primary_key_col = 
64✔
3237
                        $ref_class->getProperty('primary_col')->getDefaultValue();
64✔
3238

3239
                if($reflected_foreign_primary_key_col === '' || $reflected_foreign_primary_key_col === null) {
64✔
3240

3241
                    $msg = "ERROR: '\$primary_key_col_in_foreign_table' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3242
                         . $foreign_models_class_name . "'"
32✔
3243
                         . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3244

3245
                    // we can't use Reflection to figure out this primary key column name
3246
                    throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3247
                }
3248

3249
                // set it to the reflected value
3250
                $primary_key_col_in_foreign_table = $reflected_foreign_primary_key_col;
32✔
3251
            }
3252
            
3253
            $reflected_record_class_name = $ref_class->getProperty('record_class_name')->getDefaultValue();
1,316✔
3254
            
3255
            if(
3256
                $foreign_models_record_class_name === ''
1,316✔
3257
                && $reflected_record_class_name !== ''
1,316✔
3258
                && $reflected_record_class_name !== null
1,316✔
3259
            ) {
3260
                $foreign_models_record_class_name = $reflected_record_class_name;
32✔
3261
            }
3262
            
3263
            $reflected_collection_class_name = $ref_class->getProperty('collection_class_name')->getDefaultValue();
1,316✔
3264
            
3265
            if(
3266
                $foreign_models_collection_class_name === ''
1,316✔
3267
                && $reflected_collection_class_name !== ''
1,316✔
3268
                && $reflected_collection_class_name !== null
1,316✔
3269
            ) {
3270
                $foreign_models_collection_class_name = $reflected_collection_class_name;
32✔
3271
            }
3272
            
3273
        } else {
3274
            
3275
            $foreign_models_class_name = \LeanOrm\Model::class;
248✔
3276
            
3277
            if($foreign_table_name === '') {
248✔
3278
                
3279
                $msg = "ERROR: '\$foreign_table_name' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3280
                     . \LeanOrm\Model::class . "'"
32✔
3281
                     . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3282
                
3283
                // we can't use Reflection to figure out this table name
3284
                // because \LeanOrm\Model->table_name has a default value of ''
3285
                throw new \LeanOrm\Exceptions\MissingRelationParamException($msg);
32✔
3286
            }
3287
            
3288
            // $foreign_table_name !== '' if we got this far
3289
            if($primary_key_col_in_foreign_table === '') {
216✔
3290

3291
                $msg = "ERROR: '\$primary_key_col_in_foreign_table' cannot be empty when  '\$foreign_models_class_name' has a value of '"
32✔
3292
                     . \LeanOrm\Model::class . "'"
32✔
3293
                     . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' . PHP_EOL;
32✔
3294

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

3299
            }
3300
        } // if($foreign_models_class_name !== '' && $foreign_models_class_name !== \LeanOrm\Model::class)
3301
        
3302
        if($foreign_models_record_class_name === '') {
1,316✔
3303
            
3304
            $foreign_models_record_class_name = \LeanOrm\Model\Record::class;
184✔
3305
        }
3306
        
3307
        if($foreign_models_collection_class_name === '') {
1,316✔
3308
            
3309
            $foreign_models_collection_class_name = \LeanOrm\Model\Collection::class;
184✔
3310
        }
3311
    }
3312
    
3313
    protected function checkThatRelationNameIsNotAnActualColumnName(string $relationName): void {
3314

3315
        $tableCols = $this->getTableColNames();
1,316✔
3316

3317

3318
        $tableColsLowerCase = array_map(strtolower(...), $tableCols);
1,316✔
3319

3320
        if( in_array(strtolower($relationName), $tableColsLowerCase) ) {
1,316✔
3321

3322
            //Error trying to add a relation whose name collides with an actual
3323
            //name of a column in the db table associated with this model.
3324
            $msg = sprintf("ERROR: You cannont add a relationship with the name '%s' ", $relationName)
32✔
3325
                 . " to the Model (".static::class."). The database table "
32✔
3326
                 . sprintf(" '%s' associated with the ", $this->getTableName())
32✔
3327
                 . " model (".static::class.") already contains"
32✔
3328
                 . " a column with the same name."
32✔
3329
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
32✔
3330
                 . PHP_EOL;
32✔
3331

3332
            throw new \GDAO\Model\RecordRelationWithSameNameAsAnExistingDBTableColumnNameException($msg);
32✔
3333
        } // if( in_array(strtolower($relationName), $tableColsLowerCase) ) 
3334
    }
3335
    
3336
    /**
3337
     * @psalm-suppress PossiblyUnusedReturnValue
3338
     */
3339
    protected function validateTableName(string $table_name): bool {
3340
        
3341
        if(!$this->tableExistsInDB($table_name)) {
1,316✔
3342
            
3343
            //throw exception
3344
            $msg = "ERROR: The specified table `{$table_name}` does not exist in the DB."
40✔
3345
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
40✔
3346
                 . PHP_EOL;
40✔
3347
            throw new \LeanOrm\Exceptions\BadModelTableNameException($msg);
40✔
3348
        } // if(!$this->tableExistsInDB($table_name))
3349
        
3350
        return true;
1,316✔
3351
    }
3352
    
3353
    /**
3354
     * @psalm-suppress PossiblyUnusedReturnValue
3355
     */
3356
    protected function validateThatTableHasColumn(string $table_name, string $column_name): bool {
3357
        
3358
        if(!$this->columnExistsInDbTable($table_name, $column_name)) {
1,316✔
3359

3360
            //throw exception
3361
            $msg = "ERROR: The specified table `{$table_name}` in the DB"
112✔
3362
                 . " does not contain the specified column `{$column_name}`."
112✔
3363
                 . PHP_EOL . static::class . '::' . __FUNCTION__ . '(...).' 
112✔
3364
                 . PHP_EOL;
112✔
3365
            throw new \LeanOrm\Exceptions\BadModelColumnNameException($msg);
112✔
3366
        } // if(!$this->columnExistsInDbTable($table_name, $column_name))
3367
        
3368
        return true;
1,316✔
3369
    }
3370
}
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