2020-05-13 13:10:24 +01:00
< ? php
2021-10-10 09:26:18 +01:00
declare ( strict_types = 1 );
2020-05-20 17:53:53 +01:00
// {{{ License
2020-09-10 23:25:47 +01:00
2020-05-20 17:53:53 +01:00
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
2020-09-10 23:25:47 +01:00
2020-05-20 17:53:53 +01:00
// }}}
2020-05-13 13:10:24 +01:00
/**
* Doctrine entity manager static wrapper
*
* @ package GNUsocial
* @ category DB
*
2021-02-19 23:29:43 +00:00
* @ author Hugo Sales < hugo @ hsal . es >
* @ copyright 2020 - 2021 Free Software Foundation , Inc http :// www . fsf . org
2020-05-13 13:10:24 +01:00
* @ license https :// www . gnu . org / licenses / agpl . html GNU AGPL v3 or later
*/
2020-06-04 22:00:05 +01:00
namespace App\Core\DB ;
2020-05-13 13:10:24 +01:00
2021-04-25 22:15:24 +01:00
use App\Util\Exception\DuplicateFoundException ;
2020-09-10 23:25:47 +01:00
use App\Util\Exception\NotFoundException ;
[CORE][DB][ENTITY][Actor] Make DB::dql return a chunked array if selecting multiple entities, remove partitioning from callsite
`DB::dql('select a, b, from a join b')` would previously return `[a,
b, a, b, ...]` (or even `[b, a, b, a, ...]`), and now will return
`[[a, a, ...], [b, b, ...]]`. The issue would be further compounded
when selecting even more entities, where the order would be
unpredictable
2021-12-01 00:42:56 +00:00
use Closure ;
2020-07-21 22:32:21 +01:00
use Doctrine\Common\Collections\Criteria ;
use Doctrine\Common\Collections\ExpressionBuilder ;
2021-11-26 15:11:30 +00:00
use Doctrine\ORM\EntityManager ;
2020-05-14 22:55:04 +01:00
use Doctrine\ORM\EntityManagerInterface ;
2021-09-06 23:47:28 +01:00
use Doctrine\ORM\EntityRepository ;
2020-08-14 23:36:08 +01:00
use Doctrine\ORM\Query ;
2020-09-04 19:45:28 +01:00
use Doctrine\ORM\Query\ResultSetMappingBuilder ;
2021-11-09 23:37:46 +00:00
use Exception ;
2021-04-15 23:34:55 +01:00
use Functional as F ;
2020-05-13 13:10:24 +01:00
2021-09-06 20:59:36 +01:00
/**
2021-11-26 15:11:30 +00:00
* @ mixin EntityManager
2021-09-06 23:47:28 +01:00
* @ template T
*
2021-12-26 22:17:26 +00:00
* @ method static ? T find ( string $class , array < string , mixed > $values ) // Finds an Entity by its identifier.
* @ method static ? T getReference ( string $class , array < string , mixed > $values ) // Special cases: It's like find but does not load the object if it has not been loaded yet, it only returns a proxy to the object. (https://www.doctrine-project.org/projects/doctrine-orm/en/2.10/reference/unitofwork.html)
* @ method static void remove ( object $entity ) // Removes an entity instance.
* @ method static T merge ( object $entity ) // Merges the state of a detached entity into the persistence context
* @ method static void persist ( object $entity ) // Tells the EntityManager to make an instance managed and persistent.
* @ method static bool contains ( object $entity ) // Determines whether an entity instance is managed in this EntityManager.
* @ method static void flush () // Flushes the in-memory state of persisted objects to the database.
* @ method mixed wrapInTransaction ( callable $func ) // Executes a function in a transaction. Warning: suppresses exceptions
2021-09-06 20:59:36 +01:00
*/
class DB
2020-05-13 13:10:24 +01:00
{
2020-05-14 22:55:04 +01:00
private static ? EntityManagerInterface $em ;
public static function setManager ( $m ) : void
2020-05-13 13:10:24 +01:00
{
self :: $em = $m ;
}
2021-04-15 18:01:52 +01:00
/**
* Table name to class map , used to allow specifying table names instead of classes in doctrine calls
*/
[CORE][DB][ENTITY][Actor] Make DB::dql return a chunked array if selecting multiple entities, remove partitioning from callsite
`DB::dql('select a, b, from a join b')` would previously return `[a,
b, a, b, ...]` (or even `[b, a, b, a, ...]`), and now will return
`[[a, a, ...], [b, b, ...]]`. The issue would be further compounded
when selecting even more entities, where the order would be
unpredictable
2021-12-01 00:42:56 +00:00
private static array $table_map = [];
private static array $class_pk = [];
private static ? string $sql_table_entity_pattern = null ;
private static ? array $dql_table_name_patterns = null ;
2021-04-15 18:01:52 +01:00
public static function initTableMap ()
{
2021-05-05 17:03:03 +01:00
$all = self :: $em -> getMetadataFactory () -> getAllMetadata ();
foreach ( $all as $meta ) {
2021-11-01 21:04:49 +00:00
self :: $table_map [ $meta -> getTableName ()] = $meta -> getMetadataValue ( 'name' );
2021-07-21 17:40:48 +01:00
self :: $class_pk [ $meta -> getMetadataValue ( 'name' )] = $meta -> getIdentifier ();
2021-04-15 18:01:52 +01:00
}
2021-11-08 20:15:45 +00:00
[CORE][DB][ENTITY][Actor] Make DB::dql return a chunked array if selecting multiple entities, remove partitioning from callsite
`DB::dql('select a, b, from a join b')` would previously return `[a,
b, a, b, ...]` (or even `[b, a, b, a, ...]`), and now will return
`[[a, a, ...], [b, b, ...]]`. The issue would be further compounded
when selecting even more entities, where the order would be
unpredictable
2021-12-01 00:42:56 +00:00
self :: $sql_table_entity_pattern = '/(' . implode ( '|' , array_keys ( self :: $table_map )) . ')\s([^\s]+)/' ;
2021-12-21 12:44:34 +00:00
self :: $dql_table_name_patterns = F\map ( self :: $table_map , fn ( $_ , $s ) => " /(?<![ \\ .']) \\ b { $s } \\ b/ " );
2021-04-15 18:01:52 +01:00
}
2021-07-21 17:40:48 +01:00
public static function getTableForClass ( string $class )
{
return array_search ( $class , self :: $table_map );
}
public static function getPKForClass ( string $class )
{
return self :: $class_pk [ $class ];
}
2020-11-06 19:47:15 +00:00
/**
* Perform a Doctrine Query Language query
*/
2021-09-20 15:21:38 +01:00
public static function dql ( string $query , array $params = [], array $options = [])
2020-08-14 23:36:08 +01:00
{
[CORE][DB][ENTITY][Actor] Make DB::dql return a chunked array if selecting multiple entities, remove partitioning from callsite
`DB::dql('select a, b, from a join b')` would previously return `[a,
b, a, b, ...]` (or even `[b, a, b, a, ...]`), and now will return
`[[a, a, ...], [b, b, ...]]`. The issue would be further compounded
when selecting even more entities, where the order would be
unpredictable
2021-12-01 00:42:56 +00:00
$query = preg_replace ( self :: $dql_table_name_patterns , self :: $table_map , $query );
2021-04-15 23:34:55 +01:00
$q = new Query ( self :: $em );
2020-08-14 23:36:08 +01:00
$q -> setDQL ( $query );
2021-09-20 15:21:38 +01:00
if ( isset ( $options [ 'limit' ])) {
$q -> setMaxResults ( $options [ 'limit' ]);
}
if ( isset ( $options [ 'offset' ])) {
$q -> setFirstResult ( $options [ 'offset' ]);
}
2020-08-14 23:36:08 +01:00
foreach ( $params as $k => $v ) {
$q -> setParameter ( $k , $v );
}
2021-09-20 15:21:38 +01:00
[CORE][DB][ENTITY][Actor] Make DB::dql return a chunked array if selecting multiple entities, remove partitioning from callsite
`DB::dql('select a, b, from a join b')` would previously return `[a,
b, a, b, ...]` (or even `[b, a, b, a, ...]`), and now will return
`[[a, a, ...], [b, b, ...]]`. The issue would be further compounded
when selecting even more entities, where the order would be
unpredictable
2021-12-01 00:42:56 +00:00
$results = $q -> getResult ();
// So, Doctrine doesn't return 'select a, b from a join b' as [[a, a], [b, b]], but as [a, b, a, b] (or even [b,
// a, b, a]), so we do it ourselves. For whatever reason, neither the AST nor the ResultSetMapping have the
// entities in the correct order, so we need to "parse" the query ourselves. This only applies if there's no '.'
// in the select clause (i.e. we're selecting whole entities, not just a bunch of columns)
$matches = []; // v not a space in case of line breaks
if ( $ret = preg_match ( '/SELECT.([^\.]*).FROM/is' , $query , $matches )) {
// Grab the entities from the select clause and trim spaces
$entities = F\map ( explode ( ',' , $matches [ 1 ]), fn ( $p ) => trim ( $p ));
if ( \count ( $entities ) > 1 ) { // If more than one entities in the select clause
// Call protected method getResultSetMapping, get the alias map (to avoid parsing it ourselves, or
// dealing with the AST)
$aliases = Closure :: bind ( fn ( $q ) => $q -> getResultSetMapping (), null , $q )( $q ) -> aliasMap ;
// Since the order is not necessarily the correct one in the results (for whatever reason) (though it
// presumably is the same as in the AST, but just in case), use Functional\partition to chunk the
// results into groups of the same class
return F\partition (
$results ,
... F\map (
// partition partitions into one more array than we want (those that don't pass any predicate),
// so drop the last
F\but_last ( $entities ),
// Map into a list of callables that each check if the given object is an instance of the class
// in $aliases
fn ( $p ) => ( fn ( $o ) => $o instanceof $aliases [ $p ]),
),
);
} else {
return $results ;
}
} else {
return $results ;
}
2020-08-14 23:36:08 +01:00
}
2020-11-06 19:47:15 +00:00
/**
* Perform a native , parameterized , SQL query . $entities is a map
* from table aliases to class names . Replaces '{select}' in
* $query with the appropriate select list
*/
2021-11-28 18:53:34 +00:00
public static function sql ( string $query , array $params = [], ? array $entities = null )
2020-09-04 19:45:28 +01:00
{
2021-11-09 23:37:46 +00:00
if ( $_ENV [ 'APP_ENV' ] === 'dev' && str_starts_with ( $query , 'select *' )) {
throw new Exception ( 'Cannot use `select *`, use `select {select}` (see ResultSetMappingBuilder::COLUMN_RENAMING_INCREMENT)' );
}
2021-11-29 15:28:40 +00:00
$rsmb = new ResultSetMappingBuilder ( self :: $em , \is_null ( $entities ) ? ResultSetMappingBuilder :: COLUMN_RENAMING_INCREMENT : ResultSetMappingBuilder :: COLUMN_RENAMING_NONE );
2021-11-28 18:53:34 +00:00
if ( \is_null ( $entities )) {
$matches = [];
[CORE][DB][ENTITY][Actor] Make DB::dql return a chunked array if selecting multiple entities, remove partitioning from callsite
`DB::dql('select a, b, from a join b')` would previously return `[a,
b, a, b, ...]` (or even `[b, a, b, a, ...]`), and now will return
`[[a, a, ...], [b, b, ...]]`. The issue would be further compounded
when selecting even more entities, where the order would be
unpredictable
2021-12-01 00:42:56 +00:00
preg_match_all ( self :: $sql_table_entity_pattern , $query , $matches );
2021-11-28 18:53:34 +00:00
$entities = [];
foreach ( F\zip ( $matches [ 1 ], $matches [ 2 ]) as [ $table , $alias ]) {
$entities [ $alias ] = self :: $table_map [ $table ];
}
2021-11-08 20:15:45 +00:00
}
2020-09-04 19:45:28 +01:00
foreach ( $entities as $alias => $entity ) {
2021-11-09 23:37:46 +00:00
$rsmb -> addRootEntityFromClassMetadata ( $entity , $alias );
2020-09-04 19:45:28 +01:00
}
2021-11-09 23:37:46 +00:00
$query = preg_replace ( '/{select}/' , $rsmb -> generateSelectClause (), $query );
$q = self :: $em -> createNativeQuery ( $query , $rsmb );
2020-09-04 19:45:28 +01:00
foreach ( $params as $k => $v ) {
$q -> setParameter ( $k , $v );
}
return $q -> getResult ();
}
2020-11-06 19:47:15 +00:00
/**
* A list of possible operations needed in self :: buildExpression
*/
private static array $find_by_ops = [
'or' , 'and' , 'eq' , 'neq' , 'lt' , 'lte' ,
2020-07-25 02:55:39 +01:00
'gt' , 'gte' , 'is_null' , 'in' , 'not_in' ,
2020-11-06 19:47:15 +00:00
'contains' , 'member_of' , 'starts_with' , 'ends_with' ,
];
/**
* Build a Doctrine Criteria expression from the given $criteria .
*
* @ see self :: findBy for the syntax
*/
2021-05-11 22:04:15 +01:00
private static function buildExpression ( ExpressionBuilder $eb , array $criteria ) : array
2020-07-21 22:32:21 +01:00
{
$expressions = [];
foreach ( $criteria as $op => $exp ) {
if ( $op == 'or' || $op == 'and' ) {
$method = " { $op } X " ;
2021-04-16 16:55:50 +01:00
$expr = self :: buildExpression ( $eb , $exp );
2021-10-10 09:26:18 +01:00
if ( \is_array ( $expr )) {
2021-05-11 22:04:15 +01:00
$expressions [] = $eb -> { $method }( ... $expr );
2021-04-16 16:55:50 +01:00
}
2020-07-21 22:32:21 +01:00
} elseif ( $op == 'is_null' ) {
$expressions [] = $eb -> isNull ( $exp );
} else {
2021-10-10 09:26:18 +01:00
if ( \in_array ( $op , self :: $find_by_ops )) {
2021-05-11 22:04:15 +01:00
foreach ( $exp as $field => $value ) {
$expressions [] = $eb -> { $op }( $field , $value );
}
2020-07-21 22:32:21 +01:00
} else {
$expressions [] = $eb -> eq ( $op , $exp );
}
}
}
return $expressions ;
}
2020-11-06 19:47:15 +00:00
/**
* Query $table according to $criteria . If $criteria ' s keys are
* one of self :: $find_by_ops ( and , or , etc ), build a subexpression
* with that operator and recurse . Examples of $criteria are
* `['and' => ['lt' => ['foo' => 4], 'gte' => ['bar' => 2]]]` or
* `['in' => ['foo', 'bar']]`
*/
2021-11-26 23:30:20 +00:00
public static function findBy ( string $table , array $criteria , ? array $order_by = null , ? int $limit = null , ? int $offset = null ) : array
2020-07-21 22:32:21 +01:00
{
2021-10-10 09:26:18 +01:00
$criteria = array_change_key_case ( $criteria , \CASE_LOWER );
2020-07-21 22:32:21 +01:00
$ops = array_intersect ( array_keys ( $criteria ), self :: $find_by_ops );
2021-09-06 23:47:28 +01:00
/** @var EntityRepository */
$repo = self :: getRepository ( $table );
2020-07-21 22:32:21 +01:00
if ( empty ( $ops )) {
2021-11-26 23:30:20 +00:00
return $repo -> findBy ( $criteria , $order_by , $limit , $offset );
2020-07-21 22:32:21 +01:00
} else {
2021-05-11 22:04:15 +01:00
$eb = Criteria :: expr ();
2021-11-26 23:30:20 +00:00
$criteria = new Criteria ( $eb -> andX ( ... self :: buildExpression ( $eb , $criteria )), $order_by , $offset , $limit );
2020-07-25 18:49:57 +01:00
return $repo -> matching ( $criteria ) -> toArray (); // Always work with array or it becomes really complicated
2020-07-21 22:32:21 +01:00
}
}
2020-11-06 19:47:15 +00:00
/**
* Return the first element of the result of @ see self :: findBy
*/
2021-11-26 23:30:20 +00:00
public static function findOneBy ( string $table , array $criteria , ? array $order_by = null , ? int $offset = null )
2020-07-21 22:32:21 +01:00
{
2021-11-26 23:30:20 +00:00
$res = self :: findBy ( $table , $criteria , $order_by , 2 , $offset ); // Use limit 2 to check for consistency
2021-10-10 09:26:18 +01:00
switch ( \count ( $res )) {
2021-07-28 22:09:12 +01:00
case 0 :
throw new NotFoundException ( " No value in table { $table } matches the requested criteria " );
case 1 :
2020-07-25 18:49:57 +01:00
return $res [ 0 ];
2021-07-28 22:09:12 +01:00
default :
throw new DuplicateFoundException ( " Multiple values in table { $table } match the requested criteria " );
2020-07-25 02:55:39 +01:00
}
2020-07-21 22:32:21 +01:00
}
2021-12-05 17:50:15 +00:00
public static function removeBy ( string $table , array $criteria ) : void
2021-11-28 12:13:30 +00:00
{
2021-12-05 17:50:15 +00:00
$class = self :: $table_map [ $table ];
if ( empty ( array_intersect ( self :: getPKForClass ( $class ), array_keys ( $criteria )))) {
self :: remove ( self :: findOneBy ( $class , $criteria ));
} else {
self :: remove ( self :: getReference ( $table , $criteria ));
}
2021-11-28 12:13:30 +00:00
}
2021-04-11 12:03:32 +01:00
public static function count ( string $table , array $criteria )
{
2021-09-06 23:47:28 +01:00
/** @var EntityRepository */
2021-04-11 12:03:32 +01:00
$repo = self :: getRepository ( $table );
2021-05-11 22:04:15 +01:00
return $repo -> count ( $criteria );
2021-04-11 12:03:32 +01:00
}
2021-04-23 13:54:25 +01:00
/**
* Insert all given objects with the generated ID of the first one
*/
2021-10-10 09:26:18 +01:00
public static function persistWithSameId ( object $owner , object | array $others , ? callable $extra = null )
2021-04-23 13:54:25 +01:00
{
$conn = self :: getConnection ();
2021-10-10 09:26:18 +01:00
$metadata = self :: getClassMetadata ( \get_class ( $owner ));
2021-04-23 13:54:25 +01:00
$seqName = $metadata -> getSequenceName ( $conn -> getDatabasePlatform ());
self :: persist ( $owner );
2021-10-10 09:26:18 +01:00
$id = ( int ) $conn -> lastInsertId ( $seqName );
2021-10-10 09:26:18 +01:00
F\map ( \is_array ( $others ) ? $others : [ $others ], function ( $o ) use ( $id ) { $o -> setId ( $id ); self :: persist ( $o ); });
if ( ! \is_null ( $extra )) {
2021-04-23 13:54:25 +01:00
$extra ( $id );
}
self :: flush ();
return $id ;
}
2020-11-06 19:47:15 +00:00
/**
* Intercept static function calls to allow refering to entities
* without writing the namespace ( which is deduced from the call
* context )
*/
2020-05-14 22:55:04 +01:00
public static function __callStatic ( string $name , array $args )
2020-05-13 13:10:24 +01:00
{
2021-09-09 08:40:35 +01:00
if ( isset ( $args [ 0 ])) {
$args [ 0 ] = self :: filterTableName ( $name , $args );
}
2021-09-06 23:47:28 +01:00
return self :: $em -> { $name }( ... $args );
}
public const METHODS_ACCEPTING_TABLE_NAME = [ 'find' , 'getReference' , 'getPartialReference' , 'getRepository' ];
2021-09-09 08:40:35 +01:00
/**
* For methods in METHODS_ACCEPTING_TABLE_NAME , replace the first argument
*/
2021-09-06 23:47:28 +01:00
public static function filterTableName ( string $method , array $args ) : mixed
{
2021-10-10 09:26:18 +01:00
if ( \in_array ( $method , self :: METHODS_ACCEPTING_TABLE_NAME )
&& \is_string ( $args [ 0 ]) && \array_key_exists ( $args [ 0 ], self :: $table_map )) {
2021-09-06 23:47:28 +01:00
return self :: $table_map [ $args [ 0 ]];
} else {
return $args [ 0 ];
2020-08-13 02:23:22 +01:00
}
2020-05-13 13:10:24 +01:00
}
}