Issues (39)

src/Boson/QuerySet.php (1 issue)

Labels
Severity
1
<?php
2
3
namespace Lepton\Boson;
4
5
use Lepton\Core\Application;
6
7
/**
8
 * QuerySet is lazy: it doesn't execute queries unless forced.
9
 * It tries to build only one big query in order to accomplish the task.
10
 */
11
12
class QuerySet implements \Iterator, \ArrayAccess
13
{
14
    // Variables used for Iterator implementation
15
16
    /**
17
     * The index of the current element in iteration
18
     *
19
     * @var int
20
     */
21
    private int $index;
22
23
    /**
24
     * The current element in iteration
25
     *
26
     * @var mixed
27
     */
28
    private mixed $current;
29
30
    /**
31
     * Check is iteration is valid
32
     *
33
     * @var bool
34
     */
35
    private bool $valid;
36
37
38
    /**
39
     * Array containing the filters to use in the WHERE clause of the query
40
     *
41
     * @var array
42
     */
43
    public array $filters;
44
45
    /**
46
     * Other modifiers for the query (e.g.) GROUP BY, ORDER BY
47
     *
48
     * @var array
49
     */
50
    public array $modifiers;
51
52
    /**
53
     * Dictionary between operator after __ in filter name and MySql operators
54
     * Every element has a key, corresponding to the string after __ in filter name.
55
     * Every element is an array of two element: the first has key "operator" and the value
56
     * is a string corresponding to the MySql operator; the second is an inline function
57
     * that accepts a parameter (the value) and returns a string corresponding to the r
58
     * right-hand side of the statament.
59
     * @var array
60
     */
61
    private array $lookup_map;
62
63
64
    /**
65
     * If query is executed, contains the mysqli_result object
66
     * If query is not executed, is NULL.
67
     *
68
     * @var \mysqli_result
69
     */
70
    private \mysqli_result $result;
71
72
    /**
73
     * If query is executed, contains the cached values (that is, the values already
74
     * retrieved from database).
75
     * If query is not executed, is NULL
76
     *
77
     * @var array
78
     */
79
    private array $cache;
80
81
    public function __construct(protected string $model)
82
    {
83
        $this->lookup_map = array(
84
          "equals" => [
85
            "operator" => "=",
86
            "rhs" => fn ($x) =>  $x
87
          ],
88
          "startswith" => [
89
            "operator" => "LIKE",
90
            "rhs" => fn ($x) =>  sprintf('%s%%', $x)
91
          ],
92
93
          "endswith" => [
94
            "operator" => "LIKE",
95
            "rhs" => fn ($x) => sprintf('%%%s', $x)
96
          ],
97
98
          "contains" => [
99
            "operator" => "LIKE",
100
            "rhs" => fn ($x) => sprintf('%%%s%%', $x)
101
          ],
102
103
          "lte" => [
104
            "operator" => "<=",
105
            "rhs" => fn ($x) => $x
106
          ],
107
108
          "gte" => [
109
            "operator" => ">=",
110
            "rhs" => fn ($x) => $x
111
          ],
112
113
          "lt" => [
114
            "operator" => "<",
115
            "rhs" => fn ($x) => $x
116
          ],
117
118
          "gt" => [
119
            "operator" => ">",
120
            "rhs" => fn ($x) => $x
121
          ],
122
123
          "neq" => [
124
            "operator" => "<>",
125
            "rhs" => fn ($x) => $x
126
          ]
127
        );
128
129
        $this->filters = array();
130
        $this->modifiers = array();
131
    }
132
133
    /*
134
    ================================== ITERATOR ==========================================
135
    */
136
137
    /**
138
     * Implements \Iterator interface.
139
     *
140
     * @return mixed
141
     * The current element of iteration
142
     */
143
    public function current(): mixed
144
    {
145
        return $this->current;
146
    }
147
148
149
    /**
150
     * Implements \Iterator interface.
151
     *
152
     * @return mixed
153
     * The key of the current element of iteration
154
     */
155
    public function key(): mixed
156
    {
157
        return $this->index;
158
    }
159
160
    /**
161
     * Implements \Iterator interface.
162
     * Gets next element of iteration.
163
     *
164
     * @return void
165
     */
166
    public function next(): void
167
    {
168
        $this->fetchNext();
169
    }
170
171
172
    /**
173
     * Implements \Iterator interface.
174
     * Rewinds the iteration.
175
     *
176
     * @return void
177
     */
178
    public function rewind(): void
179
    {
180
        $this->index = -1;
181
        if (! isset($this->result)) {
182
            $this->cache = array();
183
            $this->do();
184
        }
185
        $this->fetchNext();
186
    }
187
188
189
    /**
190
     * Implements \Iterator interface
191
     *
192
     * @return bool
193
     * Whether the iteration is valid or not
194
     */
195
196
    public function valid(): bool
197
    {
198
        return $this->valid;
199
    }
200
201
202
203
    /**
204
     * Performs actual iteration step
205
     *
206
     *  @return void
207
     */
208
    protected function fetchNext(): void
209
    {
210
        // Get next row of the query result
211
        $items = $this->result->fetch_assoc();
212
        // If no element is retrieved, then iteration is not valid
213
        $this->valid = !is_null(($items));
214
215
        // If iteration is valid, update $this->current, $this->index
216
        if ($this->valid) {
217
            // Create new model
218
            $this->current = new $this->model;
219
            $db_columns = $this->current->db_columns();
220
            // Convert column names to field names
221
            $fields = array();
222
            foreach($items as $column => $value){
223
                $fieldName = array_search($column ,$db_columns);
224
                $fields[$fieldName] = $value;
225
            }
226
            $this->current->load(...$fields);
227
            $this->index = $this->current->getPk();
228
            $this->current->clearEditedFields();
229
        } else {
230
            $this->current = null;
231
            $this->index =  -1;
232
        }
233
234
235
        // Cache the retrieved element
236
        $this->cache[$this->index] = $this->current;
237
    }
238
239
240
    public function first()
241
    {
242
        if(!isset($this->cache)) {
243
            $this->rewind();
244
        }
245
246
        return $this->cache[array_key_first($this->cache)];
247
248
    }
249
250
251
    /*
252
    ================================= ARRAY ACCESS =======================================
253
    */
254
255
256
257
    /**
258
     * Implements \ArrayAccess interface.
259
     * Verifies if a key exists or not. If iteration is not started yet, throws an
260
     * Exception.
261
     *
262
     * @param mixed $offset
263
     * The key to verify
264
     *
265
     * @return bool
266
     * Whether the key exists
267
     */
268
    public function offsetExists(mixed $offset): bool
269
    {
270
        if (isset($this->result)) {
271
            return array_key_exists($offset, $this->cache);
272
        } else {
273
            throw new \Exception("Before accessing keys, you have to execute query");
274
        }
275
    }
276
277
278
279
    /**
280
     * Implements \ArrayAccess interface.
281
     * Gets the element corresponding to the given key.
282
     *
283
     * @param mixed $offset
284
     * The key of the element to retrieve
285
     *
286
     * @return mixed
287
     */
288
    public function offsetGet(mixed $offset): mixed
289
    {
290
        if (isset($this->result)) {
291
            return $this->cache[$offset];
292
        } else {
293
            throw new \Exception("Before accessing keys, you have to execute query");
294
        }
295
    }
296
297
298
    /**
299
     * Refuse to set: QuerySets are read-only.
300
     */
301
    public function offsetSet(mixed $offset, mixed $value): void
302
    {
303
        throw new \Exception("QuerySets are read-only");
304
    }
305
306
307
    /**
308
     * Refuse to set: QuerySets are read-only.
309
     */
310
    public function offsetUnset(mixed $offset): void
311
    {
312
        throw new \Exception("QuerySets are read-only");
313
    }
314
315
316
    /*
317
    ============================== DATABASE FUNCTIONS ====================================
318
    */
319
320
321
    private function extract_filters($filters): array
322
    {
323
        if ((count($filters) == 1) && ($filters[array_key_first($filters)] instanceof QuerySet)) {
324
            return $filters[array_key_first($filters)]->filters;
325
        } elseif (count(array_filter($filters, fn ($x) => $x instanceof QuerySet)) > 0) {
326
            throw new \Exception("Only one element allowed if QuerySet");
327
        } else {
328
            return $filters;
329
        }
330
    }
331
332
    public function filter(...$filters): QuerySet
333
    {
334
        $this->and(...$filters);
335
        return $this;
336
    }
337
338
339
    public function and(...$filters): QuerySet
340
    {
341
        $this->filters[] = ["AND"  => $this->extract_filters($filters)];
342
        return $this;
343
    }
344
345
346
    public function exclude(...$filters): QuerySet
347
    {
348
        $this->filters[] = ["AND NOT"  => $this->extract_filters($filters)];
349
        return $this;
350
    }
351
352
353
354
355
356
    public function or(...$filters): QuerySet
357
    {
358
        $this->filters[] = ["OR"  => $this->extract_filters($filters)];
359
        return $this;
360
    }
361
362
363
    public function xor(...$filters): QuerySet
364
    {
365
        $this->filters[] = ["XOR"  => $this->extract_filters($filters)];
366
        return $this;
367
    }
368
369
370
    public function group_by(string ...$filters): QuerySet
371
    {
372
        if (array_key_exists("GROUP BY", $this->modifiers)) {
373
            throw new \Exception("Multiple Group By!");
374
        }
375
        $this->modifiers["GROUP BY"] = $filters;
376
        return $this;
377
    }
378
379
380
    public function order_by(string|array ...$filters): QuerySet
381
    {
382
        if (array_key_exists("ORDER BY", $this->modifiers)) {
383
            throw new \Exception("Multiple order By!");
384
        }
385
386
        $this->modifiers["ORDER BY"] = [];
387
        foreach($filters as $filter){
388
            $this->modifiers["ORDER BY"][] = is_array($filter) ? $filter : [$filter => "ASC"];
389
        }
390
        $this->modifiers["ORDER BY"] = $filters;
391
        return $this;
392
    }
393
394
395
396
    public function all(): QuerySet
397
    {
398
        $this->filters = array();
399
        return $this;
400
    }
401
402
403
404
    public function count(): int
405
    {
406
        if (! isset($this->result)) {
407
            $this->do();
408
        }
409
        return $this->result->num_rows;
410
    }
411
412
413
414
    /**
415
     *  Executes the query. Until this function is not called, no query is sent to the
416
     *  database
417
     *
418
     *  @return QuerySet
419
     */
420
421
    public function do(): QuerySet
422
    {
423
424
        // Build the query
425
        list($query, $values) = $this->buildQuery();
426
427
        // Connect to DB
428
        $db = Application::getDb();
429
430
431
        // Send the query to the database
432
        // If there are values for prepared statements, send them too.
433
        if (count($values) > 0) {
434
            $result = $db->query($query, ...$values);
435
        } else {
436
            $result = $db->query($query);
437
        }
438
439
440
        // Get the result
441
        $this->result = $result->fetch_result() ;
442
        return $this;
443
    }
444
445
446
    /**
447
     * Builds the query
448
     *
449
     * Query building happens in three steps:
450
     * 1) Get the columns to select (do not SELECT * because of relationships)
451
     * 2) Build WHERE clause
452
     * 3) Build JOIN clause
453
     * 4) Build GROUP BY and ORDER BY
454
     *
455
     * Returns an array containing the query and the values to be bound.
456
     *
457
     * @return array
458
     *
459
     */
460
461
    public function buildQuery(): array
462
    {
463
        $tableName = $this->model::getTableName();
464
        $columns = (new $this->model())->getColumnList();
465
        $selectColumns = array_map(array($this, "buildSelectColumns"), $columns);
466
467
        $query = sprintf(" SELECT %s FROM %s ", implode(", ", $selectColumns), $tableName);
468
469
        $values = array();
470
        $join = array();
471
        $join_t = array();
472
473
        $modifiers = "";
474
475
        // it there are any ORDER BY, build the clause
476
        if (count($this->modifiers)> 0) {
477
            list($modifiers, $join) = $this->buildModifiers();
478
        }
479
480
        // if there are any filters build WHERE clause
481
        if (count($this->filters)> 0) {
482
            list($whereClause, $values, $join_t) = $this->buildWhereClause($this->filters);
483
        }
484
485
        array_unshift($join_t, array("table" => $this->model::getTableName()));
486
        array_unshift($join, array("table" => $this->model::getTableName()));
487
        // build JOIN for One-To-Many and One-To-One relationships
488
        $query .= " ".$this->buildJoin($join);
489
        $query .= " ".$this->buildJoin($join_t);
490
491
        if(count($this->filters)>0){
492
            $query .= sprintf(" WHERE %s ", $whereClause);
493
        }
494
495
        // it there are any ORDER BY, build the clause
496
        if (count($this->modifiers)> 0) {
497
            $query .= sprintf(" ORDER BY %s", $modifiers);
498
        }
499
500
       //if(strpos($query, "ORDER BY")) die (print_r($query));
501
        return array($query, $values);
502
    }
503
504
505
    /**
506
     *  Build the string containing the correct column name to be used for the database
507
     *  from the model field
508
     */
509
    private function buildSelectColumns(mixed $value)
510
    {
511
        $tableName = $this->model::getTableName();
512
513
        return $tableName.".".$value;
514
    }
515
516
517
    private function buildJoin(array $join)
518
    {
519
        if (count($join) == 1) {
520
            return "";
521
        }
522
        $join = array_values(array_unique($join, SORT_REGULAR));
523
524
        $clause = array();
525
        for ($i = 1; $i < count($join); $i++) {
526
            $clause[] =  sprintf(
527
                " %s ON %s.%s = %s.%s",
528
                $join[$i]["table"],
529
                $join[$i-1]["table"],
530
                $join[$i]["column"],
531
                $join[$i]["table"],
532
                $join[$i]["column"]
533
            );
534
        }
535
        $return = "JOIN ".implode(" JOIN ", $clause);
536
537
        return $return;
538
    }
539
540
    /**
541
     * Builds the modifiers for the query set.
542
     *
543
     * @return array An array containing the ORDER BY clause and the join conditions.
544
     */
545
    private function buildModifiers()
546
    {
547
        $order_by = $this->modifiers["ORDER BY"];
548
        $conditions = array();
549
        $join = array();
550
551
        foreach($order_by as $order_clause) {
552
            if(is_array($order_clause)) {
553
                $raw = array_key_first($order_clause);
554
                $method = $order_clause[$raw];
555
            } else {
556
                $raw = $order_clause;
557
                $method = "ASC";
558
            }
559
560
            $parsed = $this->lookup($raw);
561
562
            $column = $parsed["column"];
563
            $condition = $parsed["condition"];
564
565
            if (empty($parsed["join"])) {
566
                $tableName = (new $this->model())->getTableName();
567
            } else {
568
                $tableName = end($parsed["join"])["table"];
569
            }
570
571
            $conditions[$column] = sprintf("%s.%s %s", $tableName, $column, $method);
572
            $join = array_merge($join, $parsed["join"]);
573
574
        }
575
            $clause = implode(", ", $conditions);
576
            return [$clause, $join];
577
      }
578
579
580
    private function buildWhereClause($filters)
581
    {
582
        $where = "";
583
        $parameters = array();
584
        $join = array();
585
        foreach ($filters as $n => $filter) {
586
            foreach ($filter as $logic => $values) {
587
                if ($n != 0) {
588
                    $where .= sprintf(" %s ", $logic);
589
                }
590
                if (is_int(array_key_first($values))) {
591
                    $atomic = $this->buildWhereClause($values);
592
                    $where .= sprintf("(%s)", $atomic[0]);
593
                } else {
594
                    $atomic = $this->buildAtomicWhereClause($values);
595
                    $where .= $atomic[0];
596
                }
597
                $parameters = array_merge($parameters, $atomic[1]);
598
                $join = array_merge($join, $atomic[2]);
599
            }
600
        }
601
602
        return array($where, $parameters, $join);
603
    }
604
605
    private function buildAtomicWhereClause($filters)
606
    {
607
        $conditions = array();
608
        $values = array();
609
        $join = array();
610
611
        foreach($filters as $key => $value) {
612
            $lookup = $this->lookup($key);
613
614
            $column = $lookup["column"];
615
            $condition = $lookup["condition"];
616
617
            if (empty($lookup["join"])) {
618
                $tableName = (new $this->model())->getTableName();
619
            } else {
620
                $tableName = end($lookup["join"])["table"];
621
            }
622
            $map = $this->lookup_map[$condition];
623
624
            if ($value instanceof Model) {
625
                $values[$column] = ($map["rhs"])($value->getPk());
626
            } else {
627
                $values[$column] = ($map["rhs"])($value);
628
            }
629
630
            $conditions[$column] = sprintf("%s.%s %s ?", $tableName, $column, $map["operator"]);
631
632
            $join = array_merge($join, $lookup["join"]);
633
        }
634
635
        $clause = implode(" AND ", $conditions);
636
637
        return array(0=>$clause, 1=> array_values($values), 2 => $join);
638
    }
639
640
641
    public function lookup(string $value): array
642
    {
643
        $match = explode("__", $value);
644
645
        // Get lookup method
646
        if (array_key_exists(end($match), $this->lookup_map)) {
647
            $condition = array_pop($match);
648
        } else {
649
            $condition = "equals";
650
        }
651
652
        // Get column name
653
        $last = new $this->model();
654
        $column = array_pop($match);
655
656
        // Now match has only joins
657
        $join = array();
658
        foreach ($match as $k) {
659
            if($last->isForeignKey($k)) {
660
                $new= new ($last->getRelationshipParentModel($k))();
0 ignored issues
show
A parse error occurred: Syntax error, unexpected '(' on line 660 at column 26
Loading history...
661
662
                $join[] = array(
663
                    "column"=> $last->getColumnFromField($k),
664
                    "table" => $new->getTableName()
665
                );
666
            } elseif ($last->isReverseForeignKey($k)) {
667
                $reverseForeignKey = $last->getChild($k);
668
                $new = new ($reverseForeignKey->child);
669
670
                $join[] = array(
671
                    "column" => $new->getColumnFromField($reverseForeignKey->foreignKey),
672
                    "table" => $new->getTableName()
673
                );
674
675
            } else {
676
                throw new \Exception("$value is not a valid relationship description");
677
            }
678
            $last = $new;
679
        }
680
681
        return array(
682
          "column"    => $last->getColumnFromField($column),
683
          "condition" => $condition,
684
          "join" => $join
685
        );
686
    }
687
}
688