Completed
Push — master ( 84da40...7b0909 )
by Terry
05:39
created

FluentPdoModel::getTimeTaken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 0
dl 0
loc 6
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
     * @return bool
2295
     */
2296
    public function isSoftDelete() : bool
2297
    {
2298
        return $this->_soft_deletes;
2299
    }
2300
2301
    /**
2302
     * @param bool|false $force
2303
     * @return FluentPdoModel|$this
2304
     * @throws Exception
2305
     */
2306
    public function truncate(bool $force=false) : FluentPdoModel
2307
    {
2308
        if ( $force )
2309
        {
2310
            $this->execute('SET FOREIGN_KEY_CHECKS = 0');
2311
        }
2312
        $this->execute("TRUNCATE TABLE {$this->_table_name}");
2313
        if ( $force )
2314
        {
2315
            $this->execute('SET FOREIGN_KEY_CHECKS = 1');
2316
        }
2317
2318
        return $this;
2319
    }
2320
2321
    /**
2322
     * @return array
2323
     */
2324
    public function deleteSqlQuery() : array
2325
    {
2326
        $query  = "DELETE FROM {$this->_table_name}";
2327
        if ( !empty($this->_where_conditions) )
2328
        {
2329
            $query .= $this->_getWhereString(true);
2330
2331
            return [$query, $this->_getWhereParameters()];
2332
        }
2333
2334
        return [$query, []];
2335
    }
2336
2337
2338
    /**
2339
     * Return the aggregate count of column
2340
     *
2341
     * @param string $column
2342
     * @param int $cacheTtl
2343
     * @return float
2344
     */
2345
    public function count(string $column='*', int $cacheTtl=self::CACHE_NO) : float
2346
    {
2347
        return $this
2348
            ->explicitSelectMode()
2349
            ->fetchFloat("COUNT({$column}) AS cnt", 0, $cacheTtl);
2350
    }
2351
2352
2353
    /**
2354
     * Return the aggregate max count of column
2355
     *
2356
     * @param string $column
2357
     * @param int $cacheTtl
2358
     * @return int|float|string|null
2359
     */
2360
    public function max(string $column, int $cacheTtl=self::CACHE_NO)
2361
    {
2362
        return $this
2363
            ->explicitSelectMode()
2364
            ->fetchField("MAX({$column}) AS max", 0, $cacheTtl);
2365
    }
2366
2367
2368
    /**
2369
     * Return the aggregate min count of column
2370
     *
2371
     * @param string $column
2372
     * @param int $cacheTtl
2373
     * @return int|float|string|null
2374
     */
2375
    public function min(string $column, int $cacheTtl=self::CACHE_NO)
2376
    {
2377
        return $this
2378
            ->explicitSelectMode()
2379
            ->fetchField("MIN({$column}) AS min", 0, $cacheTtl);
2380
    }
2381
2382
    /**
2383
     * Return the aggregate sum count of column
2384
     *
2385
     * @param string $column
2386
     * @param int $cacheTtl
2387
     * @return int|float|string|null
2388
     */
2389
    public function sum(string $column, int $cacheTtl=self::CACHE_NO)
2390
    {
2391
        return $this
2392
            ->explicitSelectMode()
2393
            ->fetchField("SUM({$column}) AS sum", 0, $cacheTtl);
2394
    }
2395
2396
    /**
2397
     * Return the aggregate average count of column
2398
     *
2399
     * @param string $column
2400
     * @param int $cacheTtl
2401
     * @return int|float|string|null
2402
     */
2403
    public function avg(string $column, int $cacheTtl=self::CACHE_NO)
2404
    {
2405
        return $this
2406
            ->explicitSelectMode()
2407
            ->fetchField("AVG({$column}) AS avg", 0, $cacheTtl);
2408
    }
2409
2410
    /*******************************************************************************/
2411
// Utilities methods
2412
2413
    /**
2414
     * Reset fields
2415
     *
2416
     * @return FluentPdoModel|$this
2417
     */
2418
    public function reset() : FluentPdoModel
2419
    {
2420
        $this->_where_parameters        = [];
2421
        $this->_select_fields           = [];
2422
        $this->_join_sources            = [];
2423
        $this->_join_aliases            = [];
2424
        $this->_where_conditions        = [];
2425
        $this->_limit                   = 0;
2426
        $this->_offset                  = 0;
2427
        $this->_order_by                = [];
2428
        $this->_group_by                = [];
2429
        $this->_and_or_operator         = self::OPERATOR_AND;
2430
        $this->_having                  = [];
2431
        $this->_wrap_open               = false;
2432
        $this->_last_wrap_position      = 0;
2433
        $this->_pdo_stmt                = null;
2434
        $this->_distinct                = false;
2435
        $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...
2436
        $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...
2437
        $this->_cache_ttl               = -1;
2438
        $this->_timer                   = [];
2439
        $this->_built_query             = '';
2440
        $this->_paging_meta             = [];
2441
        $this->_raw_sql                 = null;
2442
        $this->_explicit_select_mode    = false;
2443
2444
        return $this;
2445
    }
2446
2447
2448
    /**
2449
     * @return FluentPdoModel|$this
2450
     */
2451
    public function removeUnauthorisedFields() : FluentPdoModel
2452
    {
2453
        return $this;
2454
    }
2455
2456
    /**
2457
     * @return Closure[]
2458
     */
2459
    protected function _getFieldHandlers() : array
2460
    {
2461
        $columns = $this->getColumns(true);
2462
        if ( empty($columns) )
2463
        {
2464
            return [];
2465
        }
2466
        return [
2467
            'id' => function(string $field, $value, string $type='', stdClass $record=null) {
2468
2469
                unset($record);
2470
                $value = $this->_fixType($field, $value);
2471
                if ( $type === self::SAVE_INSERT )
2472
                {
2473
                    Validate($value)->name($field)->nullOr()->id('ID must be a valid integer id, (%s) submitted.');
2474
                    return $value;
2475
                }
2476
                Validate($value)->name($field)->id('ID must be a valid integer id, (%s) submitted.');
2477
                return $value;
2478
            },
2479
            'created_by_id' => function(string $field, $value, string $type='', stdClass $record=null) {
2480
2481
                unset($type, $record);
2482
                $value = $this->_fixType($field, $value);
2483
                // Created user id is set to current user if record is an insert or deleted if not (unless override is true)
2484
                $value = $this->_allow_meta_override ? $value : $this->getUserId();
2485
                Validate($value)->name($field)->id('Created By must be a valid integer id, (%s) submitted.');
2486
                return $value;
2487
            },
2488
            'created_ts' => function(string $field, $value, string $type='', stdClass $record=null) {
2489
2490
                unset($type, $record);
2491
                $value = $this->_fixType($field, $value);
2492
                // Created ts is set to now if record is an insert or deleted if not (unless override is true)
2493
                $value = static::dateTime($this->_allow_meta_override ? $value : null);
2494
                Validate($value)->name($field)->date('Created must be a valid timestamp, (%s) submitted.');
2495
                return $value;
2496
            },
2497
            'modified_by_id' => function(string $field, $value, string $type='', stdClass $record=null) {
2498
2499
                unset($type, $record);
2500
                $value = $this->_fixType($field, $value);
2501
                // Modified user id is set to current user (unless override is true)
2502
                $value = $this->_allow_meta_override ? $value : $this->getUserId();
2503
                Validate($value)->name($field)->id('Modified By must be a valid integer id, (%s) submitted.');
2504
                return $value;
2505
            },
2506
            'modified_ts' => function(string $field, $value, string $type='', stdClass $record=null) {
2507
2508
                unset($type, $record);
2509
                $value = $this->_fixType($field, $value);
2510
                // Modified timestamps are set to now (unless override is true)
2511
                $value = static::dateTime($this->_allow_meta_override ? $value : null);
2512
                Validate($value)->name($field)->date('Modified must be a valid timestamp, (%s) submitted.');
2513
                return $value;
2514
            },
2515
            'status' => function(string $field, $value, string $type='', stdClass $record=null) {
2516
2517
                unset($type, $record);
2518
                $value = $this->_fixType($field, $value);
2519
                // Statuses are set to active if not set
2520
                $value = is_null($value) ? self::ACTIVE : $value;
2521
                Validate($value)->name($field)->nullOr()->status('Status must be a valid integer between -1 and 1, (%s) submitted.');
2522
                return $value;
2523
            },
2524
        ];
2525
    }
2526
2527
    /**
2528
     * @return bool
2529
     */
2530
    public function begin() : bool
2531
    {
2532
        $pdo        = $this->getPdo();
2533
        $oldDepth   = $pdo->getTransactionDepth();
2534
        $res        = $pdo->beginTransaction();
2535
        $newDepth   = $pdo->getTransactionDepth();
2536
        $this->getLogger()->debug("Calling db begin transaction", [
2537
            'old_depth'     => $oldDepth,
2538
            'new_depth'     => $newDepth,
2539
            'trans_started' => $newDepth === 1 ? true : false,
2540
        ]);
2541
2542
        return $res;
2543
    }
2544
2545
    /**
2546
     * @return bool
2547
     */
2548
    public function commit() : bool
2549
    {
2550
        $pdo        = $this->getPdo();
2551
        $oldDepth   = $pdo->getTransactionDepth();
2552
        $res        = $pdo->commit();
2553
        $newDepth   = $pdo->getTransactionDepth();
2554
        $this->getLogger()->debug("Calling db commit transaction", [
2555
            'old_depth'     => $oldDepth,
2556
            'new_depth'     => $newDepth,
2557
            'trans_ended'   => $newDepth === 0 ? true : false,
2558
        ]);
2559
        if ( ! $res )
2560
        {
2561
            return false;
2562
        }
2563
2564
        return $res === 0 ? true : $res;
2565
    }
2566
2567
    /**
2568
     * @return bool
2569
     */
2570
    public function rollback() : bool
2571
    {
2572
        $pdo        = $this->getPdo();
2573
        $oldDepth   = $pdo->getTransactionDepth();
2574
        $res        = $pdo->rollback();
2575
        $newDepth   = $pdo->getTransactionDepth();
2576
        $this->getLogger()->debug("Calling db rollback transaction", [
2577
            'old_depth'     => $oldDepth,
2578
            'new_depth'     => $newDepth,
2579
            'trans_ended'   => $newDepth === 0 ? true : false,
2580
        ]);
2581
2582
        return $res;
2583
    }
2584
2585
    /**
2586
     * @param stdClass $record
2587
     * @param  string  $type
2588
     * @return stdClass
2589
     */
2590
    public function applyGlobalModifiers(stdClass $record, string $type) : stdClass
2591
    {
2592
        unset($type);
2593
        foreach ( $record as $field => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2594
        {
2595
            if ( is_string($record->{$field}) )
2596
            {
2597
                $record->{$field} = str_replace(["\r\n", "\\r\\n", "\\n"], "\n", $value);
2598
            }
2599
        }
2600
2601
        return $record;
2602
    }
2603
2604
    /**
2605
     * @param stdClass $record
2606
     * @param  string $type
2607
     * @return stdClass
2608
     */
2609
    public function removeUnneededFields(stdClass $record, string $type) : stdClass
2610
    {
2611
        // remove un-needed fields
2612
        $columns = $this->getColumns(true);
2613
        if ( empty($columns) )
2614
        {
2615
            return $record;
2616
        }
2617
        foreach ( $record as $name => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2618
        {
2619
            if ( ! in_array($name, $columns) || in_array($name, $this->_virtual_fields) )
2620
            {
2621
                unset($record->{$name});
2622
            }
2623
        }
2624
        if ( property_exists($record, 'created_ts') && $type !== 'INSERT' && ! $this->_allow_meta_override )
2625
        {
2626
            unset($record->created_ts);
2627
        }
2628
        if ( property_exists($record, 'created_by_id') && $type !== 'INSERT' && ! $this->_allow_meta_override )
2629
        {
2630
            unset($record->created_by_id);
2631
        }
2632
2633
        return $record;
2634
    }
2635
2636
2637
    /**
2638
     * @param array $ids
2639
     * @param array $values
2640
     * @param int   $batch
2641
     * @return bool
2642
     */
2643
    public function setById(array $ids, array $values, int $batch=1000) : bool
2644
    {
2645
        $ids        = array_unique($ids);
2646
        if ( count($ids) <= $batch )
2647
        {
2648
            return (bool)$this->whereIn('id', $ids)->updateArr($values);
2649
        }
2650
        while ( ! empty($ids) )
2651
        {
2652
            $thisBatch  = array_slice($ids, 0, $batch);
2653
            $ids        = array_diff($ids, $thisBatch);
2654
            $this->reset()->whereIn('id', $thisBatch)->updateArr($values);
2655
        }
2656
2657
        return true;
2658
    }
2659
2660
2661
    /**
2662
     * @param string $displayColumnValue
2663
     * @return int
2664
     */
2665
    public function resolveId(string $displayColumnValue) : int
2666
    {
2667
        $displayColumn  = $this->getDisplayColumn();
2668
        $className      = get_class($this);
2669
        Assert($displayColumn)->notEmpty("Could not determine the display column for model ({$className})");
2670
2671
        return $this
2672
            ->reset()
2673
            ->where($displayColumn, $displayColumnValue)
2674
            ->fetchInt('id', 0, self::ONE_HOUR);
2675
    }
2676
2677
    /**
2678
     * @param int   $resourceId
2679
     * @param array $query
2680
     * @param array $extraFields
2681
     * @param int $cacheTtl
2682
     * @return array
2683
     */
2684
    public function fetchApiResource(int $resourceId, array $query=[], array $extraFields=[], int $cacheTtl=self::CACHE_NO) : array
2685
    {
2686
        Assert($resourceId)->id();
2687
2688
        $query['_limit']    = 1;
2689
        $pagingMetaData        = $this->wherePk($resourceId)->_prepareApiResource($query, $extraFields);
2690
        if ( $pagingMetaData['total'] === 0 )
2691
        {
2692
            return [[], $pagingMetaData];
2693
        }
2694
2695
        return [$this->fetchOne($resourceId, $cacheTtl), $pagingMetaData];
2696
    }
2697
2698
    /**
2699
     * @param array     $query
2700
     * @param array     $extraFields
2701
     * @param int       $cacheTtl
2702
     * @param string    $permEntity
2703
     * @return array
2704
     */
2705
    public function fetchApiResources(array $query=[], array $extraFields=[], int $cacheTtl=self::CACHE_NO, string $permEntity='') : array
2706
    {
2707
        $pagingMetaData    = $this->_prepareApiResource($query, $extraFields);
2708
        if ( $pagingMetaData['total'] === 0 )
2709
        {
2710
            return [[], $pagingMetaData];
2711
        }
2712
        $results = $this->fetch('', $cacheTtl);
2713
        if ( ! $permEntity )
2714
        {
2715
            return [$results, $pagingMetaData];
2716
        }
2717
        foreach ( $results as $record )
2718
        {
2719
            if ( ! empty($record->id) )
2720
            {
2721
                $pagingMetaData['perms'][(int)$record->id] = $this->getMaskByResourceAndId($permEntity, $record->id);
2722
            }
2723
        }
2724
2725
        return [$results, $pagingMetaData];
2726
    }
2727
2728
2729
    /**
2730
     * @return array
2731
     */
2732
    public function getSearchableAssociations() : array
2733
    {
2734
        $belongsTo = ! empty($this->_associations['belongsTo']) ? $this->_associations['belongsTo'] : [];
2735
        unset($belongsTo['CreatedBy'], $belongsTo['ModifiedBy']);
2736
2737
        return $belongsTo;
2738
    }
2739
2740
    /**
2741
     * @param array $fields
2742
     */
2743
    public function removeUnrequestedFields(array $fields)
2744
    {
2745
        foreach ( $this->_select_fields as $idx => $field )
2746
        {
2747
            $field = trim(static::after(' AS ', $field, true));
2748
            if ( ! in_array($field, $fields) )
2749
            {
2750
                unset($this->_select_fields[$idx]);
2751
            }
2752
        }
2753
    }
2754
2755
    /**
2756
     * @param array $removeFields
2757
     */
2758
    public function removeFields(array $removeFields=[])
2759
    {
2760
        $searches = [];
2761
        foreach ( $removeFields as $removeField )
2762
        {
2763
            $removeField    = str_replace("{$this->_table_alias}.", '', $removeField);
2764
            $searches[]     = "{$this->_table_alias}.{$removeField}";
2765
            $searches[]     = $removeField;
2766
        }
2767
        foreach ( $this->_select_fields as $idx => $selected )
2768
        {
2769
            $selected = stripos($selected, ' AS ') !== false ? preg_split('/ as /i', $selected) : [$selected];
2770
            foreach ( $selected as $haystack )
2771
            {
2772
                foreach ( $searches as $search )
2773
                {
2774
                    if ( trim($haystack) === trim($search) )
2775
                    {
2776
                        unset($this->_select_fields[$idx]);
2777
                        continue;
2778
                    }
2779
                }
2780
            }
2781
        }
2782
    }
2783
2784
    /**
2785
     * @return FluentPdoModel|$this
2786
     */
2787
    public function defaultFilters() : FluentPdoModel
2788
    {
2789
        return $this;
2790
    }
2791
2792
    /**
2793
     * @param bool $allow
2794
     *
2795
     * @return FluentPdoModel|$this
2796
     */
2797
    public function allowMetaColumnOverride(bool $allow=false) : FluentPdoModel
2798
    {
2799
        $this->_allow_meta_override = $allow;
2800
2801
        return $this;
2802
    }
2803
2804
    /**
2805
     * @param stdClass $record
2806
     * @return stdClass
2807
     */
2808
    public function onFetch(stdClass $record) : stdClass
2809
    {
2810
        $record     = $this->_trimAndLowerCaseKeys($record);
2811
        if ( $this->_filter_on_fetch )
2812
        {
2813
            $record     = $this->cleanseRecord($record);
2814
        }
2815
2816
        $record     =  $this->fixTypesToSentinel($record);
2817
2818
        return $this->fixTimestamps($record);
2819
    }
2820
2821
    /**
2822
     * @param $value
2823
     * @return string
2824
     */
2825
    public function gzEncodeData(string $value) : string
2826
    {
2827
        if ( $this->_hasGzipPrefix($value) )
2828
        {
2829
            return $value;
2830
        }
2831
2832
        return static::GZIP_PREFIX . base64_encode(gzencode($value, 9));
2833
    }
2834
2835
    /**
2836
     * @param $value
2837
     * @return mixed|string
2838
     */
2839
    public function gzDecodeData(string $value) : string
2840
    {
2841
        if ( ! $this->_hasGzipPrefix($value) )
2842
        {
2843
            return $value;
2844
        }
2845
        $value = substr_replace($value, '', 0, strlen(static::GZIP_PREFIX));
2846
2847
        return gzdecode(base64_decode($value));
2848
    }
2849
2850
    /**
2851
     * @param $value
2852
     * @return bool
2853
     */
2854
    protected function _hasGzipPrefix(string $value) : bool
2855
    {
2856
        return substr($value, 0, strlen(static::GZIP_PREFIX)) === static::GZIP_PREFIX ? true : false;
2857
    }
2858
2859
    /**
2860
     * @param stdClass $record
2861
     * @return stdClass
2862
     */
2863
    public function fixTimestamps(stdClass $record) : stdClass
2864
    {
2865
        foreach ( $record as $field => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2866
        {
2867
            if ( preg_match('/_ts$/', $field) )
2868
            {
2869
                $record->{$field} = is_null($value) ? null : static::atom($value);
2870
            }
2871
        }
2872
2873
        return $record;
2874
    }
2875
2876
    /**
2877
     * @param int $max
2878
     * @return FluentPdoModel|$this
2879
     */
2880
    public function setMaxRecords(int $max) : FluentPdoModel
2881
    {
2882
        Assert($max)->int();
2883
        $this->_default_max = $max;
2884
2885
        return $this;
2886
    }
2887
2888
2889
    /**
2890
     * @param stdClass $record
2891
     * @param string   $type
2892
     * @return stdClass
2893
     */
2894
    public function afterSave(stdClass $record, string $type) : stdClass
2895
    {
2896
        unset($type);
2897
        $this->clearCacheByTable();
2898
        foreach ( $record as $column => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
2899
        {
2900
            if ( !empty($record->{$column}) )
2901
            {
2902
                if ( preg_match('/_ts$/', $column) )
2903
                {
2904
                    $record->{$column} = static::atom($value);
2905
                }
2906
                if ( preg_match('/_am$/', $column) )
2907
                {
2908
                    $record->{$column} = number_format($value, 2, '.', '');
2909
                }
2910
            }
2911
        }
2912
2913
        return $record;
2914
    }
2915
2916
2917
    /**
2918
     * @param stdClass $record
2919
     * @param string $type
2920
     * @return stdClass
2921
     */
2922
    public function addDefaultFields(stdClass $record, string $type) : stdClass
2923
    {
2924
        $columns            = $this->getColumns(true);
2925
        if ( empty($columns) )
2926
        {
2927
            return $record;
2928
        }
2929
        $defaults           = [
2930
            self::SAVE_UPDATE   => [
2931
                'modified_by_id'    => null,
2932
                'modified_ts'       => null,
2933
            ],
2934
            self::SAVE_INSERT   => [
2935
                'created_by_id'     => null,
2936
                'created_ts'        => null,
2937
                'modified_by_id'    => null,
2938
                'modified_ts'       => null,
2939
                'status'            => null,
2940
            ]
2941
        ];
2942
        $columns            = array_flip($this->getColumns());
2943
        $defaults           = array_intersect_key($defaults[$type], $columns);
2944
        foreach ( $defaults as $column => $def )
2945
        {
2946
            $record->{$column} = $record->{$column} ?? $def;
2947
        }
2948
2949
        return $record;
2950
    }
2951
2952
2953
    /**
2954
     * @return bool
2955
     */
2956
    public function createTable() : bool
2957
    {
2958
        return true;
2959
    }
2960
2961
    /**
2962
     * @return bool
2963
     */
2964
    public function dropTable() : bool
2965
    {
2966
        return true;
2967
    }
2968
2969
    protected function _compileHandlers()
2970
    {
2971
        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...
2972
        {
2973
            return;
2974
        }
2975
        $parentHandlers      = self::_getFieldHandlers();
2976
        $this->_handlers    = array_merge($parentHandlers, $this->_getFieldHandlers());
2977
    }
2978
2979
    /**
2980
     * @param string $viewName
2981
     * @param int $cacheTtl
2982
     * @return array
2983
     */
2984
    public function getViewColumns($viewName, $cacheTtl=self::CACHE_NO)
2985
    {
2986
        return $this->_getColumnsByTableFromDb($viewName, $cacheTtl);
2987
    }
2988
2989
    /**
2990
     * @param int $id
2991
     * @return string
2992
     */
2993
    public function getDisplayNameById(int $id) : string
2994
    {
2995
        $displayColumn  = $this->getDisplayColumn();
2996
        $className      = get_class($this);
2997
        Assert($displayColumn)->notEmpty("Could not determine the display column for model ({$className})");
2998
2999
        return $this
3000
            ->reset()
3001
            ->fetchStr($displayColumn, $id, self::ONE_HOUR);
3002
    }
3003
3004
    /**
3005
     * @param int $id
3006
     * @param string $displayColumnValue
3007
     * @return bool
3008
     */
3009
    public function validIdDisplayNameCombo(int $id, $displayColumnValue) : bool
3010
    {
3011
        return $displayColumnValue === $this->getDisplayNameById($id);
3012
    }
3013
3014
    /**
3015
     * @param array $toPopulate
3016
     * @return stdClass
3017
     */
3018
    protected function getEmptyObject(array $toPopulate=[]) : stdClass
3019
    {
3020
        $toPopulate[]   = 'id';
3021
3022
        return (object)array_flip($toPopulate);
3023
    }
3024
3025
    /**
3026
     * @param int $id
3027
     * @return bool
3028
     */
3029
    public static function isId(int $id) : bool
3030
    {
3031
        return $id > 0;
3032
    }
3033
3034
    /**
3035
     * @param int $cacheTtl
3036
     * @return int
3037
     */
3038
    public function activeCount(int $cacheTtl=self::CACHE_NO) : int
3039
    {
3040
        return (int)$this->whereActive()->count('*', $cacheTtl);
3041
    }
3042
3043
    /**
3044
     * @param string        $tableAlias
3045
     * @param string   $columnName
3046
     * @return FluentPdoModel|$this
3047
     */
3048
    public function whereActive(string $tableAlias='', string $columnName='status') : FluentPdoModel
3049
    {
3050
        return $this->whereStatus(static::ACTIVE, $tableAlias, $columnName);
3051
    }
3052
3053
    /**
3054
     * @param string        $tableAlias
3055
     * @param string        $columnName
3056
     * @return FluentPdoModel|$this
3057
     */
3058
    public function whereInactive(string $tableAlias='', string $columnName='status') : FluentPdoModel
3059
    {
3060
        return $this->whereStatus(static::INACTIVE, $tableAlias, $columnName);
3061
    }
3062
3063
    /**
3064
     * @param string        $tableAlias
3065
     * @param string        $columnName
3066
     * @return FluentPdoModel|$this
3067
     */
3068
    public function whereArchived(string $tableAlias='', string $columnName='status') : FluentPdoModel
3069
    {
3070
        return $this->whereStatus(static::ARCHIVED, $tableAlias, $columnName);
3071
    }
3072
3073
    /**
3074
     * @param int $status
3075
     * @param string $tableAlias
3076
     * @param string $columnName
3077
     * @return FluentPdoModel|$this
3078
     */
3079
    public function whereStatus(int $status, string $tableAlias='', string $columnName='status') : FluentPdoModel
3080
    {
3081
        Assert($status)->inArray([static::ACTIVE, static::INACTIVE, static::ARCHIVED]);
3082
3083
        $tableAlias = empty($tableAlias) ? $this->getTableAlias() : $tableAlias;
3084
        $field      = empty($tableAlias) ? $columnName : "{$tableAlias}.{$columnName}";
3085
3086
        return $this->where($field, $status);
3087
    }
3088
3089
    /**
3090
     * @param int $id
3091
     * @return int
3092
     */
3093
    public function updateActive(int $id=0) : int
3094
    {
3095
        Assert($id)->unsignedInt();
3096
        if ( $id )
3097
        {
3098
            $this->wherePk($id);
3099
        }
3100
3101
        return $this->updateStatus(static::ACTIVE);
3102
    }
3103
3104
    /**
3105
     * @param int $id
3106
     * @return int
3107
     */
3108
    public function updateInactive(int $id=0) : int
3109
    {
3110
        Assert($id)->unsignedInt();
3111
        if ( $id )
3112
        {
3113
            $this->wherePk($id);
3114
        }
3115
        return $this->updateStatus(static::INACTIVE);
3116
    }
3117
3118
    /**
3119
     * @param string $field
3120
     * @param int  $id
3121
     * @return int
3122
     */
3123
    public function updateNow(string $field, int $id=0) : int
3124
    {
3125
        Assert($field)->notEmpty();
3126
3127
        return $this->updateField($field, date('Y-m-d H:i:s'), $id);
3128
    }
3129
    /**
3130
     * @param string $field
3131
     * @param int  $id
3132
     * @return int
3133
     */
3134
    public function updateToday($field, int $id=0) : int
3135
    {
3136
        Assert($field)->notEmpty();
3137
3138
        return $this->updateField($field, date('Y-m-d'), $id);
3139
    }
3140
3141
    /**
3142
     * @param int $id
3143
     * @return int
3144
     */
3145
    public function updateArchived(int $id=0) : int
3146
    {
3147
        Assert($id)->unsignedInt();
3148
        if ( $id )
3149
        {
3150
            $this->wherePk($id);
3151
        }
3152
3153
        return $this->updateStatus(static::ARCHIVED);
3154
    }
3155
3156
    /**
3157
     * @param int $status
3158
     * @return int
3159
     * @throws \Exception
3160
     */
3161
    public function updateStatus(int $status)
3162
    {
3163
        Assert($status)->inArray([static::ACTIVE, static::INACTIVE, static::ARCHIVED]);
3164
3165
        return $this->updateField('status', $status);
3166
    }
3167
3168
    /**
3169
     * Return a YYYY-MM-DD HH:II:SS date format
3170
     *
3171
     * @param string $datetime - An english textual datetime description
3172
     *          now, yesterday, 3 days ago, +1 week
3173
     *          http://php.net/manual/en/function.strtotime.php
3174
     * @return string YYYY-MM-DD HH:II:SS
3175
     */
3176
    public static function NOW(string $datetime='now') : string
3177
    {
3178
        return (new DateTime($datetime ?: 'now'))->format('Y-m-d H:i:s');
3179
    }
3180
3181
    /**
3182
     * Return a string containing the given number of question marks,
3183
     * separated by commas. Eg '?, ?, ?'
3184
     *
3185
     * @param int - total of placeholder to insert
3186
     * @return string
3187
     */
3188
    protected function _makePlaceholders(int $numberOfPlaceholders=1) : string
3189
    {
3190
        return implode(', ', array_fill(0, $numberOfPlaceholders, '?'));
3191
    }
3192
3193
    /**
3194
     * Format the table{Primary|Foreign}KeyName
3195
     *
3196
     * @param  string $pattern
3197
     * @param  string $tableName
3198
     * @return string
3199
     */
3200
    protected function _formatKeyName(string $pattern, string $tableName) : string
3201
    {
3202
        return sprintf($pattern, $tableName);
3203
    }
3204
3205
3206
    /**
3207
     * @param array $query
3208
     * @param array $extraFields
3209
     * @return array
3210
     * @throws \Exception
3211
     */
3212
    protected function _prepareApiResource(array $query=[], array $extraFields=[]) : array
3213
    {
3214
        $this->defaultFilters()->filter($query)->paginate($query);
3215
        $pagingMetaData    = $this->getPagingMeta();
3216
        if ( $pagingMetaData['total'] === 0 )
3217
        {
3218
            return $pagingMetaData;
3219
        }
3220
        $this->withBelongsTo($pagingMetaData['fields']);
3221
        if ( ! empty($extraFields) )
3222
        {
3223
            $this->select($extraFields, '', false);
3224
        }
3225
        $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...
3226
        if ( ! empty($pagingMetaData['fields']) )
3227
        {
3228
            $this->removeUnrequestedFields($pagingMetaData['fields']);
3229
        }
3230
3231
        return $pagingMetaData;
3232
    }
3233
3234
    /**
3235
     * @param string $query
3236
     * @param array $parameters
3237
     *
3238
     * @return array
3239
     */
3240
    protected function _logQuery(string $query, array $parameters) : array
3241
    {
3242
        $query                  = $this->buildQuery($query, $parameters);
3243
        if ( ! $this->_log_queries )
3244
        {
3245
            return ['', ''];
3246
        }
3247
        $ident                  = substr(str_shuffle(md5($query)), 0, 10);
3248
        $this->getLogger()->debug($ident . ': ' . PHP_EOL . $query);
3249
        $this->_timer['start']  = microtime(true);
3250
3251
        return [$query, $ident];
3252
    }
3253
3254
    /**
3255
     * @param string $ident
3256
     * @param string $builtQuery
3257
     */
3258
    protected function _logSlowQueries(string $ident, string $builtQuery)
3259
    {
3260
        if ( ! $this->_log_queries )
3261
        {
3262
            return ;
3263
        }
3264
        $this->_timer['end']    = microtime(true);
3265
        $seconds_taken          = round($this->_timer['end'] - $this->_timer['start'], 3);
3266
        if ( $seconds_taken > $this->_slow_query_secs )
3267
        {
3268
            $this->getLogger()->warning("SLOW QUERY - {$ident} - {$seconds_taken} seconds:\n{$builtQuery}");
3269
        }
3270
    }
3271
3272
    /**
3273
     * @return float
3274
     */
3275
    public function getTimeTaken() : float
3276
    {
3277
        $secondsTaken = $this->_timer['end'] - $this->_timer['start'];
3278
3279
        return (float)$secondsTaken;
3280
    }
3281
3282
    /**
3283
     * @param $secs
3284
     * @return FluentPdoModel|$this
3285
     */
3286
    public function slowQuerySeconds(int $secs) : FluentPdoModel
3287
    {
3288
        Assert($secs)->notEmpty("Seconds cannot be empty.")->numeric("Seconds must be numeric.");
3289
        $this->_slow_query_secs = $secs;
3290
3291
        return $this;
3292
    }
3293
3294
3295
    /**
3296
     * @param       $field
3297
     * @param array $values
3298
     * @param string  $placeholderPrefix
3299
     *
3300
     * @return array
3301
     */
3302
    public function getNamedWhereIn(string $field, array $values, string $placeholderPrefix='') : array
3303
    {
3304
        Assert($field)->string()->notEmpty();
3305
        Assert($values)->isArray();
3306
3307
        if ( empty($values) )
3308
        {
3309
            return ['', []];
3310
        }
3311
        $placeholderPrefix      = $placeholderPrefix ?: strtolower(str_replace('.', '__', $field));
3312
        $params                 = [];
3313
        $placeholders           = [];
3314
        $count                  = 1;
3315
        foreach ( $values as $val )
3316
        {
3317
            $name                   = "{$placeholderPrefix}_{$count}";
3318
            $params[$name]          = $val;
3319
            $placeholders[]         = ":{$name}";
3320
            $count++;
3321
        }
3322
        $placeholders           = implode(',', $placeholders);
3323
3324
        return ["AND {$field} IN ({$placeholders})\n", $params];
3325
    }
3326
3327
    /**
3328
     * @param string $field
3329
     * @param string $delimiter
3330
     *
3331
     * @return array
3332
     */
3333
    protected function _getColumnAliasParts(string $field, string $delimiter=':') : array
3334
    {
3335
        $parts      = explode($delimiter, $field);
3336
        if ( count($parts) === 2 )
3337
        {
3338
            return $parts;
3339
        }
3340
3341
        return ['', $field];
3342
    }
3343
3344
    /**
3345
     * @param string $column
3346
     * @param string $term
3347
     * @return FluentPdoModel|$this
3348
     */
3349
    protected function _addWhereClause(string $column, string $term) : FluentPdoModel
3350
    {
3351
        $modifiers = [
3352
            'whereLike'         => '/^whereLike\(([%]?[ a-z0-9:-]+[%]?)\)$/i',
3353
            'whereNotLike'      => '/^whereNotLike\(([%]?[ a-z0-9:-]+[%]?)\)$/i',
3354
            'whereLt'           => '/^whereLt\(([ a-z0-9:-]+)\)$/i',
3355
            'whereLte'          => '/^whereLte\(([ a-z0-9:-]+)\)$/i',
3356
            'whereGt'           => '/^whereGt\(([ a-z0-9:-]+)\)$/i',
3357
            'whereGte'          => '/^whereGte\(([ a-z0-9:-]+)\)$/i',
3358
            'whereBetween'      => '/^whereBetween\(([ a-z0-9:-]+),([ a-z0-9:-]+)\)$/i',
3359
            'whereNotBetween'  => '/^whereNotBetween\(([ a-z0-9:-]+),([ a-z0-9:-]+)\)$/i',
3360
        ];
3361
        foreach ( $modifiers as $func => $regex )
3362
        {
3363
            if ( preg_match($regex, $term, $matches) )
3364
            {
3365
                array_shift($matches);
3366
                switch ($func)
3367
                {
3368
                    case 'whereLike':
3369
3370
                        return $this->whereLike($column, $matches[0]);
3371
3372
                    case 'whereNotLike':
3373
3374
                        return $this->whereNotLike($column, $matches[0]);
3375
3376
                    case 'whereLt':
3377
3378
                        return $this->whereLt($column, $matches[0]);
3379
3380
                    case 'whereLte':
3381
3382
                        return $this->whereLte($column, $matches[0]);
3383
3384
                    case 'whereGt':
3385
3386
                        return $this->whereGt($column, $matches[0]);
3387
3388
                    case 'whereGte':
3389
3390
                        return $this->whereGte($column, $matches[0]);
3391
3392
                    case 'whereBetween':
3393
3394
                        return $this->whereBetween($column, $matches[0], $matches[1]);
3395
3396
                    case 'whereNotBetween':
3397
3398
                        return $this->whereNotBetween($column, $matches[0], $matches[1]);
3399
3400
                }
3401
            }
3402
        }
3403
3404
        return $this->where($column, $term);
3405
    }
3406
3407
    public function destroy()
3408
    {
3409
        if ( !is_null($this->_pdo_stmt) )
3410
        {
3411
            $this->_pdo_stmt->closeCursor();
3412
        }
3413
        $this->_pdo_stmt    = null;
3414
        $this->_handlers    = [];
3415
    }
3416
3417
    public function __destruct()
3418
    {
3419
        $this->destroy();
3420
    }
3421
3422
    /**
3423
     * Load a model
3424
     *
3425
     * @param string $modelName
3426
     * @param AbstractPdo $connection
3427
     * @return FluentPdoModel|$this
3428
     * @throws ModelNotFoundException
3429
     */
3430
    public static function loadModel(string $modelName, AbstractPdo $connection=null) : FluentPdoModel
3431
    {
3432
        $modelName = static::$_model_namespace . $modelName;
3433
        if ( ! class_exists($modelName) )
3434
        {
3435
            throw new ModelNotFoundException("Failed to find model class {$modelName}.");
3436
        }
3437
3438
        return new $modelName($connection);
3439
    }
3440
3441
    /**
3442
     * Load a model
3443
     *
3444
     * @param string      $tableName
3445
     * @param AbstractPdo $connection
3446
     * @return FluentPdoModel|$this
3447
     */
3448
    public static function loadTable(string $tableName, AbstractPdo $connection=null) : FluentPdoModel
3449
    {
3450
        $modelName     = Inflector::classify($tableName);
3451
        Assert($modelName)->notEmpty("Could not resolve model name from table name.");
3452
3453
        return static::loadModel($modelName, $connection);
3454
    }
3455
3456
    /**
3457
     * @param string   $columnName
3458
     * @param int $cacheTtl
3459
     * @param bool $flushCache
3460
     * @return bool
3461
     */
3462
    public function columnExists(string $columnName, int $cacheTtl=self::CACHE_NO, bool $flushCache=false)
3463
    {
3464
        $columns = $this->getSchemaFromDb($cacheTtl, $flushCache);
3465
        return array_key_exists($columnName, $columns);
3466
    }
3467
3468
    /**
3469
     * @param int $cacheTtl
3470
     * @param bool $flushCache
3471
     * @return FluentPdoModel|$this
3472
     */
3473
    public function loadSchemaFromDb(int $cacheTtl=self::CACHE_NO, bool $flushCache=false) : FluentPdoModel
3474
    {
3475
        $schema = $this->getSchemaFromDb($cacheTtl, $flushCache);
3476
        $this->schema($schema);
3477
3478
        return $this;
3479
    }
3480
3481
    /**
3482
     * @param int $cacheTtl
3483
     * @param bool $flushCache
3484
     * @return array
3485
     */
3486
    public function getSchemaFromDb(int $cacheTtl=self::CACHE_NO, bool $flushCache=false) : array
3487
    {
3488
        $table      = $this->getTableName();
3489
        Assert($table)->string()->notEmpty();
3490
        $schema     = [];
3491
        $columns    = $this->_getColumnsByTableFromDb($table, $cacheTtl, $flushCache);
3492
        foreach ( $columns[$table] as $column => $meta )
3493
        {
3494
            $schema[$column] = $meta->data_type;
3495
        }
3496
        return $schema;
3497
    }
3498
3499
    /**
3500
     * @param string $table
3501
     * @param int $cacheTtl
3502
     * @param bool $flushCache
3503
     * @return array
3504
     */
3505
    protected function _getColumnsByTableFromDb(string $table, int $cacheTtl=self::CACHE_NO, bool $flushCache=false) : array
3506
    {
3507
        Assert($table)->string()->notEmpty();
3508
3509
        $callback = function() use ($table) {
3510
3511
            return $this->_connection->getColumns(true, $table);
3512
        };
3513
        $cacheKey   = '/column_schema/' . $table;
3514
        if ( $flushCache === true )
3515
        {
3516
            $this->clearCache($cacheKey);
3517
        }
3518
3519
        return (array)$this->_cacheData($cacheKey, $callback, $cacheTtl);
3520
    }
3521
3522
    /**
3523
     * @param string $table
3524
     * @return bool
3525
     */
3526
    public function clearSchemaCache(string $table) : bool
3527
    {
3528
        return $this->clearCache('/column_schema/' . $table);
3529
    }
3530
3531
    /**
3532
     * @param stdClass $record
3533
     * @return stdClass
3534
     */
3535
    public function cleanseRecord(stdClass $record) : stdClass
3536
    {
3537
        foreach ( $record as $field => $value )
0 ignored issues
show
Bug introduced by
The expression $record of type object<stdClass> is not traversable.
Loading history...
3538
        {
3539
            if ( is_string($record->{$field}) )
3540
            {
3541
                $record->$field = str_replace(["\r\n", "\\r\\n", "\\n"], "\n", filter_var($record->$field, FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES));
3542
                if ( $this->_log_filter_changes && $value !== $record->$field )
3543
                {
3544
                    $table = $this->_table_name ? $this->_table_name : '';
3545
                    $this->getLogger()->debug("Field {$table}.{$field} has been cleansed", ['old' => $value, 'new' => $record->$field]);
3546
                }
3547
            }
3548
        }
3549
3550
        return $record;
3551
    }
3552
3553
    /**
3554
     * @param stdClass $record
3555
     * @param string   $type
3556
     * @return stdClass
3557
     */
3558
    public function beforeSave(stdClass $record, string $type) : stdClass
3559
    {
3560
        $record = $this->addDefaultFields($record, $type);
3561
        $record = $this->applyGlobalModifiers($record, $type);
3562
        $record = $this->applyHandlers($record, $type);
3563
        $record = $this->removeUnneededFields($record, $type);
3564
3565
        return $record;
3566
    }
3567
3568
    /**
3569
     * @param array $data
3570
     * @param string $saveType
3571
     * @return array
3572
     */
3573
    public function cleanseWebData(array $data, string $saveType) : array
3574
    {
3575
        Assert($saveType)->inArray([self::SAVE_UPDATE, self::SAVE_INSERT]);
3576
        $columns = $this->getColumns(false);
3577
        if ( empty($columns) )
3578
        {
3579
            return $data;
3580
        }
3581
        foreach ( $data as $field => $val )
3582
        {
3583
            $data[$field] = empty($val) && $val !== 0 ? null : $val;
3584
        }
3585
3586
        return array_intersect_key($data, $columns);
3587
    }
3588
3589
    /**
3590
     * @return array
3591
     */
3592
    public function skeleton() : array
3593
    {
3594
        $skel       = [];
3595
        $columns    = $this->columns(false);
3596
        foreach ( $columns as $column => $type )
3597
        {
3598
            $skel[$column] = null;
3599
        }
3600
3601
        return $skel;
3602
    }
3603
3604
    /**
3605
     * @param bool $toString
3606
     * @return array
3607
     */
3608
    public function getErrors(bool $toString=false) : array
3609
    {
3610
        if ( $toString )
3611
        {
3612
            $errors = [];
3613
            foreach ( $this->_errors as $field => $error )
3614
            {
3615
                $errors[] = implode("\n", $error);
3616
            }
3617
3618
            return implode("\n", $errors);
3619
        }
3620
3621
        return $this->_errors;
3622
    }
3623
3624
    /**
3625
     * @param bool $throw
3626
     * @return FluentPdoModel|$this
3627
     */
3628
    public function validationExceptions(bool $throw=true) : FluentPdoModel
3629
    {
3630
        $this->_validation_exceptions = $throw;
3631
3632
        return $this;
3633
    }
3634
3635
    /**
3636
     * @param array $query array('_limit' => int, '_offset' => int, '_order' => string, '_fields' => string, _search)
3637
     *
3638
     * @return FluentPdoModel|$this
3639
     * @throws Exception
3640
     */
3641
    public function paginate(array $query=[]) : FluentPdoModel
3642
    {
3643
        $_fields = $_order = $_limit = $_offset = null;
3644
        extract($query);
3645
        $this->_setLimit((int)$_limit, (int)$_offset);
3646
        $this->_setOrderBy((string)$_order);
3647
        $_fields    = is_array($_fields) ? $_fields : (string)$_fields;
3648
        $_fields    = empty($_fields) ? [] : $_fields;
3649
        $_fields    = is_string($_fields) ? explode('|', $_fields) : $_fields;
3650
        $_fields    = empty($_fields) ? [] : $_fields;
3651
        $this->_setFields(is_array($_fields) ? $_fields : explode('|', (string)$_fields));
3652
3653
        return $this;
3654
    }
3655
3656
    /**
3657
     * @param int $limit
3658
     * @param int $offset
3659
     * @return FluentPdoModel|$this
3660
     */
3661
    protected function _setLimit(int $limit=0, int $offset=0) : FluentPdoModel
3662
    {
3663
        $limit      = ! $limit || (int)$limit > (int)$this->_default_max ? (int)$this->_default_max : (int)$limit;
3664
        if ( ! is_numeric($limit) )
3665
        {
3666
            return $this;
3667
        }
3668
        $this->limit((int)$limit);
3669
        if ( $offset && is_numeric($offset) )
3670
        {
3671
            $this->offset((int)$offset);
3672
        }
3673
3674
        return $this;
3675
    }
3676
3677
    /**
3678
     * @param array $fields
3679
     * @return FluentPdoModel|$this
3680
     * @throws Exception
3681
     */
3682
    protected function _setFields(array $fields=[]) : FluentPdoModel
3683
    {
3684
        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...
3685
        {
3686
            return $this;
3687
        }
3688
        $this->explicitSelectMode();
3689
        $columns    = $this->getColumns();
3690
3691
        foreach ( $fields as $idx => $field )
3692
        {
3693
            list($alias, $field) = $this->_getColumnAliasParts($field);
3694
            $field = $field === '_display_field' ? $this->_display_column : $field;
3695
            // Regular primary table field
3696
            if ( ( empty($alias) || $alias === $this->_table_alias ) && in_array($field, $columns) )
3697
            {
3698
                $this->select("{$this->_table_alias}.{$field}");
3699
                $this->_requested_fields[] = "{$this->_table_alias}.{$field}";
3700
                continue;
3701
            }
3702
            // Reference table field with alias
3703
            if ( ! empty($alias) )
3704
            {
3705
                Assert($this->_associations['belongsTo'])->keyExists($alias, "Invalid table alias ({$alias}) specified for the field query");
3706
                Assert($field)->eq($this->_associations['belongsTo'][$alias][3], "Invalid field ({$alias}.{$field}) specified for the field query");
3707
                list(, , $join_field, $fieldAlias) = $this->_associations['belongsTo'][$alias];
3708
                $this->autoJoin($alias, static::LEFT_JOIN, false);
3709
                $this->select($join_field, $fieldAlias);
3710
                $this->_requested_fields[] = $fieldAlias;
3711
                continue;
3712
            }
3713
            // Reference table select field without alias
3714
            $belongsTo = array_key_exists('belongsTo', $this->_associations) ?  $this->_associations['belongsTo'] : [];
3715
            foreach ( $belongsTo as $joinAlias => $config )
3716
            {
3717
                list(, , $join_field, $fieldAlias) = $config;
3718
                if ( $field === $fieldAlias )
3719
                {
3720
                    $this->autoJoin($joinAlias, static::LEFT_JOIN, false);
3721
                    $this->select($join_field, $fieldAlias);
3722
                    $this->_requested_fields[] = $fieldAlias;
3723
                    continue;
3724
                }
3725
            }
3726
        }
3727
3728
        return $this;
3729
    }
3730
3731
    /**
3732
     * @param string $orderBy
3733
     * @return FluentPdoModel|$this|FluentPdoModel
3734
     */
3735
    protected function _setOrderBy(string $orderBy='') : FluentPdoModel
3736
    {
3737
        if ( ! $orderBy )
3738
        {
3739
            return $this;
3740
        }
3741
        $columns                    = $this->getColumns();
3742
        list($order, $direction)    = strpos($orderBy, ',') !== false ? explode(',', $orderBy) : [$orderBy, 'ASC'];
3743
        list($alias, $field)        = $this->_getColumnAliasParts(trim($order), '.');
3744
        $field                      = explode(' ', $field);
3745
        $field                      = trim($field[0]);
3746
        $direction                  = ! in_array(strtoupper(trim($direction)), ['ASC', 'DESC']) ? 'ASC' : strtoupper(trim($direction));
3747
        $belongsTo                  = array_key_exists('belongsTo', $this->_associations) ? $this->_associations['belongsTo'] : [];
3748
        // Regular primary table order by
3749
        if ( ( empty($alias) || $alias === $this->_table_alias ) && in_array($field, $columns) )
3750
        {
3751
            return $this->orderBy("{$this->_table_alias}.{$field}", $direction);
3752
        }
3753
        // Reference table order by with alias
3754
        if ( ! empty($alias) )
3755
        {
3756
            Assert($belongsTo)->keyExists($alias, "Invalid table alias ({$alias}) specified for the order query");
3757
            Assert($field)->eq($belongsTo[$alias][3], "Invalid field ({$alias}.{$field}) specified for the order query");
3758
3759
            return $this->autoJoin($alias)->orderBy("{$alias}.{$field}", $direction);
3760
        }
3761
        // Reference table order by without alias
3762
        foreach ( $belongsTo as $joinAlias => $config )
3763
        {
3764
            if ( $field === $config[3] )
3765
            {
3766
                return $this->autoJoin($joinAlias)->orderBy($config[2], $direction);
3767
            }
3768
        }
3769
3770
        return $this;
3771
    }
3772
3773
    /**
3774
     * @return array
3775
     */
3776
    public function getPagingMeta()
3777
    {
3778
        if ( empty($this->_paging_meta) )
3779
        {
3780
            $this->setPagingMeta();
3781
        }
3782
3783
        return $this->_paging_meta;
3784
    }
3785
3786
    /**
3787
     * @return FluentPdoModel|$this
3788
     */
3789
    public function setPagingMeta() : FluentPdoModel
3790
    {
3791
        $model                  = clone $this;
3792
        $limit                  = intval($this->getLimit());
3793
        $offset                 = intval($this->getOffset());
3794
        $total                  = intval($model->withBelongsTo()->select('')->offset(0)->limit(0)->orderBy()->count());
3795
        unset($model->_handlers, $model); //hhmv mem leak
3796
        $order_bys              = ! is_array($this->_order_by) ? [] : $this->_order_by;
3797
        $this->_paging_meta     = [
3798
            'limit'                 => $limit,
3799
            'offset'                => $offset,
3800
            'page'                  => $offset === 0 ? 1 : intval( $offset / $limit ) + 1,
3801
            'pages'                 => $limit === 0 ? 1 : intval(ceil($total / $limit)),
3802
            'order'                 => $order_bys,
3803
            'total'                 => $total = $limit === 1 && $total > 1 ? 1 : $total,
3804
            'filters'               => $this->_filter_meta,
3805
            'fields'                => $this->_requested_fields,
3806
            'perms'                 => [],
3807
        ];
3808
3809
        return $this;
3810
    }
3811
3812
    /**
3813
     * Take a web request and format a query
3814
     *
3815
     * @param array $query
3816
     *
3817
     * @return FluentPdoModel|$this
3818
     * @throws Exception
3819
     */
3820
    public function filter(array $query=[]) : FluentPdoModel
3821
    {
3822
        $columns   = $this->getColumns(false);
3823
        $alias     = '';
3824
        foreach ( $query as $column => $value )
3825
        {
3826
            if ( in_array($column, $this->_pagination_attribs) )
3827
            {
3828
                continue;
3829
            }
3830
            $field = $this->_findFieldByQuery($column, $this->_display_column);
3831
            if ( is_null($field) )
3832
            {
3833
                continue;
3834
            }
3835
            $this->_filter_meta[$field]     = $value;
3836
            $where                          = !is_array($value) && mb_stripos($value, '|') !== false ? explode('|', $value) : $value;
3837
            if ( is_array($where) )
3838
            {
3839
                $this->whereIn($field, $where);
3840
            }
3841
            else
3842
            {
3843
                $this->_addWhereClause($field, (string)$where);
3844
            }
3845
        }
3846
        if ( empty($query['_search']) )
3847
        {
3848
            return $this;
3849
        }
3850
        $alias          = !empty($alias) ? $alias : $this->_table_alias;
3851
        $string_cols    = array_filter($columns, function($type) {
3852
3853
            return in_array($type, ['varchar', 'text', 'enum']);
3854
        });
3855
        $terms          = explode('|', $query['_search']);
3856
        $where_likes    = [];
3857
        foreach ( $string_cols as $column => $type )
3858
        {
3859
            foreach ( $terms as $term )
3860
            {
3861
                $where_likes["{$alias}.{$column}"] = "%{$term}%";
3862
            }
3863
        }
3864
        // Reference fields...
3865
        $belongsTo = $this->getSearchableAssociations();
3866
        foreach ( $belongsTo as $alias => $config )
3867
        {
3868
            foreach ( $terms as $term )
3869
            {
3870
                $where_likes[$config[2]] = "%{$term}%";
3871
            }
3872
        }
3873
        if ( empty($where_likes) )
3874
        {
3875
            return $this;
3876
        }
3877
        $this->where([1=>1])->wrap()->_and();
3878
        foreach ( $where_likes as $column => $term )
3879
        {
3880
            $this->_or()->whereLike($column, $term);
3881
        }
3882
        $this->wrap();
3883
3884
        return $this;
3885
    }
3886
3887
    /**
3888
     * @param string $column
3889
     * @param string $displayCol
3890
     * @return string|null
3891
     */
3892
    protected function _findFieldByQuery(string $column, string $displayCol)
3893
    {
3894
        list($alias, $field)    = $this->_getColumnAliasParts($column);
3895
        $field                  = $field === '_display_field' ? $displayCol : $field;
3896
        $columns                = $this->getColumns();
3897
        $tableAlias             = $this->getTableAlias();
3898
        if ( ! empty($alias) && $alias === $tableAlias )
3899
        {
3900
            // Alias is set but the field isn't correct
3901
            if ( ! in_array($field, $columns) )
3902
            {
3903
                return null;
3904
            }
3905
            return "{$alias}.{$field}";
3906
        }
3907
        // Alias isn't passed in but the field is ok
3908
        if ( empty($alias) && in_array($field, $columns) )
3909
        {
3910
            return "{$tableAlias}.{$field}";
3911
        }
3912
        // Alias is passed but not this table in but there is a matching field on this table
3913
        if ( ! empty($alias) && in_array($field, $columns) )
3914
        {
3915
            return null;
3916
        }
3917
        // Now search the associations for the field
3918
        $associations = $this->getSearchableAssociations();
3919
        if ( ! empty($alias) )
3920
        {
3921
            if ( array_key_exists($alias, $associations) && $associations[$alias][3] === $field )
3922
            {
3923
                return "{$alias}.{$field}";
3924
            }
3925
3926
            return null;
3927
        }
3928
        foreach ( $associations as $assocAlias => $config )
3929
        {
3930
            list(, , $assocField, $fieldAlias) = $config;
3931
            if ( $fieldAlias === $field )
3932
            {
3933
                return $assocField;
3934
            }
3935
        }
3936
3937
        return null;
3938
    }
3939
3940
    /**
3941
     * @param $keysOnly
3942
     * @return array
3943
     */
3944
3945
    public function columns(bool $keysOnly=true) : array
3946
    {
3947
        return $keysOnly ? array_keys($this->_schema) : $this->_schema;
3948
    }
3949
3950
    /**
3951
     * @param string $field
3952
     * @param mixed $value
3953
     * @param array $pdoMetaData
3954
     * @return float|int
3955
     * @throws Exception
3956
     */
3957
    protected function _fixTypeToSentinel(string $field, $value, array $pdoMetaData=[])
3958
    {
3959
        Assert($value)->nullOr()->scalar("var is type of " . gettype($value));
3960
3961
        $fieldType      = strtolower($pdoMetaData['native_type']) ?: null;
3962
        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...
3963
        {
3964
            if ( empty($this->_schema) )
3965
            {
3966
                return $value;
3967
            }
3968
            $columns    = $this->getColumns(false);
3969
            Assert($columns)->keyExists($field, "The property {$field} does not exist.");
3970
3971
            $fieldType = $columns[$field] ?: null;
3972
        }
3973
3974
3975
        // Don't cast invalid values... only those that can be cast cleanly
3976
        switch ( $fieldType )
3977
        {
3978
            case 'varchar':
3979
            case 'var_string':
3980
            case 'string':
3981
            case 'text';
3982
            case 'date':
3983
            case 'datetime':
3984
            case 'timestamp':
3985
            case 'blob':
3986
3987
                return (string)$value;
3988
3989
            case 'int':
3990
            case 'integer':
3991
            case 'tinyint':
3992
            case 'tiny':
3993
            case 'long':
3994
            case 'longlong':
3995
3996
                return (int)$value;
3997
3998
            case 'decimal':
3999
            case 'newdecimal':
4000
4001
                return (float)$value;
4002
4003
            default:
4004
4005
                throw new Exception('Unknown type: ' . $fieldType);
4006
                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...
4007
        }
4008
    }
4009
4010
    /**
4011
     * @param string $field
4012
     * @param mixed $value
4013
     * @param bool|false $permissive
4014
     * @return float|int|null|string
4015
     */
4016
    protected function _fixType(string $field, $value, bool $permissive=false)
4017
    {
4018
        Assert($value)->nullOr()->scalar("var is type of " . gettype($value));
4019
        if ( empty($this->_schema) || ( ! array_key_exists($field, $this->_schema) && $permissive ) )
4020
        {
4021
            return $value;
4022
        }
4023
        $columns    = $this->getColumns(false);
4024
        Assert($columns)->keyExists($field, "The property {$field} does not exist.");
4025
4026
        $fieldType = ! empty($columns[$field]) ? $columns[$field] : null;
4027
4028
        if ( is_null($value) )
4029
        {
4030
            return null;
4031
        }
4032
        // return on null, '' but not 0
4033
        if ( ! is_numeric($value) && empty($value) )
4034
        {
4035
            return null;
4036
        }
4037
        // Don't cast invalid values... only those that can be cast cleanly
4038
        switch ( $fieldType )
4039
        {
4040
            case 'varchar':
4041
            case 'text';
4042
            case 'date':
4043
            case 'datetime':
4044
            case 'timestamp':
4045
4046
                // return on null, '' but not 0
4047
                return ! is_numeric($value) && empty($value) ? null : (string)$value;
4048
4049
            case 'int':
4050
4051
                if ( $field === 'id' || substr($field, -3) === '_id' )
4052
                {
4053
                    return $value ? (int)$value : null;
4054
                }
4055
4056
                return ! is_numeric($value) ? null : (int)$value;
4057
4058
            case 'decimal':
4059
4060
                return ! is_numeric($value) ? null : (float)$value;
4061
4062
            default:
4063
4064
                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...
4065
        }
4066
    }
4067
4068
    /**
4069
     * @param stdClass $record
4070
     * @param string $type
4071
     * @return stdClass
4072
     */
4073
    public function fixTypesToSentinel(stdClass $record, string $type='') : stdClass
4074
    {
4075
        foreach ( $this->row_meta_data as $column => $pdoMetaData )
4076
        {
4077
            if ( ! property_exists($record, $column) )
4078
            {
4079
                continue;
4080
            }
4081
            $record->{$column} = $this->_fixTypeToSentinel($column, $record->{$column}, $pdoMetaData);
4082
        }
4083
        // PDO might not be able to generate the meta data to sniff types
4084
        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...
4085
        {
4086
            foreach ( $this->getColumns(false) as $column => $fieldType )
4087
            {
4088
                if ( ! property_exists($record, $column) )
4089
                {
4090
                    continue;
4091
                }
4092
                $record->{$column} = $this->_fixTypeToSentinel($column, $record->{$column});
4093
            }
4094
        }
4095
4096
        unset($type);
4097
4098
        return $record;
4099
    }
4100
4101
    /**
4102
     * @param stdClass $record
4103
     * @param string   $type
4104
     * @return stdClass
4105
     * @throws Exception
4106
     */
4107
    public function applyHandlers(stdClass $record, string $type='INSERT') : stdClass
4108
    {
4109
        $this->_compileHandlers();
4110
        $this->_errors                  = [];
4111
        // Disable per field exceptions so we can capture all errors for the record
4112
        $tmpExceptions                  = $this->_validation_exceptions;
4113
        $this->_validation_exceptions   = false;
4114
        foreach ( $this->_handlers as $field => $fn_validator )
4115
        {
4116
            if ( ! property_exists($record, $field) )
4117
            {
4118
                // If the operation is an update it can be a partial update
4119
                if ( $type === self::SAVE_UPDATE )
4120
                {
4121
                    continue;
4122
                }
4123
                $record->{$field}               = null;
4124
            }
4125
            $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...
4126
        }
4127
        $this->_validation_exceptions = $tmpExceptions;
4128
        if ( $this->_validation_exceptions && ! empty($this->_errors) )
4129
        {
4130
            throw new ModelFailedValidationException("Validation of data failed", $this->getErrors(), 422);
4131
        }
4132
4133
        return $record;
4134
    }
4135
4136
    /**
4137
     * @param string $field
4138
     * @param mixed $value
4139
     * @param string $type
4140
     * @param stdClass $record
4141
     * @return null
4142
     * @throws Exception
4143
     */
4144
    protected function applyHandler(string $field, $value, string $type='', stdClass $record=null)
4145
    {
4146
        $this->_compileHandlers();
4147
        $fnHandler = ! empty($this->_handlers[$field]) ? $this->_handlers[$field] : null;
4148
        if ( is_callable($fnHandler) )
4149
        {
4150
            try
4151
            {
4152
                $value = $fnHandler($field, $value, $type, $record);
4153
            }
4154
            catch( Exception $e )
4155
            {
4156
                $this->_errors[$field][] = $e->getMessage();
4157
                if ( $this->_validation_exceptions && ! empty($this->_errors) )
4158
                {
4159
                    throw new ModelFailedValidationException("Validation of data failed", $this->getErrors(), 422);
4160
                }
4161
4162
                return null;
4163
            }
4164
        }
4165
4166
        return $value;
4167
    }
4168
4169
    /**
4170
     * @param string $start
4171
     * @param string $end
4172
     * @param string $hayStack
4173
     * @return mixed
4174
     */
4175
    public static function between(string $start, string $end, string $hayStack) : string
4176
    {
4177
        return static::before($end, static::after($start, $hayStack));
4178
    }
4179
4180
    /**
4181
     * @param string     $needle
4182
     * @param string     $hayStack
4183
     * @param bool $returnOrigIfNeedleNotExists
4184
     * @return mixed
4185
     */
4186
    public static function before(string $needle, string $hayStack, bool $returnOrigIfNeedleNotExists=false) : string
4187
    {
4188
        $result = mb_substr($hayStack, 0, mb_strpos($hayStack, $needle));
4189
        if ( !$result && $returnOrigIfNeedleNotExists )
4190
        {
4191
            return $hayStack;
4192
        }
4193
4194
        return $result;
4195
    }
4196
4197
    /**
4198
     * @param string     $needle
4199
     * @param string     $hayStack
4200
     * @param bool $returnOrigIfNeedleNotExists
4201
     * @return string
4202
     */
4203
    public static function after(string $needle, string $hayStack, bool $returnOrigIfNeedleNotExists=false) : string
4204
    {
4205
        if ( ! is_bool(mb_strpos($hayStack, $needle)) )
4206
        {
4207
            return mb_substr($hayStack, mb_strpos($hayStack, $needle) + mb_strlen($needle));
4208
        }
4209
4210
        return $returnOrigIfNeedleNotExists ? $hayStack : '';
4211
    }
4212
4213
    /**
4214
     * @return int
4215
     */
4216
    public function getUserId()
4217
    {
4218
        return 0;
4219
    }
4220
4221
    /**
4222
     * @param string $entity
4223
     * @param int $id
4224
     * @return int
4225
     */
4226
    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...
4227
    {
4228
        return 31;
4229
    }
4230
4231
    /**
4232
     * @param string|int|null $time
4233
     * @return string
4234
     */
4235
    public static function date($time=null) : string
4236
    {
4237
        return date('Y-m-d', static::getTime($time));
4238
    }
4239
4240
    /**
4241
     * @param string|int|null $time
4242
     * @return string
4243
     */
4244
    public static function dateTime($time=null) : string
4245
    {
4246
        return date('Y-m-d H:i:s', static::getTime($time));
4247
    }
4248
4249
    /**
4250
     * @param string|int|null $time
4251
     * @return string
4252
     */
4253
    public static function atom($time=null) : string
4254
    {
4255
        return date('Y-m-d\TH:i:sP', static::getTime($time));
4256
    }
4257
4258
    /**
4259
     * @param string|int|null $time
4260
     * @return int
4261
     */
4262
    public static function getTime($time=null) : int
4263
    {
4264
        if ( ! $time )
4265
        {
4266
            return time();
4267
        }
4268
        if ( is_int($time) )
4269
        {
4270
            return $time;
4271
        }
4272
4273
        return strtotime($time);
4274
    }
4275
4276
4277
    /**
4278
     * @param int $id
4279
     * @param int $cacheTtl
4280
     * @return string
4281
     */
4282
    public function getCodeById(int $id, int $cacheTtl=self::ONE_DAY) : string
4283
    {
4284
        Assert($id)->id();
4285
        $code   = $this->defaultFilters()->fetchStr($this->getDisplayColumn(), $id, $cacheTtl);
4286
        Assert($code)->notEmpty();
4287
4288
        return $code;
4289
    }
4290
}
4291