Completed
Push — master ( 01431d...84da40 )
by Terry
03:06
created

FluentPdoModel::getColumnMeta()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 4
nop 2
dl 0
loc 15
rs 9.4285
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
/**
3
 * @package Terah\FluentPdoModel
4
 *
5
 * Original work Copyright (c) 2014 Mardix (http://github.com/mardix)
6
 * Modified work Copyright (c) 2015 Terry Cullen (http://github.com/terah)
7
 *
8
 * Licensed under The MIT License
9
 * For full copyright and license information, please see the LICENSE.txt
10
 * Redistributions of files must retain the above copyright notice.
11
 *
12
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
13
 */
14
namespace Terah\FluentPdoModel;
15
16
use Closure;
17
use PDOException;
18
use Exception;
19
use PDO;
20
use PDOStatement;
21
use stdClass;
22
use DateTime;
23
use Terah\FluentPdoModel\Drivers\AbstractPdo;
24
use Psr\Log\LoggerInterface;
25
use Terah\RedisCache\CacheInterface;
26
use function Terah\Assert\Assert;
27
use function Terah\Assert\Validate;
28
/**
29
 * Class FluentPdoModel
30
 *
31
 * @package Terah\FluentPdoModel
32
 * @author  Terry Cullen - [email protected]
33
 */
34
class FluentPdoModel
35
{
36
    const ACTIVE                        = 1;
37
    const INACTIVE                      = 0;
38
    const ARCHIVED                      = -1;
39
    const GZIP_PREFIX                   = 'gzipped|';
40
    const OPERATOR_AND              = ' AND ';
41
    const OPERATOR_OR               = ' OR ';
42
    const ORDERBY_ASC               = 'ASC';
43
    const ORDERBY_DESC              = 'DESC';
44
    const SAVE_INSERT               = 'INSERT';
45
    const SAVE_UPDATE               = 'UPDATE';
46
    const LEFT_JOIN                 = 'LEFT';
47
    const INNER_JOIN                = 'INNER';
48
    const ONE_DAY                   = 86400;
49
    const ONE_WEEK                  = 60480060;
50
    const ONE_HOUR                  = 3600;
51
    const TEN_MINS                  = 600;
52
    const CACHE_NO                  = -1;
53
    const CACHE_DEFAULT             = 0;
54
55
    /** @var AbstractPdo $_connection */
56
    protected $_connection          = null;
57
58
    /** @var string */
59
    protected $_primary_key         = 'id';
60
61
    /** @var array */
62
    protected $_where_parameters    = [];
63
64
    /** @var array */
65
    protected $_select_fields       = [];
66
67
    /** @var array */
68
    protected $_join_sources        = [];
69
70
    /** @var array */
71
    protected $_join_aliases        = [];
72
73
    /** @var array $_associations */
74
    protected $_associations        = [
75
        'belongsTo' => [],
76
    ];
77
78
    /** @var array */
79
    protected $_where_conditions    = [];
80
81
    protected $_raw_sql             = '';
82
83
    /** @var int */
84
    protected $_limit               = 0;
85
86
    /** @var int */
87
    protected $_offset              = 0;
88
89
    /** @var array */
90
    protected $_order_by            = [];
91
92
    /** @var array */
93
    protected $_group_by            = [];
94
95
    /** @var string */
96
    protected $_and_or_operator     = self::OPERATOR_AND;
97
98
    /** @var array */
99
    protected $_having              = [];
100
101
    /** @var bool */
102
    protected $_wrap_open           = false;
103
104
    /** @var int */
105
    protected $_last_wrap_position  = 0;
106
107
    /** @var PDOStatement $_pdo_stmt */
108
    protected $_pdo_stmt            = null;
109
110
    /** @var bool */
111
    protected $_distinct            = false;
112
113
    /** @var null */
114
    protected $_requested_fields    = [];
115
116
    /** @var null */
117
    protected $_filter_meta         = [];
118
119
    /** @var bool */
120
    protected $_log_queries         = false;
121
122
    /** @var array */
123
    protected $_timer               = [];
124
125
    /** @var int */
126
    protected $_slow_query_secs     = 5;
127
128
    /** @var array */
129
    protected $_pagination_attribs  = [
130
        '_limit',
131
        '_offset',
132
        '_order',
133
        '_fields',
134
        '_search'
135
    ];
136
137
    /** @var string $_table_name */
138
    protected $_table_name          = '';
139
140
    /** @var string $_table_alias */
141
    protected $_table_alias         = '';
142
143
    /** @var string $_display_column */
144
    protected $_display_column      = '';
145
146
    /** @var string $_connection_name */
147
    protected $_connection_name     = '';
148
149
    /** @var array $_schema */
150
    protected $_schema              = [];
151
152
    /** @var array $_virtual_fields */
153
    protected $_virtual_fields      = [];
154
155
    /** @var array $_errors */
156
    protected $_errors              = [];
157
158
    /**
159
     * @var int - true  = connection default x days
160
     *          - false = no cache
161
     *          - int   = a specific amount
162
     */
163
    protected $_cache_ttl               = self::CACHE_NO;
164
165
    /** @var string */
166
    protected $_tmp_table_prefix        = 'tmp_';
167
168
    /** @var null|string */
169
    protected $_built_query             = '';
170
171
    /** @var array  */
172
    protected $_handlers                = [];
173
174
    /** @var bool User want to directly specify the fields */
175
    protected $_explicit_select_mode    = false;
176
177
    /** @var string[] */
178
    protected $_update_raw              = [];
179
180
    protected $_max_callback_failures   = -1;
181
182
    protected $_num_callback_failures   = 0;
183
184
    protected $_filter_on_fetch         = false;
185
186
    protected $_log_filter_changes      = true;
187
188
    protected $_include_count           = false;
189
190
    /** @var string */
191
    static protected $_model_namespace  = '';
192
193
    /** @var bool */
194
    protected $_validation_exceptions   = true;
195
196
    /** @var array */
197
    protected $_paging_meta             = [];
198
199
    /** @var bool */
200
    protected $_soft_deletes            = true;
201
202
203
    /** @var bool  */
204
    protected $_allow_meta_override     = false;
205
206
    /** @var int  */
207
    protected $_default_max             = 250;
208
209
    /** @var array  */
210
    protected $removeUnauthorisedFields = [];
211
212
    protected $_can_generic_update      = true;
213
    protected $_can_generic_add         = true;
214
    protected $_can_generic_delete      = true;
215
216
    /** @var  array */
217
    protected $row_meta_data            = [];
218
219
    /** @var array  */
220
    protected $globalRemoveUnauthorisedFields = [
221
        '/global_table_meta#view' => [
222
            'created_by_id',
223
            'created_by',
224
            'created_ts',
225
            'modified_by_id',
226
            'modified_by',
227
            'modified_ts',
228
            'status',
229
        ],
230
    ];
231
232
233
    /**
234
     * @param AbstractPdo|null $connection
235
     */
236
    public function __construct(AbstractPdo $connection=null)
237
    {
238
        $connection         = $connection ?: ConnectionPool::get($this->_connection_name);
239
        $this->_connection  = $connection;
240
        $this->_log_queries = $connection->logQueries();
241
        $this->init();
242
    }
243
244
    public function init()
245
    {}
246
247
    /**
248
     * @return AbstractPdo
249
     * @throws Exception
250
     */
251
    public function getPdo() : AbstractPdo
252
    {
253
        return $this->_connection;
254
    }
255
256
    /**
257
     * @return LoggerInterface
258
     */
259
    public function getLogger() : LoggerInterface
260
    {
261
        return $this->_connection->getLogger();
262
    }
263
264
    /**
265
     * @return CacheInterface
266
     */
267
    public function getCache() : CacheInterface
268
    {
269
        return $this->_connection->getCache();
270
    }
271
272
    /**
273
     * Define the working table and create a new instance
274
     *
275
     * @param string $tableName - Table name
276
     * @param string $alias     - The table alias name
277
     * @param string $displayColumn
278
     * @param string $primaryKeyName
279
     *
280
     * @return FluentPdoModel|$this
281
     */
282
    public function table(string $tableName, string $alias='', string $displayColumn='', string $primaryKeyName='id') : FluentPdoModel
283
    {
284
        return $this->reset()
285
            ->tableName($tableName)
286
            ->tableAlias($alias)
287
            ->displayColumn($displayColumn)
288
            ->primaryKeyName($primaryKeyName);
289
    }
290
291
    /**
292
     * @param string $primaryKeyName
293
     * @return FluentPdoModel|$this
294
     */
295
    public function primaryKeyName(string $primaryKeyName) : FluentPdoModel
296
    {
297
        $this->_primary_key = $primaryKeyName;
298
299
        return $this;
300
    }
301
302
    /**
303
     * @param string $tableName
304
     *
305
     * @return FluentPdoModel|$this
306
     */
307
    public function tableName(string $tableName) : FluentPdoModel
308
    {
309
        $this->_table_name  = $tableName;
310
311
        return $this;
312
    }
313
314
    /**
315
     * @param $explicitSelect
316
     *
317
     * @return FluentPdoModel|$this
318
     */
319
    public function explicitSelectMode(bool $explicitSelect=true) : FluentPdoModel
320
    {
321
        $this->_explicit_select_mode  = (bool)$explicitSelect;
322
323
        return $this;
324
    }
325
326
    /**
327
     * @param bool $filterOnFetch
328
     *
329
     * @return FluentPdoModel|$this
330
     */
331
    public function filterOnFetch(bool $filterOnFetch=true) : FluentPdoModel
332
    {
333
        $this->_filter_on_fetch  = (bool)$filterOnFetch;
334
335
        return $this;
336
    }
337
338
    /**
339
     * @param bool $logFilterChanges
340
     *
341
     * @return FluentPdoModel|$this
342
     */
343
    public function logFilterChanges(bool $logFilterChanges=true) : FluentPdoModel
344
    {
345
        $this->_log_filter_changes  = (bool)$logFilterChanges;
346
347
        return $this;
348
    }
349
350
    /**
351
     * Return the name of the table
352
     *
353
     * @return string
354
     */
355
    public function getTableName() : string
356
    {
357
        return $this->_table_name;
358
    }
359
360
    /**
361
     * @return string
362
     */
363
    public function getDisplayColumn() : string
364
    {
365
        return $this->_display_column;
366
    }
367
368
    /**
369
     * Set the display column
370
     *
371
     * @param string $column
372
     *
373
     * @return FluentPdoModel|$this
374
     */
375
    public function displayColumn(string $column) : FluentPdoModel
376
    {
377
        $this->_display_column = $column;
378
379
        return $this;
380
    }
381
    /**
382
     * Set the table alias
383
     *
384
     * @param string $alias
385
     *
386
     * @return FluentPdoModel|$this
387
     */
388
    public function tableAlias(string $alias) : FluentPdoModel
389
    {
390
        $this->_table_alias = $alias;
391
392
        return $this;
393
    }
394
395
    /**
396
     * @param int $cacheTtl
397
     * @return FluentPdoModel|$this
398
     * @throws Exception
399
     */
400
    protected function _cacheTtl(int $cacheTtl) : FluentPdoModel
401
    {
402
        Assert($cacheTtl)->int('Cache ttl must be either -1 for no cache, 0 for default ttl or an integer for a custom ttl');
403
        if ( $cacheTtl !== self::CACHE_NO && ! is_null($this->_pdo_stmt) )
404
        {
405
            throw new Exception("You cannot cache pre-executed queries");
406
        }
407
        $this->_cache_ttl = $cacheTtl;
408
409
        return $this;
410
    }
411
412
    /**
413
     * @return string
414
     */
415
    public function getTableAlias() : string
416
    {
417
        return $this->_table_alias;
418
    }
419
420
    /**
421
     * @param array $associations
422
     *
423
     * @return FluentPdoModel|$this
424
     */
425
    public function associations(array $associations) : FluentPdoModel
426
    {
427
        $this->_associations = $associations;
428
429
        return $this;
430
    }
431
432
    /**
433
     * @param string $alias
434
     * @param array $definition
435
     * @return FluentPdoModel|$this
436
     */
437
    public function setBelongsTo(string $alias, array $definition) : FluentPdoModel
438
    {
439
        Assert($alias)->notEmpty();
440
        Assert($definition)->isArray()->count(4);
441
442
        $this->_associations['belongsTo'][$alias] = $definition;
443
444
        return $this;
445
    }
446
447
    /**
448
     * @param $alias
449
     * @param $displayField
450
     * @return FluentPdoModel|$this
451
     * @throws \Terah\Assert\AssertionFailedException
452
     */
453
    public function setBelongsToDisplayField(string $alias, string $displayField) : FluentPdoModel
454
    {
455
        Assert($alias)->notEmpty();
456
        Assert($this->_associations['belongsTo'])->keyExists($alias);
457
        Assert($displayField)->notEmpty();
458
459
        $this->_associations['belongsTo'][$alias][2] = $displayField;
460
461
        return $this;
462
    }
463
464
    /**
465
     * @param PDOStatement $stmt
466
     *
467
     * @param PDOStatement $stmt
468
     * @param Closure $fnCallback
469
     * @return bool|stdClass
470
     */
471
    public function fetchRow(PDOStatement $stmt, Closure $fnCallback=null)
472
    {
473
        if ( ! ( $record = $stmt->fetch(PDO::FETCH_OBJ) ) )
474
        {
475
            $this->row_meta_data    = [];
476
477
            return false;
478
        }
479
        $this->row_meta_data  = $this->row_meta_data  ?: $this->getColumnMeta($stmt, $record);
480
        $record = $this->onFetch($record);
481
        if ( empty($fnCallback) )
482
        {
483
            return $record;
484
        }
485
        $record             = $fnCallback($record);
486
        if ( is_null($record) )
487
        {
488
            $this->getLogger()->warning("The callback is not returning any data which might be causing early termination of the result iteration");
489
        }
490
        unset($fnCallback);
491
492
        return $record;
493
    }
494
495
    /**
496
     * @param PDOStatement $stmt
497
     * @param $record
498
     * @return array
499
     */
500
    protected function getColumnMeta(PDOStatement $stmt, $record) : array
501
    {
502
        $meta                   = [];
503
        foreach(range(0, $stmt->columnCount() - 1) as $index)
504
        {
505
            $data                   = $stmt->getColumnMeta($index);
506
            $meta[$data['name']]    = $data;
507
        }
508
        foreach ( $record as $field => $value )
509
        {
510
            Assert($meta)->keyExists($field);
511
        }
512
513
        return $meta;
514
    }
515
516
    /**
517
     * @param array $schema
518
     *
519
     * @return FluentPdoModel|$this
520
     */
521
    public function schema(array $schema) : FluentPdoModel
522
    {
523
        $this->_schema = $schema;
524
525
        return $this;
526
    }
527
528
    /**
529
     * @param string|array $field
530
     * @param $type
531
     * @return FluentPdoModel|$this
532
     */
533
    public function addSchema($field, string $type) : FluentPdoModel
534
    {
535
        if ( is_array($field) )
536
        {
537
            foreach ( $field as $field_name => $type_def )
538
            {
539
                $this->addSchema($field_name, $type_def);
540
            }
541
542
            return $this;
543
        }
544
        Assert($field)->string()->notEmpty();
545
        $this->_schema[$field] = $type;
546
547
        return $this;
548
    }
549
550
    /**
551
     * @param $keysOnly
552
     * @return array
553
     */
554
    public function getColumns(bool $keysOnly=true) : array
555
    {
556
        return $keysOnly ? array_keys($this->_schema) : $this->_schema;
557
    }
558
559
    /**
560
     * Get the primary key name
561
     *
562
     * @return string
563
     */
564
    public function getPrimaryKeyName() : string
565
    {
566
        return $this->_formatKeyName($this->_primary_key, $this->_table_name);
567
    }
568
569
    /**
570
     * @param string $query
571
     * @param array $parameters
572
     *
573
     * @return bool
574
     * @throws Exception
575
     */
576
    public function execute(string $query, array $parameters=[]) : bool
577
    {
578
        list($this->_built_query, $ident)  = $this->_logQuery($query, $parameters);
579
        try
580
        {
581
            $this->_pdo_stmt        = $this->getPdo()->prepare($query);
582
            $result                 = $this->_pdo_stmt->execute($parameters);
583
            if ( false === $result )
584
            {
585
                $this->_pdo_stmt        = null;
586
                throw new PDOException("The query failed to execute.");
587
            }
588
        }
589
        catch( Exception $e )
590
        {
591
            $built_query        = $this->_built_query ? $this->_built_query : $this->buildQuery($query, $parameters);
592
            $this->getLogger()->error("FAILED: \n\n{$built_query}\n WITH ERROR:\n" . $e->getMessage());
593
            $this->_pdo_stmt    = null;
594
595
            throw $e;
596
        }
597
        $this->_logSlowQueries($ident, $this->_built_query);
598
599
        return $result;
600
    }
601
602
    /**
603
     * @param string $query
604
     * @param array $params
605
     * @return FluentPdoModel|$this
606
     */
607
    public function query(string $query, array $params=[]) : FluentPdoModel
608
    {
609
        $this->_raw_sql             = $query;
610
        $this->_where_parameters    = $params;
611
612
        return $this;
613
    }
614
615
    /**
616
     * @param string $sql
617
     * @param array $params
618
     *
619
     * @return string
620
     */
621
    public function buildQuery(string $sql, array $params=[]) : string
622
    {
623
        $indexed = $params == array_values($params);
624
        if ( $indexed )
625
        {
626
            foreach ( $params as $key => $val )
627
            {
628
                $val    = is_string($val) ? "'{$val}'" : $val;
629
                $val    = is_null($val) ? 'NULL' : $val;
630
                $sql    = preg_replace('/\?/', $val, $sql, 1);
631
            }
632
633
            return $sql;
634
        }
635
636
        uksort($params, function ($a, $b) {
637
            return strlen($b) - strlen($a);
638
        });
639
        foreach ( $params as $key => $val )
640
        {
641
            $val    = is_string($val) ? "'{$val}'" : $val;
642
            $val    = is_null($val) ? 'NULL' : $val;
643
            $sql    = str_replace(":$key", $val, $sql);
644
            //$sql    = str_replace("$key", $val, $sql);
645
        }
646
647
        return $sql;
648
    }
649
650
    /**
651
     * @param stdClass $record
652
     *
653
     * @return stdClass
654
     */
655
    protected function _trimAndLowerCaseKeys(stdClass $record) : stdClass
656
    {
657
        $fnTrimStrings = function($value) {
658
            return is_string($value) ? trim($value) : $value;
659
        };
660
        $record = array_map($fnTrimStrings, array_change_key_case((array)$record, CASE_LOWER));
661
        unset($fnTrimStrings);
662
663
        return (object)$record;
664
    }
665
666
    /**
667
     * Return the number of affected row by the last statement
668
     *
669
     * @return int
670
     */
671
    public function rowCount() : int
672
    {
673
        $stmt = $this->fetchStmt();
674
675
        return $stmt ? $stmt->rowCount() : 0;
676
    }
677
678
    /**
679
     * @return PDOStatement
680
     * @throws PDOException
681
     */
682
    public function fetchStmt()
683
    {
684
        if ( null === $this->_pdo_stmt )
685
        {
686
            $this->execute($this->getSelectQuery(), $this->_getWhereParameters());
687
        }
688
689
        return $this->_pdo_stmt;
690
    }
691
692
    /**
693
     * @return array
694
     */
695
    public function fetchSqlQuery() : array
696
    {
697
        $clone      = clone $this;
698
        $query      = $clone->getSelectQuery();
699
        $params     = $clone->_getWhereParameters();
700
        $result     = [$query, $params];
701
        unset($clone->_handlers, $clone, $query, $params);
702
703
        return $result;
704
    }
705
706
    /**
707
     * @param string $tableName
708
     * @param bool  $dropIfExists
709
     * @param array $indexes
710
     * @return boolean
711
     * @throws Exception
712
     */
713
    public function fetchIntoMemoryTable(string $tableName, bool $dropIfExists=true, array $indexes=[]) : bool
714
    {
715
        $tableName      = preg_replace('/[^A-Za-z0-9_]+/', '', $tableName);
716
        $tableName      = $this->_tmp_table_prefix . preg_replace('/^' . $this->_tmp_table_prefix . '/', '', $tableName);
717
        if ( $dropIfExists )
718
        {
719
            $this->execute("DROP TABLE IF EXISTS {$tableName}");
720
        }
721
        $indexSql = [];
722
        foreach ( $indexes as $name => $column )
723
        {
724
            $indexSql[] = "INDEX {$name} ({$column})";
725
        }
726
        $indexSql       = implode(", ", $indexSql);
727
        $indexSql       = empty($indexSql) ? '' : "({$indexSql})";
728
        list($querySql, $params) = $this->fetchSqlQuery();
729
        $sql = <<<SQL
730
        CREATE TEMPORARY TABLE {$tableName} {$indexSql} ENGINE=MEMORY {$querySql}
731
SQL;
732
733
        return $this->execute($sql, $params);
734
    }
735
736
    /**
737
     * @param string $keyedOn
738
     * @param int $cacheTtl
739
     * @return stdClass[]
740
     */
741
    public function fetch(string $keyedOn='', int $cacheTtl=self::CACHE_NO) : array
742
    {
743
        $this->_cacheTtl($cacheTtl);
744
        $fnCallback         = function() use ($keyedOn) {
745
746
            $stmt               = $this->fetchStmt();
747
            $rows               = [];
748
            while ( $record = $this->fetchRow($stmt) )
749
            {
750
                if ( $record === false ) continue; // For scrutinizer...
751
                if ( $keyedOn && property_exists($record, $keyedOn) )
752
                {
753
                    $rows[$record->{$keyedOn}] = $record;
754
                    continue;
755
                }
756
                $rows[]         = $record;
757
            }
758
            $this->reset();
759
760
            return $rows;
761
        };
762
        if ( $this->_cache_ttl === self::CACHE_NO )
763
        {
764
            return $fnCallback();
765
        }
766
        $table              = $this->getTableName();
767
        $id                 = $this->_parseWhereForPrimaryLookup();
768
        $id                 = $id ? "/{$id}" : '';
769
        list($sql, $params) = $this->fetchSqlQuery();
770
        $sql                = $this->buildQuery($sql, $params);
771
        $cacheKey           = "/{$table}{$id}/" . md5(json_encode([
772
            'sql'       => $sql,
773
            'keyed_on'  => $keyedOn,
774
        ]));
775
        $data = $this->_cacheData($cacheKey, $fnCallback, $this->_cache_ttl);
776
777
        return is_array($data) ? $data : [];
778
    }
779
780
    /**
781
     * @return string
782
     */
783
    protected function _parseWhereForPrimaryLookup() : string
784
    {
785
        if ( ! ( $alias = $this->getTableAlias() ) )
786
        {
787
            return '';
788
        }
789
        foreach ( $this->_where_conditions as $idx => $conds )
790
        {
791
            if ( ! empty($conds['STATEMENT']) && $conds['STATEMENT'] === "{$alias}.id = ?" )
792
            {
793
                return ! empty($conds['PARAMS'][0]) ? (string)$conds['PARAMS'][0] : '';
794
            }
795
        }
796
797
        return '';
798
    }
799
800
    /**
801
     * @param string $cacheKey
802
     * @param Closure $func
803
     * @param int $cacheTtl - 0 for default ttl, -1 for no cache or int for custom ttl
804
     * @return mixed
805
     */
806
    protected function _cacheData(string $cacheKey, Closure $func, int $cacheTtl=self::CACHE_DEFAULT)
807
    {
808
        if ( $cacheTtl === self::CACHE_NO )
809
        {
810
            /** @noinspection PhpVoidFunctionResultUsedInspection */
811
            return $func->__invoke();
812
        }
813
        $data = $this->getCache()->get($cacheKey);
814
        if ( $data && is_object($data) && property_exists($data, 'results') )
815
        {
816
            $this->getLogger()->debug("Cache hit on {$cacheKey}");
817
            return $data->results;
818
        }
819
        $this->getLogger()->debug("Cache miss on {$cacheKey}");
820
        /** @noinspection PhpVoidFunctionResultUsedInspection */
821
        $data = (object)[
822
            // Watch out... invoke most likely calls reset
823
            // which clears the model params like _cache_ttl
824
            'results' => $func->__invoke(),
825
        ];
826
        try
827
        {
828
            // The cache engine expects null for the default cache value
829
            $cacheTtl = $cacheTtl === self::CACHE_DEFAULT ? null : $cacheTtl;
830
            /** @noinspection PhpMethodParametersCountMismatchInspection */
831
            if ( ! $this->getCache()->set($cacheKey, $data, $cacheTtl) )
832
            {
833
                throw new \Exception("Could not save data to cache");
834
            }
835
            return $data->results;
836
        }
837
        catch (\Exception $e)
838
        {
839
            $this->getLogger()->error($e->getMessage(), $e->getTrace());
840
841
            return $data->results;
842
        }
843
    }
844
845
    /**
846
     * @param string $cacheKey
847
     * @return bool
848
     */
849
    public function clearCache(string $cacheKey) : bool
850
    {
851
        return $this->getCache()->delete($cacheKey);
852
    }
853
854
    /**
855
     * @param string $table
856
     * @return bool
857
     */
858
    public function clearCacheByTable(string $table='') : bool
859
    {
860
        $table  = $table ?: $this->getTableName();
861
        if ( ! $table )
862
        {
863
            return true;
864
        }
865
866
        return $this->clearCache("/{$table}/");
867
    }
868
869
    /**
870
     * @param Closure $fnCallback
871
     * @return int
872
     */
873
    public function fetchCallback(Closure $fnCallback) : int
874
    {
875
        $successCnt    = 0;
876
        $stmt           = $this->fetchStmt();
877
        while ( $this->_tallySuccessCount($stmt, $fnCallback, $successCnt) )
878
        {}
879
880
        return $successCnt;
881
    }
882
883
    /**
884
     * @param Closure $fnCallback
885
     * @param string  $keyedOn
886
     * @return array
887
     */
888
    public function fetchObjectsByCallback(Closure $fnCallback, string $keyedOn='') : array
889
    {
890
        $stmt       = $this->fetchStmt();
891
        $rows       = [];
892
        while ( $record = $this->fetchRow($stmt, $fnCallback) )
893
        {
894
            if ( $keyedOn && property_exists($record, $keyedOn) )
895
            {
896
                $rows[$record->{$keyedOn}] = $record;
897
                continue;
898
            }
899
            $rows[] = $record;
900
        }
901
        $this->reset();
902
903
        return $rows;
904
    }
905
906
    /**
907
     * @param $numFailures
908
     * @return FluentPdoModel|$this
909
     */
910
    public function maxCallbackFailures(int $numFailures) : FluentPdoModel
911
    {
912
        Assert($numFailures)->int();
913
        $this->_max_callback_failures = $numFailures;
914
915
        return $this;
916
    }
917
918
    /**
919
     * @param PDOStatement $stmt
920
     * @param Closure $fnCallback
921
     * @param int $successCnt
922
     * @return bool|null|stdClass
923
     */
924
    protected function _tallySuccessCount(PDOStatement $stmt, Closure $fnCallback, int &$successCnt)
925
    {
926
        $record = $this->fetchRow($stmt);
927
        if ( $record === false )
928
        {
929
            return false;
930
        }
931
        $record = $fnCallback($record);
932
        // Callback return null then we want to exit the fetch loop
933
        if ( is_null($record) )
934
        {
935
            $this->getLogger()->warning("The callback is not returning any data which might be causing early termination of the result iteration");
936
            return null;
937
        }
938
        // The not record then don't bump the tally
939
        if ( ! $record )
940
        {
941
            $this->_num_callback_failures++;
942
            if ( $this->_max_callback_failures !== -1 && $this->_num_callback_failures >= $this->_max_callback_failures )
943
            {
944
                $this->getLogger()->error("The callback has failed {$this->_max_callback_failures} times... aborting...");
945
                $successCnt = null;
946
                return null;
947
            }
948
949
            return true;
950
        }
951
        $successCnt++;
952
953
        return $record;
954
    }
955
956
    /**
957
     * @return bool
958
     */
959
    public function canGenericUpdate() : bool
960
    {
961
        return $this->_can_generic_update;
962
    }
963
964
    /**
965
     * @return bool
966
     */
967
    public function canGenericAdd() : bool
968
    {
969
        return $this->_can_generic_add;
970
    }
971
972
    /**
973
     * @return bool
974
     */
975
    public function canGenericDelete() : bool
976
    {
977
        return $this->_can_generic_delete;
978
    }
979
980
    /**
981
     * @param string $keyedOn
982
     * @param string $valueField
983
     * @param int $cacheTtl
984
     * @return mixed
985
     */
986
    public function fetchList(string $keyedOn='', string $valueField='', int $cacheTtl=self::CACHE_NO) : array
987
    {
988
        $keyedOn            = $keyedOn ?: $this->getPrimaryKeyName();
989
        $valueField         = $valueField ?: $this->getDisplayColumn();
990
        $keyedOnAlias       = strtolower(str_replace('.', '_', $keyedOn));
991
        $valueFieldAlias    = strtolower(str_replace('.', '_', $valueField));
992
        if ( preg_match('/ as /i', $keyedOn) )
993
        {
994
            list($keyedOn, $keyedOnAlias)   = preg_split('/ as /i', $keyedOn);
995
            $keyedOn                        = trim($keyedOn);
996
            $keyedOnAlias                   = trim($keyedOnAlias);
997
        }
998
        if ( preg_match('/ as /i', $valueField) )
999
        {
1000
            list($valueField, $valueFieldAlias) = preg_split('/ as /i', $valueField);
1001
            $valueField                         = trim($valueField);
1002
            $valueFieldAlias                    = trim($valueFieldAlias);
1003
        }
1004
1005
        $this->_cacheTtl($cacheTtl);
1006
        $fnCallback         = function() use ($keyedOn, $keyedOnAlias, $valueField, $valueFieldAlias) {
1007
1008
            $rows = [];
1009
            $stmt = $this->select(null)
1010
                ->select($keyedOn, $keyedOnAlias)
1011
                ->select($valueField, $valueFieldAlias)
1012
                ->fetchStmt();
1013
            while ( $record = $this->fetchRow($stmt) )
1014
            {
1015
                $rows[$record->{$keyedOnAlias}] = $record->{$valueFieldAlias};
1016
            }
1017
1018
            return $rows;
1019
        };
1020
        if ( $this->_cache_ttl === self::CACHE_NO )
1021
        {
1022
            $result = $fnCallback();
1023
            unset($cacheKey, $fnCallback);
1024
1025
            return $result;
1026
        }
1027
        $table              = $this->getTableName();
1028
        $cacheKey           = md5(json_encode([
1029
            'sql'               => $this->fetchSqlQuery(),
1030
            'keyed_on'          => $keyedOn,
1031
            'keyed_on_alias'    => $keyedOnAlias,
1032
            'value_field'       => $valueField,
1033
            'value_fieldAlias'  => $valueFieldAlias,
1034
        ]));
1035
1036
        return $this->_cacheData("/{$table}/list/{$cacheKey}", $fnCallback, $this->_cache_ttl);
1037
    }
1038
1039
    /**
1040
     * @param string $column
1041
     * @param int $cacheTtl
1042
     * @param bool|true $unique
1043
     * @return array
1044
     */
1045
    public function fetchColumn(string $column, int $cacheTtl=self::CACHE_NO, bool $unique=true) : array
1046
    {
1047
        $list = $this->select($column)->fetch('', $cacheTtl);
1048
        foreach ( $list as $idx => $obj )
1049
        {
1050
            $list[$idx] = $obj->{$column};
1051
        }
1052
        return $unique ? array_unique($list) : $list;
1053
    }
1054
1055
    /**
1056
     * @param string $field
1057
     * @param int $itemId
1058
     * @param int $cacheTtl
1059
     * @return mixed|null
1060
     */
1061
    public function fetchField(string $field='', int $itemId=0, int $cacheTtl=self::CACHE_NO)
1062
    {
1063
        $field      = $field ?: $this->getPrimaryKeyName();
1064
        $object     = $this->select(null)->select($field)->fetchOne($itemId, $cacheTtl);
1065
        if ( ! $object )
1066
        {
1067
            return null;
1068
        }
1069
        // Handle aliases
1070
        if ( preg_match('/ as /i', $field) )
1071
        {
1072
            list($expression, $alias) = preg_split('/ as /i', $field);
1073
            unset($expression);
1074
            $field = trim($alias);
1075
        }
1076
        if ( strpos($field, '.') !== false )
1077
        {
1078
            list($tableAlias, $field) = explode('.', $field);
1079
            unset($tableAlias);
1080
        }
1081
1082
        return property_exists($object, $field) ? $object->{$field} : null;
1083
    }
1084
1085
    /**
1086
     * @param string $field
1087
     * @param int $itemId
1088
     * @param int $cacheTtl
1089
     * @return string
1090
     */
1091
    public function fetchStr(string $field='', $itemId=0, int $cacheTtl=self::CACHE_NO) : string
1092
    {
1093
        return (string)$this->fetchField($field, $itemId, $cacheTtl);
1094
    }
1095
1096
    /**
1097
     * @param int $cacheTtl
1098
     * @return int
1099
     */
1100
    public function fetchId(int $cacheTtl=self::CACHE_NO) : int
1101
    {
1102
        return $this->fetchInt($this->getPrimaryKeyName(), 0, $cacheTtl);
1103
    }
1104
1105
    /**
1106
     * @param string $field
1107
     * @param int $itemId
1108
     * @param int $cacheTtl
1109
     * @return int
1110
     */
1111
    public function fetchInt(string $field='', int $itemId=0, int $cacheTtl=self::CACHE_NO) : int
1112
    {
1113
        return (int)$this->fetchField($field, $itemId, $cacheTtl);
1114
    }
1115
1116
    /**
1117
     * @param string $field
1118
     * @param int $itemId
1119
     * @param int $cacheTtl
1120
     * @return float
1121
     */
1122
    public function fetchFloat(string $field='', int $itemId=0, int $cacheTtl=self::CACHE_NO) : float
1123
    {
1124
        return (float)$this->fetchField($field, $itemId, $cacheTtl);
1125
    }
1126
1127
    /**
1128
     * @param string $field
1129
     * @param int $itemId
1130
     * @param int $cacheTtl
1131
     * @return bool
1132
     */
1133
    public function fetchBool(string $field='', int $itemId=0, int $cacheTtl=self::CACHE_NO) : bool
1134
    {
1135
        return (bool)$this->fetchField($field, $itemId, $cacheTtl);
1136
    }
1137
1138
    /**
1139
     * @param int|null $id
1140
     * @param int $cacheTtl
1141
     * @return stdClass|bool
1142
     */
1143
    public function fetchOne(int $id=0, int $cacheTtl=self::CACHE_NO)
1144
    {
1145
        if ( $id > 0 )
1146
        {
1147
            $this->wherePk($id, true);
1148
        }
1149
        $this->limit(1);
1150
        $fetchAll   = $this->fetch('', $cacheTtl);
1151
1152
        return $fetchAll ? array_shift($fetchAll) : false;
1153
    }
1154
1155
    /**
1156
     * @param int|null $id
1157
     * @param int $cacheTtl
1158
     * @return boolean
1159
     */
1160
    public function fetchExists(int $id=0, int $cacheTtl=self::CACHE_NO) : bool
1161
    {
1162
        if ( $id > 0 )
1163
        {
1164
            $this->wherePk($id, true);
1165
        }
1166
1167
        return $this->count('*', $cacheTtl) !== 0;
1168
    }
1169
1170
    /*------------------------------------------------------------------------------
1171
                                    Fluent Query Builder
1172
    *-----------------------------------------------------------------------------*/
1173
1174
    /**
1175
     * Create the select clause
1176
     *
1177
     * @param  mixed    $columns  - the column to select. Can be string or array of fields
1178
     * @param  string   $alias - an alias to the column
1179
     * @param boolean $explicitSelect
1180
     * @return FluentPdoModel|$this
1181
     */
1182
    public function select($columns='*', string $alias='', bool $explicitSelect=true) : FluentPdoModel
1183
    {
1184
        if ( $explicitSelect )
1185
        {
1186
            $this->explicitSelectMode();
1187
        }
1188
        if ( $alias && ! is_array($columns) & $columns !== $alias )
1189
        {
1190
            $columns .= " AS {$alias} ";
1191
        }
1192
        if ( $columns === '*' && !empty($this->_schema) )
1193
        {
1194
            $columns = $this->getColumns();
1195
        }
1196
        // Reset the select list
1197
        if ( is_null($columns) )
1198
        {
1199
            $this->_select_fields = [];
1200
1201
            return $this;
1202
        }
1203
        $columns = is_array($columns) ? $columns : [$columns];
1204
1205
//        if ( empty($this->_select_fields) && $addAllIfEmpty )
1206
//        {
1207
//            $this->select('*');
1208
//        }
1209
        if ( $this->_table_alias )
1210
        {
1211
            $schema = $this->columns();
1212
            foreach ( $columns as $idx => $col )
1213
            {
1214
                if ( in_array($col, $schema) )
1215
                {
1216
                    $columns[$idx] = "{$this->_table_alias}.{$col}";
1217
                }
1218
            }
1219
        }
1220
        $this->_select_fields = array_merge($this->_select_fields, $columns);
1221
1222
        return $this;
1223
    }
1224
1225
    /**
1226
     * @param string $select
1227
     * @return FluentPdoModel|$this
1228
     */
1229
    public function selectRaw(string $select) : FluentPdoModel
1230
    {
1231
        $this->_select_fields[] = $select;
1232
1233
        return $this;
1234
    }
1235
1236
    /**
1237
     * @param bool $logQueries
1238
     *
1239
     * @return FluentPdoModel|$this
1240
     */
1241
    public function logQueries(bool $logQueries=true) : FluentPdoModel
1242
    {
1243
        $this->_log_queries = $logQueries;
1244
1245
        return $this;
1246
    }
1247
1248
    /**
1249
     * @param bool $includeCnt
1250
     *
1251
     * @return FluentPdoModel|$this
1252
     */
1253
    public function includeCount(bool $includeCnt=true) : FluentPdoModel
1254
    {
1255
        $this->_include_count = $includeCnt;
1256
1257
        return $this;
1258
    }
1259
1260
    /**
1261
     * @param bool $distinct
1262
     *
1263
     * @return FluentPdoModel|$this
1264
     */
1265
    public function distinct(bool $distinct=true) : FluentPdoModel
1266
    {
1267
        $this->_distinct = $distinct;
1268
1269
        return $this;
1270
    }
1271
1272
    /**
1273
     * @param array $fields
1274
     * @return FluentPdoModel|$this
1275
     */
1276
    public function withBelongsTo(array $fields=[]) : FluentPdoModel
1277
    {
1278
        if ( ! empty($this->_associations['belongsTo']) )
1279
        {
1280
            foreach ( $this->_associations['belongsTo'] as $alias => $config )
1281
            {
1282
                $addFieldsForJoins = empty($fields) || in_array($config[3], $fields);
1283
                $this->autoJoin($alias, self::LEFT_JOIN, $addFieldsForJoins);
1284
            }
1285
        }
1286
1287
        return $this;
1288
    }
1289
1290
    /**
1291
     * @param string $alias
1292
     * @param string $type
1293
     * @param bool   $addSelectField
1294
     * @return FluentPdoModel|$this
1295
     */
1296
    public function autoJoin(string $alias, string $type=self::LEFT_JOIN, bool $addSelectField=true) : FluentPdoModel
1297
    {
1298
        Assert($this->_associations['belongsTo'])->keyExists($alias, "Invalid join... the alias does not exists");
1299
        list($table, $join_col, $field, $fieldAlias) = $this->_associations['belongsTo'][$alias];
1300
        $condition = "{$alias}.id = {$this->_table_alias}.{$join_col}";
1301
        if ( in_array($alias, $this->_join_aliases) )
1302
        {
1303
            return $this;
1304
        }
1305
        $this->join($table, $condition, $alias, $type);
1306
        if ( $addSelectField )
1307
        {
1308
            $this->select($field, $fieldAlias, false);
1309
        }
1310
1311
        return $this;
1312
    }
1313
1314
    /**
1315
     * Add where condition, more calls appends with AND
1316
     *
1317
     * @param string $condition possibly containing ? or :name
1318
     * @param mixed $parameters accepted by PDOStatement::execute or a scalar value
1319
     * @param mixed ...
1320
     * @return FluentPdoModel|$this
1321
     */
1322
    public function where($condition, $parameters=[]) : FluentPdoModel
1323
    {
1324
        // By default the and_or_operator and wrap operator is AND,
1325
        if ( $this->_wrap_open || ! $this->_and_or_operator )
1326
        {
1327
            $this->_and();
1328
        }
1329
1330
        // where(array("column1" => 1, "column2 > ?" => 2))
1331
        if ( is_array($condition) )
1332
        {
1333
            foreach ($condition as $key => $val)
1334
            {
1335
                $this->where($key, $val);
1336
            }
1337
1338
            return $this;
1339
        }
1340
1341
        $args = func_num_args();
1342
        if ( $args != 2 || strpbrk((string)$condition, '?:') )
1343
        { // where('column < ? OR column > ?', array(1, 2))
1344
            if ( $args != 2 || !is_array($parameters) )
1345
            { // where('column < ? OR column > ?', 1, 2)
1346
                $parameters = func_get_args();
1347
                array_shift($parameters);
1348
            }
1349
        }
1350
        else if ( ! is_array($parameters) )
1351
        {//where(column,value) => column=value
1352
            $condition .= ' = ?';
1353
            $parameters = [$parameters];
1354
        }
1355
        else if ( is_array($parameters) )
1356
        { // where('column', array(1, 2)) => column IN (?,?)
1357
            $placeholders = $this->_makePlaceholders(count($parameters));
1358
            $condition = "({$condition} IN ({$placeholders}))";
1359
        }
1360
1361
        $this->_where_conditions[] = [
1362
            'STATEMENT'   => $condition,
1363
            'PARAMS'      => $parameters,
1364
            'OPERATOR'    => $this->_and_or_operator
1365
        ];
1366
        // Reset the where operator to AND. To use OR, you must call _or()
1367
        $this->_and();
1368
1369
        return $this;
1370
    }
1371
1372
    /**
1373
     * Create an AND operator in the where clause
1374
     *
1375
     * @return FluentPdoModel|$this
1376
     */
1377
    public function _and() : FluentPdoModel
1378
    {
1379
        if ( $this->_wrap_open )
1380
        {
1381
            $this->_where_conditions[] = self::OPERATOR_AND;
1382
            $this->_last_wrap_position = count($this->_where_conditions);
1383
            $this->_wrap_open = false;
1384
1385
            return $this;
1386
        }
1387
        $this->_and_or_operator = self::OPERATOR_AND;
1388
1389
        return $this;
1390
    }
1391
1392
1393
    /**
1394
     * Create an OR operator in the where clause
1395
     *
1396
     * @return FluentPdoModel|$this
1397
     */
1398
    public function _or() : FluentPdoModel
1399
    {
1400
        if ( $this->_wrap_open )
1401
        {
1402
            $this->_where_conditions[]  = self::OPERATOR_OR;
1403
            $this->_last_wrap_position  = count($this->_where_conditions);
1404
            $this->_wrap_open           = false;
1405
1406
            return $this;
1407
        }
1408
        $this->_and_or_operator     = self::OPERATOR_OR;
1409
1410
        return $this;
1411
    }
1412
1413
    /**
1414
     * To group multiple where clauses together.
1415
     *
1416
     * @return FluentPdoModel|$this
1417
     */
1418
    public function wrap() : FluentPdoModel
1419
    {
1420
        $this->_wrap_open           = true;
1421
        $spliced                    = array_splice($this->_where_conditions, $this->_last_wrap_position, count($this->_where_conditions), '(');
1422
        $this->_where_conditions    = array_merge($this->_where_conditions, $spliced);
1423
        array_push($this->_where_conditions,')');
1424
        $this->_last_wrap_position = count($this->_where_conditions);
1425
1426
        return $this;
1427
    }
1428
1429
    /**
1430
     * Where Primary key
1431
     *
1432
     * @param int  $id
1433
     * @param bool $addAlias
1434
     *
1435
     * @return FluentPdoModel|$this
1436
     */
1437
    public function wherePk(int $id, bool $addAlias=true) : FluentPdoModel
1438
    {
1439
        $alias = $addAlias && ! empty($this->_table_alias) ? "{$this->_table_alias}." : '';
1440
1441
        return $this->where($alias . $this->getPrimaryKeyName(), $id);
1442
    }
1443
1444
    /**
1445
     * WHERE $columnName != $value
1446
     *
1447
     * @param  string   $columnName
1448
     * @param  mixed    $value
1449
     * @return FluentPdoModel|$this
1450
     */
1451
    public function whereNot(string $columnName, $value) : FluentPdoModel
1452
    {
1453
        return $this->where("$columnName != ?", $value);
1454
    }
1455
    /**
1456
     * WHERE $columnName != $value
1457
     *
1458
     * @param  string   $columnName
1459
     * @param  mixed    $value
1460
     * @return FluentPdoModel|$this
1461
     */
1462
    public function whereCoercedNot(string $columnName, $value) : FluentPdoModel
1463
    {
1464
        return $this->where("IFNULL({$columnName}, '') != ?", $value);
1465
    }
1466
1467
    /**
1468
     * WHERE $columnName LIKE $value
1469
     *
1470
     * @param  string   $columnName
1471
     * @param  mixed    $value
1472
     * @return FluentPdoModel|$this
1473
     */
1474
    public function whereLike(string $columnName, $value) : FluentPdoModel
1475
    {
1476
        return $this->where("$columnName LIKE ?", $value);
1477
    }
1478
1479
    /**
1480
     * @param string $columnName
1481
     * @param mixed $value1
1482
     * @param mixed $value2
1483
     * @return FluentPdoModel|$this
1484
     */
1485
    public function whereBetween(string $columnName, $value1, $value2) : FluentPdoModel
1486
    {
1487
        $value1 = is_string($value1) ? trim($value1) : $value1;
1488
        $value2 = is_string($value2) ? trim($value2) : $value2;
1489
1490
        return $this->where("$columnName BETWEEN ? AND ?", [$value1, $value2]);
1491
    }
1492
1493
    /**
1494
     * @param string $columnName
1495
     * @param mixed $value1
1496
     * @param mixed $value2
1497
     * @return FluentPdoModel|$this
1498
     */
1499
    public function whereNotBetween(string $columnName, $value1, $value2) : FluentPdoModel
1500
    {
1501
        $value1 = is_string($value1) ? trim($value1) : $value1;
1502
        $value2 = is_string($value2) ? trim($value2) : $value2;
1503
1504
        return $this->where("$columnName NOT BETWEEN ? AND ?", [$value1, $value2]);
1505
    }
1506
1507
    /**
1508
     * @param string $columnName
1509
     * @param string $regex
1510
     * @return FluentPdoModel|$this
1511
     */
1512
    public function whereRegex(string $columnName, string $regex) : FluentPdoModel
1513
    {
1514
        return $this->where("$columnName REGEXP ?", $regex);
1515
    }
1516
1517
    /**
1518
     * @param string $columnName
1519
     * @param string $regex
1520
     * @return FluentPdoModel|$this
1521
     */
1522
    public function whereNotRegex(string $columnName, string $regex) : FluentPdoModel
1523
    {
1524
        return $this->where("$columnName NOT REGEXP ?", $regex);
1525
    }
1526
1527
    /**
1528
     * WHERE $columnName NOT LIKE $value
1529
     *
1530
     * @param  string   $columnName
1531
     * @param  string   $value
1532
     * @return FluentPdoModel|$this
1533
     */
1534
    public function whereNotLike(string $columnName, string $value) : FluentPdoModel
1535
    {
1536
        return $this->where("$columnName NOT LIKE ?", $value);
1537
    }
1538
1539
    /**
1540
     * WHERE $columnName > $value
1541
     *
1542
     * @param  string   $columnName
1543
     * @param  mixed    $value
1544
     * @return FluentPdoModel|$this
1545
     */
1546
    public function whereGt(string $columnName, $value) : FluentPdoModel
1547
    {
1548
        return $this->where("$columnName > ?", $value);
1549
    }
1550
1551
    /**
1552
     * WHERE $columnName >= $value
1553
     *
1554
     * @param  string   $columnName
1555
     * @param  mixed    $value
1556
     * @return FluentPdoModel|$this
1557
     */
1558
    public function whereGte(string $columnName, $value) : FluentPdoModel
1559
    {
1560
        return $this->where("$columnName >= ?", $value);
1561
    }
1562
1563
    /**
1564
     * WHERE $columnName < $value
1565
     *
1566
     * @param  string   $columnName
1567
     * @param  mixed    $value
1568
     * @return FluentPdoModel|$this
1569
     */
1570
    public function whereLt(string $columnName, $value) : FluentPdoModel
1571
    {
1572
        return $this->where("$columnName < ?", $value);
1573
    }
1574
1575
    /**
1576
     * WHERE $columnName <= $value
1577
     *
1578
     * @param  string   $columnName
1579
     * @param  mixed    $value
1580
     * @return FluentPdoModel|$this
1581
     */
1582
    public function whereLte(string $columnName, $value) : FluentPdoModel
1583
    {
1584
        return $this->where("$columnName <= ?", $value);
1585
    }
1586
1587
    /**
1588
     * WHERE $columnName IN (?,?,?,...)
1589
     *
1590
     * @param  string   $columnName
1591
     * @param  array    $values
1592
     * @return FluentPdoModel|$this
1593
     */
1594
    public function whereIn(string $columnName, array $values) : FluentPdoModel
1595
    {
1596
        return $this->where($columnName, array_values($values));
1597
    }
1598
1599
    /**
1600
     * WHERE $columnName NOT IN (?,?,?,...)
1601
     *
1602
     * @param  string   $columnName
1603
     * @param  array    $values
1604
     * @return FluentPdoModel|$this
1605
     */
1606
    public function whereNotIn(string $columnName, array $values) : FluentPdoModel
1607
    {
1608
        $placeholders = $this->_makePlaceholders(count($values));
1609
1610
        return $this->where("({$columnName} NOT IN ({$placeholders}))", $values);
1611
    }
1612
1613
    /**
1614
     * WHERE $columnName IS NULL
1615
     *
1616
     * @param  string   $columnName
1617
     * @return FluentPdoModel|$this
1618
     */
1619
    public function whereNull(string $columnName) : FluentPdoModel
1620
    {
1621
        return $this->where("({$columnName} IS NULL)");
1622
    }
1623
1624
    /**
1625
     * WHERE $columnName IS NOT NULL
1626
     *
1627
     * @param  string   $columnName
1628
     * @return FluentPdoModel|$this
1629
     */
1630
    public function whereNotNull(string $columnName) : FluentPdoModel
1631
    {
1632
        return $this->where("({$columnName} IS NOT NULL)");
1633
    }
1634
1635
    /**
1636
     * @param string $statement
1637
     * @param string $operator
1638
     * @return FluentPdoModel|$this
1639
     */
1640
    public function having(string $statement, string $operator=self::OPERATOR_AND) : FluentPdoModel
1641
    {
1642
        $this->_having[] = [
1643
            'STATEMENT'   => $statement,
1644
            'OPERATOR'    => $operator
1645
        ];
1646
1647
        return $this;
1648
    }
1649
1650
    /**
1651
     * ORDER BY $columnName (ASC | DESC)
1652
     *
1653
     * @param  string   $columnName - The name of the column or an expression
1654
     * @param  string   $ordering   (DESC | ASC)
1655
     * @return FluentPdoModel|$this
1656
     */
1657
    public function orderBy(string $columnName='', string $ordering='DESC') : FluentPdoModel
1658
    {
1659
        $ordering       = strtoupper($ordering);
1660
        Assert($ordering)->inArray(['DESC', 'ASC']);
1661
        if ( ! $columnName )
1662
        {
1663
            $this->_order_by = [];
1664
1665
            return $this;
1666
        }
1667
        $this->_order_by[] = trim("{$columnName} {$ordering}");
1668
1669
        return $this;
1670
    }
1671
1672
    /**
1673
     * GROUP BY $columnName
1674
     *
1675
     * @param  string   $columnName
1676
     * @return FluentPdoModel|$this
1677
     */
1678
    public function groupBy(string $columnName) : FluentPdoModel
1679
    {
1680
        $columnName = is_array($columnName) ? $columnName : [$columnName];
1681
        foreach ( $columnName as $col )
1682
        {
1683
            $this->_group_by[] = $col;
1684
        }
1685
1686
        return $this;
1687
    }
1688
1689
1690
    /**
1691
     * LIMIT $limit
1692
     *
1693
     * @param  int      $limit
1694
     * @param  int|null $offset
1695
     * @return FluentPdoModel|$this
1696
     */
1697
    public function limit(int $limit, int $offset=0) : FluentPdoModel
1698
    {
1699
        $this->_limit =  $limit;
1700
        if ( $offset )
1701
        {
1702
            $this->offset($offset);
1703
        }
1704
        return $this;
1705
    }
1706
1707
    /**
1708
     * Return the limit
1709
     *
1710
     * @return integer
1711
     */
1712
    public function getLimit() : int
1713
    {
1714
        return $this->_limit;
1715
    }
1716
1717
    /**
1718
     * OFFSET $offset
1719
     *
1720
     * @param  int      $offset
1721
     * @return FluentPdoModel|$this
1722
     */
1723
    public function offset(int $offset) : FluentPdoModel
1724
    {
1725
        $this->_offset = (int)$offset;
1726
1727
        return $this;
1728
    }
1729
1730
    /**
1731
     * Return the offset
1732
     *
1733
     * @return integer
1734
     */
1735
    public function getOffset() : int
1736
    {
1737
        return $this->_offset;
1738
    }
1739
1740
    /**
1741
     * Build a join
1742
     *
1743
     * @param  string    $table         - The table name
1744
     * @param  string   $constraint    -> id = profile.user_id
1745
     * @param  string   $tableAlias   - The alias of the table name
1746
     * @param  string   $joinOperator - LEFT | INNER | etc...
1747
     * @return FluentPdoModel|$this
1748
     */
1749
    public function join(string $table, string $constraint='', string $tableAlias='', string $joinOperator='') : FluentPdoModel
1750
    {
1751
        if ( ! $constraint )
1752
        {
1753
            return $this->autoJoin($table, $joinOperator);
1754
        }
1755
        $join                   = [$joinOperator ? "{$joinOperator} " : ''];
1756
        $join[]                 = "JOIN {$table} ";
1757
        $tableAlias             = $tableAlias ?: Inflector::classify($table);
1758
        $join[]                 = $tableAlias ? "AS {$tableAlias} " : '';
1759
        $join[]                 = "ON {$constraint}";
1760
        $this->_join_sources[]  = implode('', $join);
1761
        if ( $tableAlias )
1762
        {
1763
            $this->_join_aliases[]  = $tableAlias;
1764
        }
1765
1766
        return $this;
1767
    }
1768
1769
    /**
1770
     * Create a left join
1771
     *
1772
     * @param  string   $table
1773
     * @param  string   $constraint
1774
     * @param  string   $tableAlias
1775
     * @return FluentPdoModel|$this
1776
     */
1777
    public function leftJoin(string $table, string $constraint, string $tableAlias='') : FluentPdoModel
1778
    {
1779
        return $this->join($table, $constraint, $tableAlias, self::LEFT_JOIN);
1780
    }
1781
1782
1783
    /**
1784
     * Return the build select query
1785
     *
1786
     * @return string
1787
     */
1788
    public function getSelectQuery() : string
1789
    {
1790
        if ( $this->_raw_sql )
1791
        {
1792
            return $this->_raw_sql;
1793
        }
1794
        if ( empty($this->_select_fields) || ! $this->_explicit_select_mode )
1795
        {
1796
            $this->select('*', '', false);
1797
        }
1798
        foreach ( $this->_select_fields as $idx => $cols )
1799
        {
1800
            if ( strpos(trim(strtolower($cols)), 'distinct ') === 0 )
1801
            {
1802
                $this->_distinct = true;
1803
                $this->_select_fields[$idx] = str_ireplace('distinct ', '', $cols);
1804
            }
1805
        }
1806
        if ( $this->_include_count )
1807
        {
1808
            $this->select('COUNT(*) as __cnt');
1809
        }
1810
        $query  = 'SELECT ';
1811
        $query .= $this->_distinct ? 'DISTINCT ' : '';
1812
        $query .= implode(', ', $this->_prepareColumns($this->_select_fields));
1813
        $query .= " FROM {$this->_table_name}" . ( $this->_table_alias ? " {$this->_table_alias}" : '' );
1814
        if ( count($this->_join_sources ) )
1815
        {
1816
            $query .= (' ').implode(' ',$this->_join_sources);
1817
        }
1818
        $query .= $this->_getWhereString(); // WHERE
1819
        if ( count($this->_group_by) )
1820
        {
1821
            $query .= ' GROUP BY ' . implode(', ', array_unique($this->_group_by));
1822
        }
1823
        if ( count($this->_order_by ) )
1824
        {
1825
            $query .= ' ORDER BY ' . implode(', ', array_unique($this->_order_by));
1826
        }
1827
        $query .= $this->_getHavingString(); // HAVING
1828
1829
        return $this->_connection->setLimit($query, $this->_limit, $this->_offset);
1830
    }
1831
1832
    /**
1833
     * Prepare columns to include the table alias name
1834
     * @param array $columns
1835
     * @return array
1836
     */
1837
    protected function _prepareColumns(array $columns) : array
1838
    {
1839
        if ( ! $this->_table_alias )
1840
        {
1841
            return $columns;
1842
        }
1843
        $newColumns = [];
1844
        foreach ($columns as $column)
1845
        {
1846
            if ( strpos($column, ',') && ! preg_match('/^[a-zA-Z_]{2,200}\(.{1,500}\)/', trim($column)) )
1847
            {
1848
                $newColumns = array_merge($this->_prepareColumns(explode(',', $column)), $newColumns);
1849
            }
1850
            elseif ( preg_match('/^(AVG|SUM|MAX|MIN|COUNT|CONCAT)/', $column) )
1851
            {
1852
                $newColumns[] = trim($column);
1853
            }
1854
            elseif (strpos($column, '.') === false && strpos(strtoupper($column), 'NULL') === false)
1855
            {
1856
                $column         = trim($column);
1857
                $newColumns[]   = preg_match('/^[0-9]/', $column) ? trim($column) : "{$this->_table_alias}.{$column}";
1858
            }
1859
            else
1860
            {
1861
                $newColumns[] = trim($column);
1862
            }
1863
        }
1864
1865
        return $newColumns;
1866
    }
1867
1868
    /**
1869
     * Build the WHERE clause(s)
1870
     *
1871
     * @param bool $purgeAliases
1872
     * @return string
1873
     */
1874
    protected function _getWhereString(bool $purgeAliases=false) : string
1875
    {
1876
        // If there are no WHERE clauses, return empty string
1877
        if ( empty($this->_where_conditions) )
1878
        {
1879
            return '';
1880
        }
1881
        $where_condition = '';
1882
        $last_condition = '';
1883
        foreach ( $this->_where_conditions as $condition )
1884
        {
1885
            if ( is_array($condition) )
1886
            {
1887
                if ( $where_condition && $last_condition != '(' && !preg_match('/\)\s+(OR|AND)\s+$/i', $where_condition))
1888
                {
1889
                    $where_condition .= $condition['OPERATOR'];
1890
                }
1891
                if ( $purgeAliases && ! empty($condition['STATEMENT']) && strpos($condition['STATEMENT'], '.') !== false && ! empty($this->_table_alias) )
1892
                {
1893
                    $condition['STATEMENT'] = preg_replace("/{$this->_table_alias}\./", '', $condition['STATEMENT']);
1894
                }
1895
                $where_condition .= $condition['STATEMENT'];
1896
                $this->_where_parameters = array_merge($this->_where_parameters, $condition['PARAMS']);
1897
            }
1898
            else
1899
            {
1900
                $where_condition .= $condition;
1901
            }
1902
            $last_condition = $condition;
1903
        }
1904
1905
        return " WHERE {$where_condition}" ;
1906
    }
1907
1908
    /**
1909
     * Return the HAVING clause
1910
     *
1911
     * @return string
1912
     */
1913
    protected function _getHavingString() : string
1914
    {
1915
        // If there are no WHERE clauses, return empty string
1916
        if ( empty($this->_having) )
1917
        {
1918
            return '';
1919
        }
1920
        $having_condition = '';
1921
        foreach ( $this->_having as $condition )
1922
        {
1923
            if ( $having_condition && ! preg_match('/\)\s+(OR|AND)\s+$/i', $having_condition) )
1924
            {
1925
                $having_condition .= $condition['OPERATOR'];
1926
            }
1927
            $having_condition .= $condition['STATEMENT'];
1928
        }
1929
1930
        return " HAVING {$having_condition}" ;
1931
    }
1932
1933
    /**
1934
     * Return the values to be bound for where
1935
     *
1936
     * @param bool $purgeAliases
1937
     * @return array
1938
     */
1939
    protected function _getWhereParameters(bool $purgeAliases=false) : array
1940
    {
1941
        unset($purgeAliases);
1942
1943
        return $this->_where_parameters;
1944
    }
1945
1946
    /**
1947
     * @param array $record
1948
     * @return stdClass
1949
     */
1950
    public function insertArr(array $record) : stdClass
1951
    {
1952
        return $this->insert((object)$record);
1953
    }
1954
1955
    /**
1956
     * Insert new rows
1957
     * $records can be a stdClass or an array of stdClass to add a bulk insert
1958
     * If a single row is inserted, it will return it's row instance
1959
     *
1960
     * @param stdClass $record
1961
     * @return stdClass
1962
     * @throws Exception
1963
     */
1964
    public function insert(stdClass $record) : stdClass
1965
    {
1966
        Assert((array)$record)->notEmpty("The data passed to insert does not contain any data");
1967
        Assert($record)->isInstanceOf('stdClass', "The data to be inserted must be an object or an array of objects");
1968
1969
        $record = $this->beforeSave($record, self::SAVE_INSERT);
1970
        if ( ! empty($this->_errors) )
1971
        {
1972
            return $record;
1973
        }
1974
        list($sql, $insert_values) = $this->insertSqlQuery([$record]);
1975
        $this->execute((string)$sql, (array)$insert_values);
1976
        $rowCount = $this->rowCount();
1977
        if ( $rowCount === 1 )
1978
        {
1979
            $primaryKeyName                 = $this->getPrimaryKeyName();
1980
            $record->{$primaryKeyName}      = $this->getLastInsertId($primaryKeyName);
1981
        }
1982
        $record = $this->afterSave($record, self::SAVE_INSERT);
1983
        $this->destroy();
1984
1985
        return $record;
1986
    }
1987
1988
    /**
1989
     * @param string $name
1990
     * @return int
1991
     */
1992
    public function getLastInsertId(string $name='') : int
1993
    {
1994
        return (int)$this->getPdo()->lastInsertId($name ?: null);
1995
    }
1996
1997
    /**
1998
     * @param stdClass[] $records
1999
     * @return stdClass[]
2000
     */
2001
    public function insertSqlQuery(array $records) : array
2002
    {
2003
        Assert($records)->notEmpty("The data passed to insert does not contain any data");
2004
        Assert($records)->all()->isInstanceOf('stdClass', "The data to be inserted must be an object or an array of objects");
2005
2006
        $insert_values      = [];
2007
        $question_marks     = [];
2008
        $properties         = [];
2009
        foreach ( $records as $record )
2010
        {
2011
            $properties         = !empty($properties) ? $properties : array_keys(get_object_vars($record));
2012
            $question_marks[]   = '('  . $this->_makePlaceholders(count($properties)) . ')';
2013
            $insert_values      = array_merge($insert_values, array_values((array)$record));
2014
        }
2015
        $properties         = implode(', ', $properties);
2016
        $question_marks     = implode(', ', $question_marks);
2017
        $sql                = "INSERT INTO {$this->_table_name} ({$properties}) VALUES {$question_marks}";
2018
2019
        return [$sql, $insert_values];
2020
    }
2021
2022
    /**
2023
     * @param       $data
2024
     * @param array $matchOn
2025
     * @param bool  $returnObj
2026
     * @return bool|int|stdClass
2027
     */
2028
    public function upsert($data, array $matchOn=[], $returnObj=false)
2029
    {
2030
        if ( ! is_array($data) )
2031
        {
2032
            return $this->upsertOne($data, $matchOn, $returnObj);
2033
        }
2034
        Assert($data)
2035
            ->notEmpty("The data passed to insert does not contain any data")
2036
            ->all()->isInstanceOf('stdClass', "The data to be inserted must be an object or an array of objects");
2037
        $num_success    = 0;
2038
        foreach ( $data as $row )
2039
        {
2040
            $clone = clone $this;
2041
            if ( $clone->upsertOne($row, $matchOn) )
2042
            {
2043
                $num_success++;
2044
            }
2045
            unset($clone->_handlers, $clone); // hhvm mem leak
2046
        }
2047
2048
        return $num_success;
2049
    }
2050
2051
    /**
2052
     * @param stdClass $object
2053
     * @param array    $matchOn
2054
     * @param bool     $returnObj
2055
     * @return bool|int|stdClass
2056
     */
2057
    public function upsertOne(stdClass $object, array $matchOn=[], $returnObj=false)
2058
    {
2059
        $primary_key    = $this->getPrimaryKeyName();
2060
        $matchOn       = empty($matchOn) && property_exists($object, $primary_key) ? [$primary_key] : $matchOn;
2061
        foreach ( $matchOn as $column )
2062
        {
2063
            Assert( ! property_exists($object, $column) && $column !== $primary_key)->false('The match on value for upserts is missing.');
2064
            if ( property_exists($object, $column) )
2065
            {
2066
                if ( is_null($object->{$column}) )
2067
                {
2068
                    $this->whereNull($column);
2069
                }
2070
                else
2071
                {
2072
                    $this->where($column, $object->{$column});
2073
                }
2074
            }
2075
        }
2076
        if ( count($this->_where_conditions) < 1 )
2077
        {
2078
            return $this->insert($object);
2079
        }
2080
        if ( ( $id = (int)$this->fetchField($primary_key) ) )
2081
        {
2082
            if ( property_exists($object, $primary_key) && empty($object->{$primary_key}) )
2083
            {
2084
                $object->$primary_key = $id;
2085
            }
2086
            $rows_affected = $this->reset()->wherePk($id)->update($object);
2087
            if ( $rows_affected === false )
2088
            {
2089
                return false;
2090
            }
2091
2092
            return $returnObj ? $this->reset()->fetchOne($id) : $id;
2093
        }
2094
2095
        return $this->insert($object);
2096
    }
2097
2098
    /**
2099
     * @param array      $data
2100
     * @param array      $matchOn
2101
     * @param bool|false $returnObj
2102
     * @return bool|int|stdClass
2103
     */
2104
    public function upsertArr(array $data, array $matchOn=[], bool $returnObj=false)
2105
    {
2106
        return $this->upsert((object)$data, $matchOn, $returnObj);
2107
    }
2108
2109
    /**
2110
     * Update entries
2111
     * Use the query builder to create the where clause
2112
     *
2113
     * @param stdClass $record
2114
     * @param bool     $updateAll
2115
     * @return int
2116
     * @throws Exception
2117
     */
2118
    public function update(stdClass $record, $updateAll=false) : int
2119
    {
2120
        Assert($record)
2121
            ->notEmpty("The data passed to update does not contain any data")
2122
            ->isInstanceOf('stdClass', "The data to be updated must be an object or an array of objects");
2123
2124
        if ( empty($this->_where_conditions) && ! $updateAll )
2125
        {
2126
            throw new Exception("You cannot update an entire table without calling update with updateAll=true", 500);
2127
        }
2128
        $record = $this->beforeSave($record, self::SAVE_UPDATE);
2129
        if ( ! empty($this->_errors) )
2130
        {
2131
            return 0;
2132
        }
2133
        list($sql, $values) = $this->updateSqlQuery($record);
2134
        $this->execute($sql, $values);
2135
        $this->afterSave($record, self::SAVE_UPDATE);
2136
        $rowCount = $this->rowCount();
2137
        $this->destroy();
2138
2139
        return $rowCount;
2140
    }
2141
2142
    /**
2143
     * @param array      $record
2144
     * @param bool|false $updateAll
2145
     * @return int
2146
     * @throws Exception
2147
     */
2148
    public function updateArr(array $record, $updateAll=false) : int
2149
    {
2150
        return $this->update((object)$record, $updateAll);
2151
    }
2152
2153
2154
    /**
2155
     * @param string  $field
2156
     * @param mixed   $value
2157
     * @param int     $id
2158
     * @param bool|false $updateAll
2159
     * @return int
2160
     * @throws Exception
2161
     */
2162
    public function updateField(string $field, $value, int $id=0, bool $updateAll=false) : int
2163
    {
2164
        if ( $id && $id > 0 )
2165
        {
2166
            $this->wherePk($id);
2167
        }
2168
2169
        return $this->update((object)[$field => $value], $updateAll);
2170
    }
2171
2172
    /**
2173
     * @param stdClass $record
2174
     * @return bool|int
2175
     * @throws Exception
2176
     */
2177
    public function updateChanged(stdClass $record) : int
2178
    {
2179
        foreach ( $record as $field => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2180
        {
2181
            if ( is_null($value) )
2182
            {
2183
                $this->whereNotNull($field);
2184
                continue;
2185
            }
2186
            $this->whereCoercedNot($field, $value);
2187
        }
2188
2189
        return $this->update($record);
2190
    }
2191
2192
    /**
2193
     * @param string    $expression
2194
     * @param array     $params
2195
     * @return FluentPdoModel|$this
2196
     */
2197
    public function updateByExpression(string $expression, array $params) : FluentPdoModel
2198
    {
2199
        $this->_update_raw[] = [$expression, $params];
2200
2201
        return $this;
2202
    }
2203
2204
    /**
2205
     * @param array $data
2206
     * @return int
2207
     * @throws Exception
2208
     */
2209
    public function rawUpdate(array $data=[]) : int
2210
    {
2211
        list($sql, $values) = $this->updateSql($data);
2212
        $this->execute($sql, $values);
2213
        $rowCount           = $this->rowCount();
2214
        $this->destroy();
2215
2216
        return $rowCount;
2217
    }
2218
2219
    /**
2220
     * @param stdClass $record
2221
     * @return array
2222
     */
2223
    public function updateSqlQuery(stdClass $record) : array
2224
    {
2225
        Assert($record)
2226
            ->notEmpty("The data passed to update does not contain any data")
2227
            ->isInstanceOf('stdClass', "The data to be updated must be an object or an array of objects");
2228
2229
        // Make sure we remove the primary key
2230
2231
        return $this->updateSql((array)$record);
2232
    }
2233
2234
    /**
2235
     * @param $record
2236
     * @return array
2237
     */
2238
    protected function updateSql(array $record) : array
2239
    {
2240
        unset($record[$this->getPrimaryKeyName()]);
2241
        // Sqlite needs a null primary key
2242
        //$record[$this->getPrimaryKeyName()] = null;
2243
        $field_list = [];
2244
        foreach ( $record as $key => $value )
2245
        {
2246
            if ( is_numeric($key) )
2247
            {
2248
                $field_list[] = $value;
2249
                unset($record[$key]);
2250
                continue;
2251
            }
2252
            $field_list[] = "{$key} = ?";
2253
        }
2254
        $rawParams  = [];
2255
        foreach ( $this->_update_raw as $rawUpdate )
2256
        {
2257
            $field_list[]   = $rawUpdate[0];
2258
            $rawParams      = array_merge($rawParams, $rawUpdate[1]);
2259
        }
2260
        $field_list     = implode(', ', $field_list);
2261
        $where_str      = $this->_getWhereString();
2262
        $joins          = ! empty($this->_join_sources) ? (' ').implode(' ',$this->_join_sources) : '';
2263
        $alias          = ! empty($this->_table_alias) ? " AS {$this->_table_alias}" : '';
2264
        $sql            = "UPDATE {$this->_table_name}{$alias}{$joins} SET {$field_list}{$where_str}";
2265
        $values         = array_merge(array_values($record), $rawParams, $this->_getWhereParameters());
2266
2267
        return [$sql, $values];
2268
    }
2269
2270
    /**
2271
     * @param bool $deleteAll
2272
     * @param bool $force
2273
     * @return int
2274
     * @throws Exception
2275
     */
2276
    public function delete(bool $deleteAll=false, bool $force=false) : int
2277
    {
2278
        if ( ! $force && $this->_soft_deletes )
2279
        {
2280
            return $this->updateArchived();
2281
        }
2282
2283
        list($sql, $params) = $this->deleteSqlQuery();
2284
        if ( empty($this->_where_conditions) && ! $deleteAll )
2285
        {
2286
            throw new Exception("You cannot update an entire table without calling update with deleteAll=true");
2287
        }
2288
        $this->execute($sql, $params);
2289
2290
        return $this->rowCount();
2291
    }
2292
2293
    /**
2294
     * @param bool|false $force
2295
     * @return FluentPdoModel|$this
2296
     * @throws Exception
2297
     */
2298
    public function truncate(bool $force=false) : FluentPdoModel
2299
    {
2300
        if ( $force )
2301
        {
2302
            $this->execute('SET FOREIGN_KEY_CHECKS = 0');
2303
        }
2304
        $this->execute("TRUNCATE TABLE {$this->_table_name}");
2305
        if ( $force )
2306
        {
2307
            $this->execute('SET FOREIGN_KEY_CHECKS = 1');
2308
        }
2309
2310
        return $this;
2311
    }
2312
2313
    /**
2314
     * @return array
2315
     */
2316
    public function deleteSqlQuery() : array
2317
    {
2318
        $query  = "DELETE FROM {$this->_table_name}";
2319
        if ( !empty($this->_where_conditions) )
2320
        {
2321
            $query .= $this->_getWhereString(true);
2322
2323
            return [$query, $this->_getWhereParameters()];
2324
        }
2325
2326
        return [$query, []];
2327
    }
2328
2329
2330
    /**
2331
     * Return the aggregate count of column
2332
     *
2333
     * @param string $column
2334
     * @param int $cacheTtl
2335
     * @return float
2336
     */
2337
    public function count(string $column='*', int $cacheTtl=self::CACHE_NO) : float
2338
    {
2339
        return $this
2340
            ->explicitSelectMode()
2341
            ->fetchFloat("COUNT({$column}) AS cnt", 0, $cacheTtl);
2342
    }
2343
2344
2345
    /**
2346
     * Return the aggregate max count of column
2347
     *
2348
     * @param string $column
2349
     * @param int $cacheTtl
2350
     * @return int|float|string|null
2351
     */
2352
    public function max(string $column, int $cacheTtl=self::CACHE_NO)
2353
    {
2354
        return $this
2355
            ->explicitSelectMode()
2356
            ->fetchField("MAX({$column}) AS max", 0, $cacheTtl);
2357
    }
2358
2359
2360
    /**
2361
     * Return the aggregate min count of column
2362
     *
2363
     * @param string $column
2364
     * @param int $cacheTtl
2365
     * @return int|float|string|null
2366
     */
2367
    public function min(string $column, int $cacheTtl=self::CACHE_NO)
2368
    {
2369
        return $this
2370
            ->explicitSelectMode()
2371
            ->fetchField("MIN({$column}) AS min", 0, $cacheTtl);
2372
    }
2373
2374
    /**
2375
     * Return the aggregate sum count of column
2376
     *
2377
     * @param string $column
2378
     * @param int $cacheTtl
2379
     * @return int|float|string|null
2380
     */
2381
    public function sum(string $column, int $cacheTtl=self::CACHE_NO)
2382
    {
2383
        return $this
2384
            ->explicitSelectMode()
2385
            ->fetchField("SUM({$column}) AS sum", 0, $cacheTtl);
2386
    }
2387
2388
    /**
2389
     * Return the aggregate average count of column
2390
     *
2391
     * @param string $column
2392
     * @param int $cacheTtl
2393
     * @return int|float|string|null
2394
     */
2395
    public function avg(string $column, int $cacheTtl=self::CACHE_NO)
2396
    {
2397
        return $this
2398
            ->explicitSelectMode()
2399
            ->fetchField("AVG({$column}) AS avg", 0, $cacheTtl);
2400
    }
2401
2402
    /*******************************************************************************/
2403
// Utilities methods
2404
2405
    /**
2406
     * Reset fields
2407
     *
2408
     * @return FluentPdoModel|$this
2409
     */
2410
    public function reset() : FluentPdoModel
2411
    {
2412
        $this->_where_parameters        = [];
2413
        $this->_select_fields           = [];
2414
        $this->_join_sources            = [];
2415
        $this->_join_aliases            = [];
2416
        $this->_where_conditions        = [];
2417
        $this->_limit                   = 0;
2418
        $this->_offset                  = 0;
2419
        $this->_order_by                = [];
2420
        $this->_group_by                = [];
2421
        $this->_and_or_operator         = self::OPERATOR_AND;
2422
        $this->_having                  = [];
2423
        $this->_wrap_open               = false;
2424
        $this->_last_wrap_position      = 0;
2425
        $this->_pdo_stmt                = null;
2426
        $this->_distinct                = false;
2427
        $this->_requested_fields        = [];
0 ignored issues
show
Documentation Bug introduced by
It seems like array() of type array is incompatible with the declared type null of property $_requested_fields.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
2428
        $this->_filter_meta             = [];
0 ignored issues
show
Documentation Bug introduced by
It seems like array() of type array is incompatible with the declared type null of property $_filter_meta.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
2429
        $this->_cache_ttl               = -1;
2430
        $this->_timer                   = [];
2431
        $this->_built_query             = '';
2432
        $this->_paging_meta             = [];
2433
        $this->_raw_sql                 = null;
2434
        $this->_explicit_select_mode    = false;
2435
2436
        return $this;
2437
    }
2438
2439
2440
    /**
2441
     * @return FluentPdoModel|$this
2442
     */
2443
    public function removeUnauthorisedFields() : FluentPdoModel
2444
    {
2445
        return $this;
2446
    }
2447
2448
    /**
2449
     * @return Closure[]
2450
     */
2451
    protected function _getFieldHandlers() : array
2452
    {
2453
        $columns = $this->getColumns(true);
2454
        if ( empty($columns) )
2455
        {
2456
            return [];
2457
        }
2458
        return [
2459
            'id' => function(string $field, $value, string $type='', stdClass $record=null) {
2460
2461
                unset($record);
2462
                $value = $this->_fixType($field, $value);
2463
                if ( $type === self::SAVE_INSERT )
2464
                {
2465
                    Validate($value)->name($field)->nullOr()->id('ID must be a valid integer id, (%s) submitted.');
2466
                    return $value;
2467
                }
2468
                Validate($value)->name($field)->id('ID must be a valid integer id, (%s) submitted.');
2469
                return $value;
2470
            },
2471
            'created_by_id' => function(string $field, $value, string $type='', stdClass $record=null) {
2472
2473
                unset($type, $record);
2474
                $value = $this->_fixType($field, $value);
2475
                // Created user id is set to current user if record is an insert or deleted if not (unless override is true)
2476
                $value = $this->_allow_meta_override ? $value : $this->getUserId();
2477
                Validate($value)->name($field)->id('Created By must be a valid integer id, (%s) submitted.');
2478
                return $value;
2479
            },
2480
            'created_ts' => function(string $field, $value, string $type='', stdClass $record=null) {
2481
2482
                unset($type, $record);
2483
                $value = $this->_fixType($field, $value);
2484
                // Created ts is set to now if record is an insert or deleted if not (unless override is true)
2485
                $value = static::dateTime($this->_allow_meta_override ? $value : null);
2486
                Validate($value)->name($field)->date('Created must be a valid timestamp, (%s) submitted.');
2487
                return $value;
2488
            },
2489
            'modified_by_id' => function(string $field, $value, string $type='', stdClass $record=null) {
2490
2491
                unset($type, $record);
2492
                $value = $this->_fixType($field, $value);
2493
                // Modified user id is set to current user (unless override is true)
2494
                $value = $this->_allow_meta_override ? $value : $this->getUserId();
2495
                Validate($value)->name($field)->id('Modified By must be a valid integer id, (%s) submitted.');
2496
                return $value;
2497
            },
2498
            'modified_ts' => function(string $field, $value, string $type='', stdClass $record=null) {
2499
2500
                unset($type, $record);
2501
                $value = $this->_fixType($field, $value);
2502
                // Modified timestamps are set to now (unless override is true)
2503
                $value = static::dateTime($this->_allow_meta_override ? $value : null);
2504
                Validate($value)->name($field)->date('Modified must be a valid timestamp, (%s) submitted.');
2505
                return $value;
2506
            },
2507
            'status' => function(string $field, $value, string $type='', stdClass $record=null) {
2508
2509
                unset($type, $record);
2510
                $value = $this->_fixType($field, $value);
2511
                // Statuses are set to active if not set
2512
                $value = is_null($value) ? self::ACTIVE : $value;
2513
                Validate($value)->name($field)->nullOr()->status('Status must be a valid integer between -1 and 1, (%s) submitted.');
2514
                return $value;
2515
            },
2516
        ];
2517
    }
2518
2519
    /**
2520
     * @return bool
2521
     */
2522
    public function begin() : bool
2523
    {
2524
        $pdo        = $this->getPdo();
2525
        $oldDepth   = $pdo->getTransactionDepth();
2526
        $res        = $pdo->beginTransaction();
2527
        $newDepth   = $pdo->getTransactionDepth();
2528
        $this->getLogger()->debug("Calling db begin transaction", [
2529
            'old_depth'     => $oldDepth,
2530
            'new_depth'     => $newDepth,
2531
            'trans_started' => $newDepth === 1 ? true : false,
2532
        ]);
2533
2534
        return $res;
2535
    }
2536
2537
    /**
2538
     * @return bool
2539
     */
2540
    public function commit() : bool
2541
    {
2542
        $pdo        = $this->getPdo();
2543
        $oldDepth   = $pdo->getTransactionDepth();
2544
        $res        = $pdo->commit();
2545
        $newDepth   = $pdo->getTransactionDepth();
2546
        $this->getLogger()->debug("Calling db commit transaction", [
2547
            'old_depth'     => $oldDepth,
2548
            'new_depth'     => $newDepth,
2549
            'trans_ended'   => $newDepth === 0 ? true : false,
2550
        ]);
2551
        if ( ! $res )
2552
        {
2553
            return false;
2554
        }
2555
2556
        return $res === 0 ? true : $res;
2557
    }
2558
2559
    /**
2560
     * @return bool
2561
     */
2562
    public function rollback() : bool
2563
    {
2564
        $pdo        = $this->getPdo();
2565
        $oldDepth   = $pdo->getTransactionDepth();
2566
        $res        = $pdo->rollback();
2567
        $newDepth   = $pdo->getTransactionDepth();
2568
        $this->getLogger()->debug("Calling db rollback transaction", [
2569
            'old_depth'     => $oldDepth,
2570
            'new_depth'     => $newDepth,
2571
            'trans_ended'   => $newDepth === 0 ? true : false,
2572
        ]);
2573
2574
        return $res;
2575
    }
2576
2577
    /**
2578
     * @param stdClass $record
2579
     * @param  string  $type
2580
     * @return stdClass
2581
     */
2582
    public function applyGlobalModifiers(stdClass $record, string $type) : stdClass
2583
    {
2584
        unset($type);
2585
        foreach ( $record as $field => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2586
        {
2587
            if ( is_string($record->{$field}) )
2588
            {
2589
                $record->{$field} = str_replace(["\r\n", "\\r\\n", "\\n"], "\n", $value);
2590
            }
2591
        }
2592
2593
        return $record;
2594
    }
2595
2596
    /**
2597
     * @param stdClass $record
2598
     * @param  string $type
2599
     * @return stdClass
2600
     */
2601
    public function removeUnneededFields(stdClass $record, string $type) : stdClass
2602
    {
2603
        // remove un-needed fields
2604
        $columns = $this->getColumns(true);
2605
        if ( empty($columns) )
2606
        {
2607
            return $record;
2608
        }
2609
        foreach ( $record as $name => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2610
        {
2611
            if ( ! in_array($name, $columns) || in_array($name, $this->_virtual_fields) )
2612
            {
2613
                unset($record->{$name});
2614
            }
2615
        }
2616
        if ( property_exists($record, 'created_ts') && $type !== 'INSERT' && ! $this->_allow_meta_override )
2617
        {
2618
            unset($record->created_ts);
2619
        }
2620
        if ( property_exists($record, 'created_by_id') && $type !== 'INSERT' && ! $this->_allow_meta_override )
2621
        {
2622
            unset($record->created_by_id);
2623
        }
2624
2625
        return $record;
2626
    }
2627
2628
2629
    /**
2630
     * @param array $ids
2631
     * @param array $values
2632
     * @param int   $batch
2633
     * @return bool
2634
     */
2635
    public function setById(array $ids, array $values, int $batch=1000) : bool
2636
    {
2637
        $ids        = array_unique($ids);
2638
        if ( count($ids) <= $batch )
2639
        {
2640
            return (bool)$this->whereIn('id', $ids)->updateArr($values);
2641
        }
2642
        while ( ! empty($ids) )
2643
        {
2644
            $thisBatch  = array_slice($ids, 0, $batch);
2645
            $ids        = array_diff($ids, $thisBatch);
2646
            $this->reset()->whereIn('id', $thisBatch)->updateArr($values);
2647
        }
2648
2649
        return true;
2650
    }
2651
2652
2653
    /**
2654
     * @param string $displayColumnValue
2655
     * @return int
2656
     */
2657
    public function resolveId(string $displayColumnValue) : int
2658
    {
2659
        $displayColumn  = $this->getDisplayColumn();
2660
        $className      = get_class($this);
2661
        Assert($displayColumn)->notEmpty("Could not determine the display column for model ({$className})");
2662
2663
        return $this
2664
            ->reset()
2665
            ->where($displayColumn, $displayColumnValue)
2666
            ->fetchInt('id', 0, self::ONE_HOUR);
2667
    }
2668
2669
    /**
2670
     * @param int   $resourceId
2671
     * @param array $query
2672
     * @param array $extraFields
2673
     * @param int $cacheTtl
2674
     * @return array
2675
     */
2676
    public function fetchApiResource(int $resourceId, array $query=[], array $extraFields=[], int $cacheTtl=self::CACHE_NO) : array
2677
    {
2678
        Assert($resourceId)->id();
2679
2680
        $query['_limit']    = 1;
2681
        $pagingMetaData        = $this->wherePk($resourceId)->_prepareApiResource($query, $extraFields);
2682
        if ( $pagingMetaData['total'] === 0 )
2683
        {
2684
            return [[], $pagingMetaData];
2685
        }
2686
2687
        return [$this->fetchOne($resourceId, $cacheTtl), $pagingMetaData];
2688
    }
2689
2690
    /**
2691
     * @param array     $query
2692
     * @param array     $extraFields
2693
     * @param int       $cacheTtl
2694
     * @param string    $permEntity
2695
     * @return array
2696
     */
2697
    public function fetchApiResources(array $query=[], array $extraFields=[], int $cacheTtl=self::CACHE_NO, string $permEntity='') : array
2698
    {
2699
        $pagingMetaData    = $this->_prepareApiResource($query, $extraFields);
2700
        if ( $pagingMetaData['total'] === 0 )
2701
        {
2702
            return [[], $pagingMetaData];
2703
        }
2704
        $results = $this->fetch('', $cacheTtl);
2705
        if ( ! $permEntity )
2706
        {
2707
            return [$results, $pagingMetaData];
2708
        }
2709
        foreach ( $results as $record )
2710
        {
2711
            if ( ! empty($record->id) )
2712
            {
2713
                $pagingMetaData['perms'][(int)$record->id] = $this->getMaskByResourceAndId($permEntity, $record->id);
2714
            }
2715
        }
2716
2717
        return [$results, $pagingMetaData];
2718
    }
2719
2720
2721
    /**
2722
     * @return array
2723
     */
2724
    public function getSearchableAssociations() : array
2725
    {
2726
        $belongsTo = ! empty($this->_associations['belongsTo']) ? $this->_associations['belongsTo'] : [];
2727
        unset($belongsTo['CreatedBy'], $belongsTo['ModifiedBy']);
2728
2729
        return $belongsTo;
2730
    }
2731
2732
    /**
2733
     * @param array $fields
2734
     */
2735
    public function removeUnrequestedFields(array $fields)
2736
    {
2737
        foreach ( $this->_select_fields as $idx => $field )
2738
        {
2739
            $field = trim(static::after(' AS ', $field, true));
2740
            if ( ! in_array($field, $fields) )
2741
            {
2742
                unset($this->_select_fields[$idx]);
2743
            }
2744
        }
2745
    }
2746
2747
    /**
2748
     * @param array $removeFields
2749
     */
2750
    public function removeFields(array $removeFields=[])
2751
    {
2752
        $searches = [];
2753
        foreach ( $removeFields as $removeField )
2754
        {
2755
            $removeField    = str_replace("{$this->_table_alias}.", '', $removeField);
2756
            $searches[]     = "{$this->_table_alias}.{$removeField}";
2757
            $searches[]     = $removeField;
2758
        }
2759
        foreach ( $this->_select_fields as $idx => $selected )
2760
        {
2761
            $selected = stripos($selected, ' AS ') !== false ? preg_split('/ as /i', $selected) : [$selected];
2762
            foreach ( $selected as $haystack )
2763
            {
2764
                foreach ( $searches as $search )
2765
                {
2766
                    if ( trim($haystack) === trim($search) )
2767
                    {
2768
                        unset($this->_select_fields[$idx]);
2769
                        continue;
2770
                    }
2771
                }
2772
            }
2773
        }
2774
    }
2775
2776
    /**
2777
     * @return FluentPdoModel|$this
2778
     */
2779
    public function defaultFilters() : FluentPdoModel
2780
    {
2781
        return $this;
2782
    }
2783
2784
    /**
2785
     * @param bool $allow
2786
     *
2787
     * @return FluentPdoModel|$this
2788
     */
2789
    public function allowMetaColumnOverride(bool $allow=false) : FluentPdoModel
2790
    {
2791
        $this->_allow_meta_override = $allow;
2792
2793
        return $this;
2794
    }
2795
2796
    /**
2797
     * @param stdClass $record
2798
     * @return stdClass
2799
     */
2800
    public function onFetch(stdClass $record) : stdClass
2801
    {
2802
        $record     = $this->_trimAndLowerCaseKeys($record);
2803
        if ( $this->_filter_on_fetch )
2804
        {
2805
            $record     = $this->cleanseRecord($record);
2806
        }
2807
2808
        $record     =  $this->fixTypesToSentinel($record);
2809
2810
        return $this->fixTimestamps($record);
2811
    }
2812
2813
    /**
2814
     * @param $value
2815
     * @return string
2816
     */
2817
    public function gzEncodeData(string $value) : string
2818
    {
2819
        if ( $this->_hasGzipPrefix($value) )
2820
        {
2821
            return $value;
2822
        }
2823
2824
        return static::GZIP_PREFIX . base64_encode(gzencode($value, 9));
2825
    }
2826
2827
    /**
2828
     * @param $value
2829
     * @return mixed|string
2830
     */
2831
    public function gzDecodeData(string $value) : string
2832
    {
2833
        if ( ! $this->_hasGzipPrefix($value) )
2834
        {
2835
            return $value;
2836
        }
2837
        $value = substr_replace($value, '', 0, strlen(static::GZIP_PREFIX));
2838
2839
        return gzdecode(base64_decode($value));
2840
    }
2841
2842
    /**
2843
     * @param $value
2844
     * @return bool
2845
     */
2846
    protected function _hasGzipPrefix(string $value) : bool
2847
    {
2848
        return substr($value, 0, strlen(static::GZIP_PREFIX)) === static::GZIP_PREFIX ? true : false;
2849
    }
2850
2851
    /**
2852
     * @param stdClass $record
2853
     * @return stdClass
2854
     */
2855
    public function fixTimestamps(stdClass $record) : stdClass
2856
    {
2857
        foreach ( $record as $field => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2858
        {
2859
            if ( preg_match('/_ts$/', $field) )
2860
            {
2861
                $record->{$field} = is_null($value) ? null : static::atom($value);
2862
            }
2863
        }
2864
2865
        return $record;
2866
    }
2867
2868
    /**
2869
     * @param int $max
2870
     * @return FluentPdoModel|$this
2871
     */
2872
    public function setMaxRecords(int $max) : FluentPdoModel
2873
    {
2874
        Assert($max)->int();
2875
        $this->_default_max = $max;
2876
2877
        return $this;
2878
    }
2879
2880
2881
    /**
2882
     * @param stdClass $record
2883
     * @param string   $type
2884
     * @return stdClass
2885
     */
2886
    public function afterSave(stdClass $record, string $type) : stdClass
2887
    {
2888
        unset($type);
2889
        $this->clearCacheByTable();
2890
        foreach ( $record as $column => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2891
        {
2892
            if ( !empty($record->{$column}) )
2893
            {
2894
                if ( preg_match('/_ts$/', $column) )
2895
                {
2896
                    $record->{$column} = static::atom($value);
2897
                }
2898
                if ( preg_match('/_am$/', $column) )
2899
                {
2900
                    $record->{$column} = number_format($value, 2, '.', '');
2901
                }
2902
            }
2903
        }
2904
2905
        return $record;
2906
    }
2907
2908
2909
    /**
2910
     * @param stdClass $record
2911
     * @param string $type
2912
     * @return stdClass
2913
     */
2914
    public function addDefaultFields(stdClass $record, string $type) : stdClass
2915
    {
2916
        $columns            = $this->getColumns(true);
2917
        if ( empty($columns) )
2918
        {
2919
            return $record;
2920
        }
2921
        $defaults           = [
2922
            self::SAVE_UPDATE   => [
2923
                'modified_by_id'    => null,
2924
                'modified_ts'       => null,
2925
            ],
2926
            self::SAVE_INSERT   => [
2927
                'created_by_id'     => null,
2928
                'created_ts'        => null,
2929
                'modified_by_id'    => null,
2930
                'modified_ts'       => null,
2931
                'status'            => null,
2932
            ]
2933
        ];
2934
        $columns            = array_flip($this->getColumns());
2935
        $defaults           = array_intersect_key($defaults[$type], $columns);
2936
        foreach ( $defaults as $column => $def )
2937
        {
2938
            $record->{$column} = $record->{$column} ?? $def;
2939
        }
2940
2941
        return $record;
2942
    }
2943
2944
2945
    /**
2946
     * @return bool
2947
     */
2948
    public function createTable() : bool
2949
    {
2950
        return true;
2951
    }
2952
2953
    /**
2954
     * @return bool
2955
     */
2956
    public function dropTable() : bool
2957
    {
2958
        return true;
2959
    }
2960
2961
    protected function _compileHandlers()
2962
    {
2963
        if ( $this->_handlers )
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->_handlers of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
2964
        {
2965
            return;
2966
        }
2967
        $parentHandlers      = self::_getFieldHandlers();
2968
        $this->_handlers    = array_merge($parentHandlers, $this->_getFieldHandlers());
2969
    }
2970
2971
    /**
2972
     * @param string $viewName
2973
     * @param int $cacheTtl
2974
     * @return array
2975
     */
2976
    public function getViewColumns($viewName, $cacheTtl=self::CACHE_NO)
2977
    {
2978
        return $this->_getColumnsByTableFromDb($viewName, $cacheTtl);
2979
    }
2980
2981
    /**
2982
     * @param int $id
2983
     * @return string
2984
     */
2985
    public function getDisplayNameById(int $id) : string
2986
    {
2987
        $displayColumn  = $this->getDisplayColumn();
2988
        $className      = get_class($this);
2989
        Assert($displayColumn)->notEmpty("Could not determine the display column for model ({$className})");
2990
2991
        return $this
2992
            ->reset()
2993
            ->fetchStr($displayColumn, $id, self::ONE_HOUR);
2994
    }
2995
2996
    /**
2997
     * @param int $id
2998
     * @param string $displayColumnValue
2999
     * @return bool
3000
     */
3001
    public function validIdDisplayNameCombo(int $id, $displayColumnValue) : bool
3002
    {
3003
        return $displayColumnValue === $this->getDisplayNameById($id);
3004
    }
3005
3006
    /**
3007
     * @param array $toPopulate
3008
     * @return stdClass
3009
     */
3010
    protected function getEmptyObject(array $toPopulate=[]) : stdClass
3011
    {
3012
        $toPopulate[]   = 'id';
3013
3014
        return (object)array_flip($toPopulate);
3015
    }
3016
3017
    /**
3018
     * @param int $id
3019
     * @return bool
3020
     */
3021
    public static function isId(int $id) : bool
3022
    {
3023
        return $id > 0;
3024
    }
3025
3026
    /**
3027
     * @param int $cacheTtl
3028
     * @return int
3029
     */
3030
    public function activeCount(int $cacheTtl=self::CACHE_NO) : int
3031
    {
3032
        return (int)$this->whereActive()->count('*', $cacheTtl);
3033
    }
3034
3035
    /**
3036
     * @param string        $tableAlias
3037
     * @param string   $columnName
3038
     * @return FluentPdoModel|$this
3039
     */
3040
    public function whereActive(string $tableAlias='', string $columnName='status') : FluentPdoModel
3041
    {
3042
        return $this->whereStatus(static::ACTIVE, $tableAlias, $columnName);
3043
    }
3044
3045
    /**
3046
     * @param string        $tableAlias
3047
     * @param string        $columnName
3048
     * @return FluentPdoModel|$this
3049
     */
3050
    public function whereInactive(string $tableAlias='', string $columnName='status') : FluentPdoModel
3051
    {
3052
        return $this->whereStatus(static::INACTIVE, $tableAlias, $columnName);
3053
    }
3054
3055
    /**
3056
     * @param string        $tableAlias
3057
     * @param string        $columnName
3058
     * @return FluentPdoModel|$this
3059
     */
3060
    public function whereArchived(string $tableAlias='', string $columnName='status') : FluentPdoModel
3061
    {
3062
        return $this->whereStatus(static::ARCHIVED, $tableAlias, $columnName);
3063
    }
3064
3065
    /**
3066
     * @param int $status
3067
     * @param string $tableAlias
3068
     * @param string $columnName
3069
     * @return FluentPdoModel|$this
3070
     */
3071
    public function whereStatus(int $status, string $tableAlias='', string $columnName='status') : FluentPdoModel
3072
    {
3073
        Assert($status)->inArray([static::ACTIVE, static::INACTIVE, static::ARCHIVED]);
3074
3075
        $tableAlias = empty($tableAlias) ? $this->getTableAlias() : $tableAlias;
3076
        $field      = empty($tableAlias) ? $columnName : "{$tableAlias}.{$columnName}";
3077
3078
        return $this->where($field, $status);
3079
    }
3080
3081
    /**
3082
     * @param int $id
3083
     * @return int
3084
     */
3085
    public function updateActive(int $id=0) : int
3086
    {
3087
        Assert($id)->unsignedInt();
3088
        if ( $id )
3089
        {
3090
            $this->wherePk($id);
3091
        }
3092
3093
        return $this->updateStatus(static::ACTIVE);
3094
    }
3095
3096
    /**
3097
     * @param int $id
3098
     * @return int
3099
     */
3100
    public function updateInactive(int $id=0) : int
3101
    {
3102
        Assert($id)->unsignedInt();
3103
        if ( $id )
3104
        {
3105
            $this->wherePk($id);
3106
        }
3107
        return $this->updateStatus(static::INACTIVE);
3108
    }
3109
3110
    /**
3111
     * @param string $field
3112
     * @param int  $id
3113
     * @return int
3114
     */
3115
    public function updateNow(string $field, int $id=0) : int
3116
    {
3117
        Assert($field)->notEmpty();
3118
3119
        return $this->updateField($field, date('Y-m-d H:i:s'), $id);
3120
    }
3121
    /**
3122
     * @param string $field
3123
     * @param int  $id
3124
     * @return int
3125
     */
3126
    public function updateToday($field, int $id=0) : int
3127
    {
3128
        Assert($field)->notEmpty();
3129
3130
        return $this->updateField($field, date('Y-m-d'), $id);
3131
    }
3132
3133
    /**
3134
     * @param int $id
3135
     * @return int
3136
     */
3137
    public function updateArchived(int $id=0) : int
3138
    {
3139
        Assert($id)->unsignedInt();
3140
        if ( $id )
3141
        {
3142
            $this->wherePk($id);
3143
        }
3144
3145
        return $this->updateStatus(static::ARCHIVED);
3146
    }
3147
3148
    /**
3149
     * @param int $status
3150
     * @return int
3151
     * @throws \Exception
3152
     */
3153
    public function updateStatus(int $status)
3154
    {
3155
        Assert($status)->inArray([static::ACTIVE, static::INACTIVE, static::ARCHIVED]);
3156
3157
        return $this->updateField('status', $status);
3158
    }
3159
3160
    /**
3161
     * Return a YYYY-MM-DD HH:II:SS date format
3162
     *
3163
     * @param string $datetime - An english textual datetime description
3164
     *          now, yesterday, 3 days ago, +1 week
3165
     *          http://php.net/manual/en/function.strtotime.php
3166
     * @return string YYYY-MM-DD HH:II:SS
3167
     */
3168
    public static function NOW(string $datetime='now') : string
3169
    {
3170
        return (new DateTime($datetime ?: 'now'))->format('Y-m-d H:i:s');
3171
    }
3172
3173
    /**
3174
     * Return a string containing the given number of question marks,
3175
     * separated by commas. Eg '?, ?, ?'
3176
     *
3177
     * @param int - total of placeholder to insert
3178
     * @return string
3179
     */
3180
    protected function _makePlaceholders(int $numberOfPlaceholders=1) : string
3181
    {
3182
        return implode(', ', array_fill(0, $numberOfPlaceholders, '?'));
3183
    }
3184
3185
    /**
3186
     * Format the table{Primary|Foreign}KeyName
3187
     *
3188
     * @param  string $pattern
3189
     * @param  string $tableName
3190
     * @return string
3191
     */
3192
    protected function _formatKeyName(string $pattern, string $tableName) : string
3193
    {
3194
        return sprintf($pattern, $tableName);
3195
    }
3196
3197
3198
    /**
3199
     * @param array $query
3200
     * @param array $extraFields
3201
     * @return array
3202
     * @throws \Exception
3203
     */
3204
    protected function _prepareApiResource(array $query=[], array $extraFields=[]) : array
3205
    {
3206
        $this->defaultFilters()->filter($query)->paginate($query);
3207
        $pagingMetaData    = $this->getPagingMeta();
3208
        if ( $pagingMetaData['total'] === 0 )
3209
        {
3210
            return $pagingMetaData;
3211
        }
3212
        $this->withBelongsTo($pagingMetaData['fields']);
3213
        if ( ! empty($extraFields) )
3214
        {
3215
            $this->select($extraFields, '', false);
3216
        }
3217
        $this->removeUnauthorisedFields();
0 ignored issues
show
Unused Code introduced by
The call to the method Terah\FluentPdoModel\Flu...oveUnauthorisedFields() seems un-needed as the method has no side-effects.

PHP Analyzer performs a side-effects analysis of your code. A side-effect is basically anything that might be visible after the scope of the method is left.

Let’s take a look at an example:

class User
{
    private $email;

    public function getEmail()
    {
        return $this->email;
    }

    public function setEmail($email)
    {
        $this->email = $email;
    }
}

If we look at the getEmail() method, we can see that it has no side-effect. Whether you call this method or not, no future calls to other methods are affected by this. As such code as the following is useless:

$user = new User();
$user->getEmail(); // This line could safely be removed as it has no effect.

On the hand, if we look at the setEmail(), this method _has_ side-effects. In the following case, we could not remove the method call:

$user = new User();
$user->setEmail('email@domain'); // This line has a side-effect (it changes an
                                 // instance variable).
Loading history...
3218
        if ( ! empty($pagingMetaData['fields']) )
3219
        {
3220
            $this->removeUnrequestedFields($pagingMetaData['fields']);
3221
        }
3222
3223
        return $pagingMetaData;
3224
    }
3225
3226
    /**
3227
     * @param string $query
3228
     * @param array $parameters
3229
     *
3230
     * @return array
3231
     */
3232
    protected function _logQuery(string $query, array $parameters) : array
3233
    {
3234
        $query                  = $this->buildQuery($query, $parameters);
3235
        if ( ! $this->_log_queries )
3236
        {
3237
            return ['', ''];
3238
        }
3239
        $ident                  = substr(str_shuffle(md5($query)), 0, 10);
3240
        $this->getLogger()->debug($ident . ': ' . PHP_EOL . $query);
3241
        $this->_timer['start']  = microtime(true);
3242
3243
        return [$query, $ident];
3244
    }
3245
3246
    /**
3247
     * @param string $ident
3248
     * @param string $builtQuery
3249
     */
3250
    protected function _logSlowQueries(string $ident, string $builtQuery)
3251
    {
3252
        if ( ! $this->_log_queries )
3253
        {
3254
            return ;
3255
        }
3256
        $this->_timer['end']    = microtime(true);
3257
        $seconds_taken          = round($this->_timer['end'] - $this->_timer['start'], 3);
3258
        if ( $seconds_taken > $this->_slow_query_secs )
3259
        {
3260
            $this->getLogger()->warning("SLOW QUERY - {$ident} - {$seconds_taken} seconds:\n{$builtQuery}");
3261
        }
3262
    }
3263
3264
    /**
3265
     * @return float
3266
     */
3267
    public function getTimeTaken() : float
3268
    {
3269
        $secondsTaken = $this->_timer['end'] - $this->_timer['start'];
3270
3271
        return (float)$secondsTaken;
3272
    }
3273
3274
    /**
3275
     * @param $secs
3276
     * @return FluentPdoModel|$this
3277
     */
3278
    public function slowQuerySeconds(int $secs) : FluentPdoModel
3279
    {
3280
        Assert($secs)->notEmpty("Seconds cannot be empty.")->numeric("Seconds must be numeric.");
3281
        $this->_slow_query_secs = $secs;
3282
3283
        return $this;
3284
    }
3285
3286
3287
    /**
3288
     * @param       $field
3289
     * @param array $values
3290
     * @param string  $placeholderPrefix
3291
     *
3292
     * @return array
3293
     */
3294
    public function getNamedWhereIn(string $field, array $values, string $placeholderPrefix='') : array
3295
    {
3296
        Assert($field)->string()->notEmpty();
3297
        Assert($values)->isArray();
3298
3299
        if ( empty($values) )
3300
        {
3301
            return ['', []];
3302
        }
3303
        $placeholderPrefix      = $placeholderPrefix ?: strtolower(str_replace('.', '__', $field));
3304
        $params                 = [];
3305
        $placeholders           = [];
3306
        $count                  = 1;
3307
        foreach ( $values as $val )
3308
        {
3309
            $name                   = "{$placeholderPrefix}_{$count}";
3310
            $params[$name]          = $val;
3311
            $placeholders[]         = ":{$name}";
3312
            $count++;
3313
        }
3314
        $placeholders           = implode(',', $placeholders);
3315
3316
        return ["AND {$field} IN ({$placeholders})\n", $params];
3317
    }
3318
3319
    /**
3320
     * @param string $field
3321
     * @param string $delimiter
3322
     *
3323
     * @return array
3324
     */
3325
    protected function _getColumnAliasParts(string $field, string $delimiter=':') : array
3326
    {
3327
        $parts      = explode($delimiter, $field);
3328
        if ( count($parts) === 2 )
3329
        {
3330
            return $parts;
3331
        }
3332
3333
        return ['', $field];
3334
    }
3335
3336
    /**
3337
     * @param string $column
3338
     * @param string $term
3339
     * @return FluentPdoModel|$this
3340
     */
3341
    protected function _addWhereClause(string $column, string $term) : FluentPdoModel
3342
    {
3343
        $modifiers = [
3344
            'whereLike'         => '/^whereLike\(([%]?[ a-z0-9:-]+[%]?)\)$/i',
3345
            'whereNotLike'      => '/^whereNotLike\(([%]?[ a-z0-9:-]+[%]?)\)$/i',
3346
            'whereLt'           => '/^whereLt\(([ a-z0-9:-]+)\)$/i',
3347
            'whereLte'          => '/^whereLte\(([ a-z0-9:-]+)\)$/i',
3348
            'whereGt'           => '/^whereGt\(([ a-z0-9:-]+)\)$/i',
3349
            'whereGte'          => '/^whereGte\(([ a-z0-9:-]+)\)$/i',
3350
            'whereBetween'      => '/^whereBetween\(([ a-z0-9:-]+),([ a-z0-9:-]+)\)$/i',
3351
            'whereNotBetween'  => '/^whereNotBetween\(([ a-z0-9:-]+),([ a-z0-9:-]+)\)$/i',
3352
        ];
3353
        foreach ( $modifiers as $func => $regex )
3354
        {
3355
            if ( preg_match($regex, $term, $matches) )
3356
            {
3357
                array_shift($matches);
3358
                switch ($func)
3359
                {
3360
                    case 'whereLike':
3361
3362
                        return $this->whereLike($column, $matches[0]);
3363
3364
                    case 'whereNotLike':
3365
3366
                        return $this->whereNotLike($column, $matches[0]);
3367
3368
                    case 'whereLt':
3369
3370
                        return $this->whereLt($column, $matches[0]);
3371
3372
                    case 'whereLte':
3373
3374
                        return $this->whereLte($column, $matches[0]);
3375
3376
                    case 'whereGt':
3377
3378
                        return $this->whereGt($column, $matches[0]);
3379
3380
                    case 'whereGte':
3381
3382
                        return $this->whereGte($column, $matches[0]);
3383
3384
                    case 'whereBetween':
3385
3386
                        return $this->whereBetween($column, $matches[0], $matches[1]);
3387
3388
                    case 'whereNotBetween':
3389
3390
                        return $this->whereNotBetween($column, $matches[0], $matches[1]);
3391
3392
                }
3393
            }
3394
        }
3395
3396
        return $this->where($column, $term);
3397
    }
3398
3399
    public function destroy()
3400
    {
3401
        if ( !is_null($this->_pdo_stmt) )
3402
        {
3403
            $this->_pdo_stmt->closeCursor();
3404
        }
3405
        $this->_pdo_stmt    = null;
3406
        $this->_handlers    = [];
3407
    }
3408
3409
    public function __destruct()
3410
    {
3411
        $this->destroy();
3412
    }
3413
3414
    /**
3415
     * Load a model
3416
     *
3417
     * @param string $modelName
3418
     * @param AbstractPdo $connection
3419
     * @return FluentPdoModel|$this
3420
     * @throws ModelNotFoundException
3421
     */
3422
    public static function loadModel(string $modelName, AbstractPdo $connection=null) : FluentPdoModel
3423
    {
3424
        $modelName = static::$_model_namespace . $modelName;
3425
        if ( ! class_exists($modelName) )
3426
        {
3427
            throw new ModelNotFoundException("Failed to find model class {$modelName}.");
3428
        }
3429
3430
        return new $modelName($connection);
3431
    }
3432
3433
    /**
3434
     * Load a model
3435
     *
3436
     * @param string      $tableName
3437
     * @param AbstractPdo $connection
3438
     * @return FluentPdoModel|$this
3439
     */
3440
    public static function loadTable(string $tableName, AbstractPdo $connection=null) : FluentPdoModel
3441
    {
3442
        $modelName     = Inflector::classify($tableName);
3443
        Assert($modelName)->notEmpty("Could not resolve model name from table name.");
3444
3445
        return static::loadModel($modelName, $connection);
3446
    }
3447
3448
    /**
3449
     * @param string   $columnName
3450
     * @param int $cacheTtl
3451
     * @param bool $flushCache
3452
     * @return bool
3453
     */
3454
    public function columnExists(string $columnName, int $cacheTtl=self::CACHE_NO, bool $flushCache=false)
3455
    {
3456
        $columns = $this->getSchemaFromDb($cacheTtl, $flushCache);
3457
        return array_key_exists($columnName, $columns);
3458
    }
3459
3460
    /**
3461
     * @param int $cacheTtl
3462
     * @param bool $flushCache
3463
     * @return FluentPdoModel|$this
3464
     */
3465
    public function loadSchemaFromDb(int $cacheTtl=self::CACHE_NO, bool $flushCache=false) : FluentPdoModel
3466
    {
3467
        $schema = $this->getSchemaFromDb($cacheTtl, $flushCache);
3468
        $this->schema($schema);
3469
3470
        return $this;
3471
    }
3472
3473
    /**
3474
     * @param int $cacheTtl
3475
     * @param bool $flushCache
3476
     * @return array
3477
     */
3478
    public function getSchemaFromDb(int $cacheTtl=self::CACHE_NO, bool $flushCache=false) : array
3479
    {
3480
        $table      = $this->getTableName();
3481
        Assert($table)->string()->notEmpty();
3482
        $schema     = [];
3483
        $columns    = $this->_getColumnsByTableFromDb($table, $cacheTtl, $flushCache);
3484
        foreach ( $columns[$table] as $column => $meta )
3485
        {
3486
            $schema[$column] = $meta->data_type;
3487
        }
3488
        return $schema;
3489
    }
3490
3491
    /**
3492
     * @param string $table
3493
     * @param int $cacheTtl
3494
     * @param bool $flushCache
3495
     * @return array
3496
     */
3497
    protected function _getColumnsByTableFromDb(string $table, int $cacheTtl=self::CACHE_NO, bool $flushCache=false) : array
3498
    {
3499
        Assert($table)->string()->notEmpty();
3500
3501
        $callback = function() use ($table) {
3502
3503
            return $this->_connection->getColumns(true, $table);
3504
        };
3505
        $cacheKey   = '/column_schema/' . $table;
3506
        if ( $flushCache === true )
3507
        {
3508
            $this->clearCache($cacheKey);
3509
        }
3510
3511
        return (array)$this->_cacheData($cacheKey, $callback, $cacheTtl);
3512
    }
3513
3514
    /**
3515
     * @param string $table
3516
     * @return bool
3517
     */
3518
    public function clearSchemaCache(string $table) : bool
3519
    {
3520
        return $this->clearCache('/column_schema/' . $table);
3521
    }
3522
3523
    /**
3524
     * @param stdClass $record
3525
     * @return stdClass
3526
     */
3527
    public function cleanseRecord(stdClass $record) : stdClass
3528
    {
3529
        foreach ( $record as $field => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
3530
        {
3531
            if ( is_string($record->{$field}) )
3532
            {
3533
                $record->$field = str_replace(["\r\n", "\\r\\n", "\\n"], "\n", filter_var($record->$field, FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES));
3534
                if ( $this->_log_filter_changes && $value !== $record->$field )
3535
                {
3536
                    $table = $this->_table_name ? $this->_table_name : '';
3537
                    $this->getLogger()->debug("Field {$table}.{$field} has been cleansed", ['old' => $value, 'new' => $record->$field]);
3538
                }
3539
            }
3540
        }
3541
3542
        return $record;
3543
    }
3544
3545
    /**
3546
     * @param stdClass $record
3547
     * @param string   $type
3548
     * @return stdClass
3549
     */
3550
    public function beforeSave(stdClass $record, string $type) : stdClass
3551
    {
3552
        $record = $this->addDefaultFields($record, $type);
3553
        $record = $this->applyGlobalModifiers($record, $type);
3554
        $record = $this->applyHandlers($record, $type);
3555
        $record = $this->removeUnneededFields($record, $type);
3556
3557
        return $record;
3558
    }
3559
3560
    /**
3561
     * @param array $data
3562
     * @param string $saveType
3563
     * @return array
3564
     */
3565
    public function cleanseWebData(array $data, string $saveType) : array
3566
    {
3567
        Assert($saveType)->inArray([self::SAVE_UPDATE, self::SAVE_INSERT]);
3568
        $columns = $this->getColumns(false);
3569
        if ( empty($columns) )
3570
        {
3571
            return $data;
3572
        }
3573
        foreach ( $data as $field => $val )
3574
        {
3575
            $data[$field] = empty($val) && $val !== 0 ? null : $val;
3576
        }
3577
3578
        return array_intersect_key($data, $columns);
3579
    }
3580
3581
    /**
3582
     * @return array
3583
     */
3584
    public function skeleton() : array
3585
    {
3586
        $skel       = [];
3587
        $columns    = $this->columns(false);
3588
        foreach ( $columns as $column => $type )
3589
        {
3590
            $skel[$column] = null;
3591
        }
3592
3593
        return $skel;
3594
    }
3595
3596
    /**
3597
     * @param bool $toString
3598
     * @return array
3599
     */
3600
    public function getErrors(bool $toString=false) : array
3601
    {
3602
        if ( $toString )
3603
        {
3604
            $errors = [];
3605
            foreach ( $this->_errors as $field => $error )
3606
            {
3607
                $errors[] = implode("\n", $error);
3608
            }
3609
3610
            return implode("\n", $errors);
3611
        }
3612
3613
        return $this->_errors;
3614
    }
3615
3616
    /**
3617
     * @param bool $throw
3618
     * @return FluentPdoModel|$this
3619
     */
3620
    public function validationExceptions(bool $throw=true) : FluentPdoModel
3621
    {
3622
        $this->_validation_exceptions = $throw;
3623
3624
        return $this;
3625
    }
3626
3627
    /**
3628
     * @param array $query array('_limit' => int, '_offset' => int, '_order' => string, '_fields' => string, _search)
3629
     *
3630
     * @return FluentPdoModel|$this
3631
     * @throws Exception
3632
     */
3633
    public function paginate(array $query=[]) : FluentPdoModel
3634
    {
3635
        $_fields = $_order = $_limit = $_offset = null;
3636
        extract($query);
3637
        $this->_setLimit((int)$_limit, (int)$_offset);
3638
        $this->_setOrderBy((string)$_order);
3639
        $_fields    = is_array($_fields) ? $_fields : (string)$_fields;
3640
        $_fields    = empty($_fields) ? [] : $_fields;
3641
        $_fields    = is_string($_fields) ? explode('|', $_fields) : $_fields;
3642
        $_fields    = empty($_fields) ? [] : $_fields;
3643
        $this->_setFields(is_array($_fields) ? $_fields : explode('|', (string)$_fields));
3644
3645
        return $this;
3646
    }
3647
3648
    /**
3649
     * @param int $limit
3650
     * @param int $offset
3651
     * @return FluentPdoModel|$this
3652
     */
3653
    protected function _setLimit(int $limit=0, int $offset=0) : FluentPdoModel
3654
    {
3655
        $limit      = ! $limit || (int)$limit > (int)$this->_default_max ? (int)$this->_default_max : (int)$limit;
3656
        if ( ! is_numeric($limit) )
3657
        {
3658
            return $this;
3659
        }
3660
        $this->limit((int)$limit);
3661
        if ( $offset && is_numeric($offset) )
3662
        {
3663
            $this->offset((int)$offset);
3664
        }
3665
3666
        return $this;
3667
    }
3668
3669
    /**
3670
     * @param array $fields
3671
     * @return FluentPdoModel|$this
3672
     * @throws Exception
3673
     */
3674
    protected function _setFields(array $fields=[]) : FluentPdoModel
3675
    {
3676
        if ( ! $fields )
0 ignored issues
show
Bug Best Practice introduced by
The expression $fields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
3677
        {
3678
            return $this;
3679
        }
3680
        $this->explicitSelectMode();
3681
        $columns    = $this->getColumns();
3682
3683
        foreach ( $fields as $idx => $field )
3684
        {
3685
            list($alias, $field) = $this->_getColumnAliasParts($field);
3686
            $field = $field === '_display_field' ? $this->_display_column : $field;
3687
            // Regular primary table field
3688
            if ( ( empty($alias) || $alias === $this->_table_alias ) && in_array($field, $columns) )
3689
            {
3690
                $this->select("{$this->_table_alias}.{$field}");
3691
                $this->_requested_fields[] = "{$this->_table_alias}.{$field}";
3692
                continue;
3693
            }
3694
            // Reference table field with alias
3695
            if ( ! empty($alias) )
3696
            {
3697
                Assert($this->_associations['belongsTo'])->keyExists($alias, "Invalid table alias ({$alias}) specified for the field query");
3698
                Assert($field)->eq($this->_associations['belongsTo'][$alias][3], "Invalid field ({$alias}.{$field}) specified for the field query");
3699
                list(, , $join_field, $fieldAlias) = $this->_associations['belongsTo'][$alias];
3700
                $this->autoJoin($alias, static::LEFT_JOIN, false);
3701
                $this->select($join_field, $fieldAlias);
3702
                $this->_requested_fields[] = $fieldAlias;
3703
                continue;
3704
            }
3705
            // Reference table select field without alias
3706
            $belongsTo = array_key_exists('belongsTo', $this->_associations) ?  $this->_associations['belongsTo'] : [];
3707
            foreach ( $belongsTo as $joinAlias => $config )
3708
            {
3709
                list(, , $join_field, $fieldAlias) = $config;
3710
                if ( $field === $fieldAlias )
3711
                {
3712
                    $this->autoJoin($joinAlias, static::LEFT_JOIN, false);
3713
                    $this->select($join_field, $fieldAlias);
3714
                    $this->_requested_fields[] = $fieldAlias;
3715
                    continue;
3716
                }
3717
            }
3718
        }
3719
3720
        return $this;
3721
    }
3722
3723
    /**
3724
     * @param string $orderBy
3725
     * @return FluentPdoModel|$this|FluentPdoModel
3726
     */
3727
    protected function _setOrderBy(string $orderBy='') : FluentPdoModel
3728
    {
3729
        if ( ! $orderBy )
3730
        {
3731
            return $this;
3732
        }
3733
        $columns                    = $this->getColumns();
3734
        list($order, $direction)    = strpos($orderBy, ',') !== false ? explode(',', $orderBy) : [$orderBy, 'ASC'];
3735
        list($alias, $field)        = $this->_getColumnAliasParts(trim($order), '.');
3736
        $field                      = explode(' ', $field);
3737
        $field                      = trim($field[0]);
3738
        $direction                  = ! in_array(strtoupper(trim($direction)), ['ASC', 'DESC']) ? 'ASC' : strtoupper(trim($direction));
3739
        $belongsTo                  = array_key_exists('belongsTo', $this->_associations) ? $this->_associations['belongsTo'] : [];
3740
        // Regular primary table order by
3741
        if ( ( empty($alias) || $alias === $this->_table_alias ) && in_array($field, $columns) )
3742
        {
3743
            return $this->orderBy("{$this->_table_alias}.{$field}", $direction);
3744
        }
3745
        // Reference table order by with alias
3746
        if ( ! empty($alias) )
3747
        {
3748
            Assert($belongsTo)->keyExists($alias, "Invalid table alias ({$alias}) specified for the order query");
3749
            Assert($field)->eq($belongsTo[$alias][3], "Invalid field ({$alias}.{$field}) specified for the order query");
3750
3751
            return $this->autoJoin($alias)->orderBy("{$alias}.{$field}", $direction);
3752
        }
3753
        // Reference table order by without alias
3754
        foreach ( $belongsTo as $joinAlias => $config )
3755
        {
3756
            if ( $field === $config[3] )
3757
            {
3758
                return $this->autoJoin($joinAlias)->orderBy($config[2], $direction);
3759
            }
3760
        }
3761
3762
        return $this;
3763
    }
3764
3765
    /**
3766
     * @return array
3767
     */
3768
    public function getPagingMeta()
3769
    {
3770
        if ( empty($this->_paging_meta) )
3771
        {
3772
            $this->setPagingMeta();
3773
        }
3774
3775
        return $this->_paging_meta;
3776
    }
3777
3778
    /**
3779
     * @return FluentPdoModel|$this
3780
     */
3781
    public function setPagingMeta() : FluentPdoModel
3782
    {
3783
        $model                  = clone $this;
3784
        $limit                  = intval($this->getLimit());
3785
        $offset                 = intval($this->getOffset());
3786
        $total                  = intval($model->withBelongsTo()->select('')->offset(0)->limit(0)->orderBy()->count());
3787
        unset($model->_handlers, $model); //hhmv mem leak
3788
        $order_bys              = ! is_array($this->_order_by) ? [] : $this->_order_by;
3789
        $this->_paging_meta     = [
3790
            'limit'                 => $limit,
3791
            'offset'                => $offset,
3792
            'page'                  => $offset === 0 ? 1 : intval( $offset / $limit ) + 1,
3793
            'pages'                 => $limit === 0 ? 1 : intval(ceil($total / $limit)),
3794
            'order'                 => $order_bys,
3795
            'total'                 => $total = $limit === 1 && $total > 1 ? 1 : $total,
3796
            'filters'               => $this->_filter_meta,
3797
            'fields'                => $this->_requested_fields,
3798
            'perms'                 => [],
3799
        ];
3800
3801
        return $this;
3802
    }
3803
3804
    /**
3805
     * Take a web request and format a query
3806
     *
3807
     * @param array $query
3808
     *
3809
     * @return FluentPdoModel|$this
3810
     * @throws Exception
3811
     */
3812
    public function filter(array $query=[]) : FluentPdoModel
3813
    {
3814
        $columns   = $this->getColumns(false);
3815
        $alias     = '';
3816
        foreach ( $query as $column => $value )
3817
        {
3818
            if ( in_array($column, $this->_pagination_attribs) )
3819
            {
3820
                continue;
3821
            }
3822
            $field = $this->_findFieldByQuery($column, $this->_display_column);
3823
            if ( is_null($field) )
3824
            {
3825
                continue;
3826
            }
3827
            $this->_filter_meta[$field]     = $value;
3828
            $where                          = !is_array($value) && mb_stripos($value, '|') !== false ? explode('|', $value) : $value;
3829
            if ( is_array($where) )
3830
            {
3831
                $this->whereIn($field, $where);
3832
            }
3833
            else
3834
            {
3835
                $this->_addWhereClause($field, (string)$where);
3836
            }
3837
        }
3838
        if ( empty($query['_search']) )
3839
        {
3840
            return $this;
3841
        }
3842
        $alias          = !empty($alias) ? $alias : $this->_table_alias;
3843
        $string_cols    = array_filter($columns, function($type) {
3844
3845
            return in_array($type, ['varchar', 'text', 'enum']);
3846
        });
3847
        $terms          = explode('|', $query['_search']);
3848
        $where_likes    = [];
3849
        foreach ( $string_cols as $column => $type )
3850
        {
3851
            foreach ( $terms as $term )
3852
            {
3853
                $where_likes["{$alias}.{$column}"] = "%{$term}%";
3854
            }
3855
        }
3856
        // Reference fields...
3857
        $belongsTo = $this->getSearchableAssociations();
3858
        foreach ( $belongsTo as $alias => $config )
3859
        {
3860
            foreach ( $terms as $term )
3861
            {
3862
                $where_likes[$config[2]] = "%{$term}%";
3863
            }
3864
        }
3865
        if ( empty($where_likes) )
3866
        {
3867
            return $this;
3868
        }
3869
        $this->where([1=>1])->wrap()->_and();
3870
        foreach ( $where_likes as $column => $term )
3871
        {
3872
            $this->_or()->whereLike($column, $term);
3873
        }
3874
        $this->wrap();
3875
3876
        return $this;
3877
    }
3878
3879
    /**
3880
     * @param string $column
3881
     * @param string $displayCol
3882
     * @return string|null
3883
     */
3884
    protected function _findFieldByQuery(string $column, string $displayCol)
3885
    {
3886
        list($alias, $field)    = $this->_getColumnAliasParts($column);
3887
        $field                  = $field === '_display_field' ? $displayCol : $field;
3888
        $columns                = $this->getColumns();
3889
        $tableAlias             = $this->getTableAlias();
3890
        if ( ! empty($alias) && $alias === $tableAlias )
3891
        {
3892
            // Alias is set but the field isn't correct
3893
            if ( ! in_array($field, $columns) )
3894
            {
3895
                return null;
3896
            }
3897
            return "{$alias}.{$field}";
3898
        }
3899
        // Alias isn't passed in but the field is ok
3900
        if ( empty($alias) && in_array($field, $columns) )
3901
        {
3902
            return "{$tableAlias}.{$field}";
3903
        }
3904
        // Alias is passed but not this table in but there is a matching field on this table
3905
        if ( ! empty($alias) && in_array($field, $columns) )
3906
        {
3907
            return null;
3908
        }
3909
        // Now search the associations for the field
3910
        $associations = $this->getSearchableAssociations();
3911
        if ( ! empty($alias) )
3912
        {
3913
            if ( array_key_exists($alias, $associations) && $associations[$alias][3] === $field )
3914
            {
3915
                return "{$alias}.{$field}";
3916
            }
3917
3918
            return null;
3919
        }
3920
        foreach ( $associations as $assocAlias => $config )
3921
        {
3922
            list(, , $assocField, $fieldAlias) = $config;
3923
            if ( $fieldAlias === $field )
3924
            {
3925
                return $assocField;
3926
            }
3927
        }
3928
3929
        return null;
3930
    }
3931
3932
    /**
3933
     * @param $keysOnly
3934
     * @return array
3935
     */
3936
3937
    public function columns(bool $keysOnly=true) : array
3938
    {
3939
        return $keysOnly ? array_keys($this->_schema) : $this->_schema;
3940
    }
3941
3942
    /**
3943
     * @param string $field
3944
     * @param mixed $value
3945
     * @param array $pdoMetaData
3946
     * @return float|int
3947
     * @throws Exception
3948
     */
3949
    protected function _fixTypeToSentinel(string $field, $value, array $pdoMetaData=[])
3950
    {
3951
        Assert($value)->nullOr()->scalar("var is type of " . gettype($value));
3952
3953
        $fieldType      = strtolower($pdoMetaData['native_type']) ?: null;
3954
        if ( ! $fieldType )
0 ignored issues
show
Bug Best Practice introduced by
The expression $fieldType of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
3955
        {
3956
            if ( empty($this->_schema) )
3957
            {
3958
                return $value;
3959
            }
3960
            $columns    = $this->getColumns(false);
3961
            Assert($columns)->keyExists($field, "The property {$field} does not exist.");
3962
3963
            $fieldType = $columns[$field] ?: null;
3964
        }
3965
3966
3967
        // Don't cast invalid values... only those that can be cast cleanly
3968
        switch ( $fieldType )
3969
        {
3970
            case 'varchar':
3971
            case 'var_string':
3972
            case 'string':
3973
            case 'text';
3974
            case 'date':
3975
            case 'datetime':
3976
            case 'timestamp':
3977
            case 'blob':
3978
3979
                return (string)$value;
3980
3981
            case 'int':
3982
            case 'integer':
3983
            case 'tinyint':
3984
            case 'tiny':
3985
            case 'long':
3986
            case 'longlong':
3987
3988
                return (int)$value;
3989
3990
            case 'decimal':
3991
            case 'newdecimal':
3992
3993
                return (float)$value;
3994
3995
            default:
3996
3997
                throw new Exception('Unknown type: ' . $fieldType);
3998
                return $value;
0 ignored issues
show
Unused Code introduced by
return $value; does not seem to be reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
3999
        }
4000
    }
4001
4002
    /**
4003
     * @param string $field
4004
     * @param mixed $value
4005
     * @param bool|false $permissive
4006
     * @return float|int|null|string
4007
     */
4008
    protected function _fixType(string $field, $value, bool $permissive=false)
4009
    {
4010
        Assert($value)->nullOr()->scalar("var is type of " . gettype($value));
4011
        if ( empty($this->_schema) || ( ! array_key_exists($field, $this->_schema) && $permissive ) )
4012
        {
4013
            return $value;
4014
        }
4015
        $columns    = $this->getColumns(false);
4016
        Assert($columns)->keyExists($field, "The property {$field} does not exist.");
4017
4018
        $fieldType = ! empty($columns[$field]) ? $columns[$field] : null;
4019
4020
        if ( is_null($value) )
4021
        {
4022
            return null;
4023
        }
4024
        // return on null, '' but not 0
4025
        if ( ! is_numeric($value) && empty($value) )
4026
        {
4027
            return null;
4028
        }
4029
        // Don't cast invalid values... only those that can be cast cleanly
4030
        switch ( $fieldType )
4031
        {
4032
            case 'varchar':
4033
            case 'text';
4034
            case 'date':
4035
            case 'datetime':
4036
            case 'timestamp':
4037
4038
                // return on null, '' but not 0
4039
                return ! is_numeric($value) && empty($value) ? null : (string)$value;
4040
4041
            case 'int':
4042
4043
                if ( $field === 'id' || substr($field, -3) === '_id' )
4044
                {
4045
                    return $value ? (int)$value : null;
4046
                }
4047
4048
                return ! is_numeric($value) ? null : (int)$value;
4049
4050
            case 'decimal':
4051
4052
                return ! is_numeric($value) ? null : (float)$value;
4053
4054
            default:
4055
4056
                return $value;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $value; (integer|double|string|object|array|boolean) is incompatible with the return type documented by Terah\FluentPdoModel\FluentPdoModel::_fixType of type double|integer|null|string.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
4057
        }
4058
    }
4059
4060
    /**
4061
     * @param stdClass $record
4062
     * @param string $type
4063
     * @return stdClass
4064
     */
4065
    public function fixTypesToSentinel(stdClass $record, string $type='') : stdClass
4066
    {
4067
        foreach ( $this->row_meta_data as $column => $pdoMetaData )
4068
        {
4069
            if ( ! property_exists($record, $column) )
4070
            {
4071
                continue;
4072
            }
4073
            $record->{$column} = $this->_fixTypeToSentinel($column, $record->{$column}, $pdoMetaData);
4074
        }
4075
        // PDO might not be able to generate the meta data to sniff types
4076
        if ( ! $this->row_meta_data )
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->row_meta_data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
4077
        {
4078
            foreach ( $this->getColumns(false) as $column => $fieldType )
4079
            {
4080
                if ( ! property_exists($record, $column) )
4081
                {
4082
                    continue;
4083
                }
4084
                $record->{$column} = $this->_fixTypeToSentinel($column, $record->{$column});
4085
            }
4086
        }
4087
4088
        unset($type);
4089
4090
        return $record;
4091
    }
4092
4093
    /**
4094
     * @param stdClass $record
4095
     * @param string   $type
4096
     * @return stdClass
4097
     * @throws Exception
4098
     */
4099
    public function applyHandlers(stdClass $record, string $type='INSERT') : stdClass
4100
    {
4101
        $this->_compileHandlers();
4102
        $this->_errors                  = [];
4103
        // Disable per field exceptions so we can capture all errors for the record
4104
        $tmpExceptions                  = $this->_validation_exceptions;
4105
        $this->_validation_exceptions   = false;
4106
        foreach ( $this->_handlers as $field => $fn_validator )
4107
        {
4108
            if ( ! property_exists($record, $field) )
4109
            {
4110
                // If the operation is an update it can be a partial update
4111
                if ( $type === self::SAVE_UPDATE )
4112
                {
4113
                    continue;
4114
                }
4115
                $record->{$field}               = null;
4116
            }
4117
            $record->{$field}               = $this->applyHandler($field, $record->{$field}, $type, $record);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $record->{$field} is correct as $this->applyHandler($fie...field}, $type, $record) (which targets Terah\FluentPdoModel\Flu...doModel::applyHandler()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
4118
        }
4119
        $this->_validation_exceptions = $tmpExceptions;
4120
        if ( $this->_validation_exceptions && ! empty($this->_errors) )
4121
        {
4122
            throw new ModelFailedValidationException("Validation of data failed", $this->getErrors(), 422);
4123
        }
4124
4125
        return $record;
4126
    }
4127
4128
    /**
4129
     * @param string $field
4130
     * @param mixed $value
4131
     * @param string $type
4132
     * @param stdClass $record
4133
     * @return null
4134
     * @throws Exception
4135
     */
4136
    protected function applyHandler(string $field, $value, string $type='', stdClass $record=null)
4137
    {
4138
        $this->_compileHandlers();
4139
        $fnHandler = ! empty($this->_handlers[$field]) ? $this->_handlers[$field] : null;
4140
        if ( is_callable($fnHandler) )
4141
        {
4142
            try
4143
            {
4144
                $value = $fnHandler($field, $value, $type, $record);
4145
            }
4146
            catch( Exception $e )
4147
            {
4148
                $this->_errors[$field][] = $e->getMessage();
4149
                if ( $this->_validation_exceptions && ! empty($this->_errors) )
4150
                {
4151
                    throw new ModelFailedValidationException("Validation of data failed", $this->getErrors(), 422);
4152
                }
4153
4154
                return null;
4155
            }
4156
        }
4157
4158
        return $value;
4159
    }
4160
4161
    /**
4162
     * @param string $start
4163
     * @param string $end
4164
     * @param string $hayStack
4165
     * @return mixed
4166
     */
4167
    public static function between(string $start, string $end, string $hayStack) : string
4168
    {
4169
        return static::before($end, static::after($start, $hayStack));
4170
    }
4171
4172
    /**
4173
     * @param string     $needle
4174
     * @param string     $hayStack
4175
     * @param bool $returnOrigIfNeedleNotExists
4176
     * @return mixed
4177
     */
4178
    public static function before(string $needle, string $hayStack, bool $returnOrigIfNeedleNotExists=false) : string
4179
    {
4180
        $result = mb_substr($hayStack, 0, mb_strpos($hayStack, $needle));
4181
        if ( !$result && $returnOrigIfNeedleNotExists )
4182
        {
4183
            return $hayStack;
4184
        }
4185
4186
        return $result;
4187
    }
4188
4189
    /**
4190
     * @param string     $needle
4191
     * @param string     $hayStack
4192
     * @param bool $returnOrigIfNeedleNotExists
4193
     * @return string
4194
     */
4195
    public static function after(string $needle, string $hayStack, bool $returnOrigIfNeedleNotExists=false) : string
4196
    {
4197
        if ( ! is_bool(mb_strpos($hayStack, $needle)) )
4198
        {
4199
            return mb_substr($hayStack, mb_strpos($hayStack, $needle) + mb_strlen($needle));
4200
        }
4201
4202
        return $returnOrigIfNeedleNotExists ? $hayStack : '';
4203
    }
4204
4205
    /**
4206
     * @return int
4207
     */
4208
    public function getUserId()
4209
    {
4210
        return 0;
4211
    }
4212
4213
    /**
4214
     * @param string $entity
4215
     * @param int $id
4216
     * @return int
4217
     */
4218
    public function getMaskByResourceAndId(string $entity, int $id) : int
0 ignored issues
show
Unused Code introduced by
The parameter $entity is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $id is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
4219
    {
4220
        return 31;
4221
    }
4222
4223
    /**
4224
     * @param string|int|null $time
4225
     * @return string
4226
     */
4227
    public static function date($time=null) : string
4228
    {
4229
        return date('Y-m-d', static::getTime($time));
4230
    }
4231
4232
    /**
4233
     * @param string|int|null $time
4234
     * @return string
4235
     */
4236
    public static function dateTime($time=null) : string
4237
    {
4238
        return date('Y-m-d H:i:s', static::getTime($time));
4239
    }
4240
4241
    /**
4242
     * @param string|int|null $time
4243
     * @return string
4244
     */
4245
    public static function atom($time=null) : string
4246
    {
4247
        return date('Y-m-d\TH:i:sP', static::getTime($time));
4248
    }
4249
4250
    /**
4251
     * @param string|int|null $time
4252
     * @return int
4253
     */
4254
    public static function getTime($time=null) : int
4255
    {
4256
        if ( ! $time )
4257
        {
4258
            return time();
4259
        }
4260
        if ( is_int($time) )
4261
        {
4262
            return $time;
4263
        }
4264
4265
        return strtotime($time);
4266
    }
4267
4268
4269
    /**
4270
     * @param int $id
4271
     * @param int $cacheTtl
4272
     * @return string
4273
     */
4274
    public function getCodeById(int $id, int $cacheTtl=self::ONE_DAY) : string
4275
    {
4276
        Assert($id)->id();
4277
        $code   = $this->defaultFilters()->fetchStr($this->getDisplayColumn(), $id, $cacheTtl);
4278
        Assert($code)->notEmpty();
4279
4280
        return $code;
4281
    }
4282
}
4283