Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
69 / 69
DB
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
12 / 12
31
100.00% covered (success)
100.00%
69 / 69
 setManager
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 initTableMap
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
5 / 5
 getTableForClass
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 getPKForClass
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 dql
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
6 / 6
 sql
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
8 / 8
 buildExpression
100.00% covered (success)
100.00%
1 / 1
8
100.00% covered (success)
100.00%
15 / 15
 findBy
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
8 / 8
 findOneBy
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
7 / 7
 count
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 persistWithSameId
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
10 / 10
 __callStatic
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
4 / 4
1<?php
2
3// {{{ License
4
5// This file is part of GNU social - https://www.gnu.org/software/social
6//
7// GNU social is free software: you can redistribute it and/or modify
8// it under the terms of the GNU Affero General Public License as published by
9// the Free Software Foundation, either version 3 of the License, or
10// (at your option) any later version.
11//
12// GNU social is distributed in the hope that it will be useful,
13// but WITHOUT ANY WARRANTY; without even the implied warranty of
14// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15// GNU Affero General Public License for more details.
16//
17// You should have received a copy of the GNU Affero General Public License
18// along with GNU social.  If not, see <http://www.gnu.org/licenses/>.
19
20// }}}
21
22/**
23 * Doctrine entity manager static wrapper
24 *
25 * @package GNUsocial
26 * @category DB
27 *
28 * @author    Hugo Sales <hugo@hsal.es>
29 * @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
30 * @license   https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
31 */
32
33namespace App\Core\DB;
34
35use App\Util\Exception\DuplicateFoundException;
36use App\Util\Exception\NotFoundException;
37use Doctrine\Common\Collections\Criteria;
38use Doctrine\Common\Collections\Expr\Expression;
39use Doctrine\Common\Collections\ExpressionBuilder;
40use Doctrine\ORM\EntityManagerInterface;
41use Doctrine\ORM\Query;
42use Doctrine\ORM\Query\ResultSetMappingBuilder;
43use Functional as F;
44
45abstract class DB
46{
47    private static ?EntityManagerInterface $em;
48    public static function setManager($m): void
49    {
50        self::$em = $m;
51    }
52
53    /**
54     * Table name to class map, used to allow specifying table names instead of classes in doctrine calls
55     */
56    private static array $table_map = [];
57    private static array $class_pk  = [];
58    public static function initTableMap()
59    {
60        $all = self::$em->getMetadataFactory()->getAllMetadata();
61        foreach ($all as $meta) {
62            self::$table_map[$meta->getTableName()]          = $meta->getMetadataValue('name');
63            self::$class_pk[$meta->getMetadataValue('name')] = $meta->getIdentifier();
64        }
65    }
66
67    public static function getTableForClass(string $class)
68    {
69        return array_search($class, self::$table_map);
70    }
71
72    public static function getPKForClass(string $class)
73    {
74        return self::$class_pk[$class];
75    }
76
77    /**
78     * Perform a Doctrine Query Language query
79     */
80    public static function dql(string $query, array $params = [])
81    {
82        $query = preg_replace(F\map(self::$table_map, function ($_, $s) { return "/\\b{$s}\\b/"; }), self::$table_map, $query);
83        $q     = new Query(self::$em);
84        $q->setDQL($query);
85        foreach ($params as $k => $v) {
86            $q->setParameter($k, $v);
87        }
88        return $q->getResult();
89    }
90
91    /**
92     * Perform a native, parameterized, SQL query. $entities is a map
93     * from table aliases to class names. Replaces '{select}' in
94     * $query with the appropriate select list
95     */
96    public static function sql(string $query, array $entities, array $params = [])
97    {
98        $rsm = new ResultSetMappingBuilder(self::$em);
99        foreach ($entities as $alias => $entity) {
100            $rsm->addRootEntityFromClassMetadata($entity, $alias);
101        }
102        $query = preg_replace('/{select}/', $rsm->generateSelectClause(), $query);
103        $q     = self::$em->createNativeQuery($query, $rsm);
104        foreach ($params as $k => $v) {
105            $q->setParameter($k, $v);
106        }
107        return $q->getResult();
108    }
109
110    /**
111     * A list of possible operations needed in self::buildExpression
112     */
113    private static array $find_by_ops = [
114        'or', 'and', 'eq', 'neq', 'lt', 'lte',
115        'gt', 'gte', 'is_null', 'in', 'not_in',
116        'contains', 'member_of', 'starts_with', 'ends_with',
117    ];
118
119    /**
120     * Build a Doctrine Criteria expression from the given $criteria.
121     *
122     * @see self::findBy for the syntax
123     */
124    private static function buildExpression(ExpressionBuilder $eb, array $criteria): array
125    {
126        $expressions = [];
127        foreach ($criteria as $op => $exp) {
128            if ($op == 'or' || $op == 'and') {
129                $method = "{$op}X";
130                $expr   = self::buildExpression($eb, $exp);
131                if (is_array($expr)) {
132                    $expressions[] = $eb->{$method}(...$expr);
133                } else {
134                    $expressions[] = $eb->{$method}($expr);
135                }
136            } elseif ($op == 'is_null') {
137                $expressions[] = $eb->isNull($exp);
138            } else {
139                if (in_array($op, self::$find_by_ops)) {
140                    foreach ($exp as $field => $value) {
141                        $expressions[] = $eb->{$op}($field, $value);
142                    }
143                } else {
144                    $expressions[] = $eb->eq($op, $exp);
145                }
146            }
147        }
148
149        return $expressions;
150    }
151
152    /**
153     * Query $table according to $criteria. If $criteria's keys are
154     * one of self::$find_by_ops (and, or, etc), build a subexpression
155     * with that operator and recurse. Examples of $criteria are
156     * `['and' => ['lt' => ['foo' => 4], 'gte' => ['bar' => 2]]]` or
157     * `['in' => ['foo', 'bar']]`
158     */
159    public static function findBy(string $table, array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
160    {
161        $criteria = array_change_key_case($criteria, CASE_LOWER);
162        $ops      = array_intersect(array_keys($criteria), self::$find_by_ops);
163        $repo     = self::getRepository($table);
164        if (empty($ops)) {
165            return $repo->findBy($criteria, $orderBy, $limit, $offset);
166        } else {
167            $eb       = Criteria::expr();
168            $criteria = new Criteria($eb->andX(...self::buildExpression($eb, $criteria)), $orderBy, $offset, $limit);
169            return $repo->matching($criteria)->toArray(); // Always work with array or it becomes really complicated
170        }
171    }
172
173    /**
174     * Return the first element of the result of @see self::findBy
175     */
176    public static function findOneBy(string $table, array $criteria, ?array $orderBy = null, ?int $offset = null)
177    {
178        $res = self::findBy($table, $criteria, $orderBy, 2, $offset); // Use limit 2 to check for consistency
179        switch (count($res)) {
180        case 0:
181            throw new NotFoundException("No value in table {$table} matches the requested criteria");
182        case 1:
183            return $res[0];
184        default:
185            throw new DuplicateFoundException("Multiple values in table {$table} match the requested criteria");
186        }
187    }
188
189    public static function count(string $table, array $criteria)
190    {
191        $repo = self::getRepository($table);
192        return $repo->count($criteria);
193    }
194
195    /**
196     * Insert all given objects with the generated ID of the first one
197     */
198    public static function persistWithSameId(object $owner, object | array $others, ?callable $extra = null)
199    {
200        $conn     = self::getConnection();
201        $metadata = self::getClassMetadata(get_class($owner));
202        $seqName  = $metadata->getSequenceName($conn->getDatabasePlatform());
203        self::persist($owner);
204        $id = $conn->lastInsertId($seqName);
205        F\map(is_array($others) ? $others : [$others], function ($o) use ($id) { $o->setId($id); self::persist($o); });
206        if (!is_null($extra)) {
207            $extra($id);
208        }
209        self::flush();
210        return $id;
211    }
212
213    /**
214     * Intercept static function calls to allow refering to entities
215     * without writing the namespace (which is deduced from the call
216     * context)
217     */
218    public static function __callStatic(string $name, array $args)
219    {
220        if (in_array($name, ['find', 'getReference', 'getPartialReference', 'getRepository'])
221            && !str_contains($args[0], '\\')) {
222            $args[0] = self::$table_map[$args[0]];
223        }
224
225        return self::$em->{$name}(...$args);
226    }
227}