<?php

namespace Mautic\CoreBundle\Entity;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Doctrine\DBAL\Query\QueryBuilder as DbalQueryBuilder;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Cache\ResultCacheHelper;
use Mautic\CoreBundle\Cache\ResultCacheOptions;
use Mautic\CoreBundle\Doctrine\Paginator\SimplePaginator;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\SearchStringHelper;
use Mautic\UserBundle\Entity\User;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * @template T of object
 *
 * @extends ServiceEntityRepository<T>
 */
class CommonRepository extends ServiceEntityRepository
{
    /**
     * @phpstan-param class-string<T>|null $entityFQCN
     */
    public function __construct(ManagerRegistry $registry, string $entityFQCN = null)
    {
        parent::__construct($registry, $entityFQCN ?? str_replace('Repository', '', static::class));
    }

    /**
     * Stores the parsed columns and their negate status for addAdvancedSearchWhereClause().
     *
     * @var array
     */
    protected $advancedFilterCommands = [];

    /**
     * @var User|null
     */
    protected $currentUser;

    /**
     * @var TranslatorInterface
     */
    protected $translator;

    /**
     * This eliminates chance for parameter name collision.
     *
     * @see CommonRepository::generateRandomParameterName()
     *
     * @var int
     */
    protected $lastUsedParameterId = 0;

    /**
     * @var ExpressionBuilder|null
     */
    private $expressionBuilder;

    /**
     * @param string $alias
     * @param object $entity
     *
     * @return mixed
     */
    public function checkUniqueAlias($alias, $entity = null)
    {
        $q = $this->createQueryBuilder('e')
                  ->select('count(e.id) as aliascount')
                  ->where('e.alias = :alias');
        $q->setParameter('alias', $alias);

        if (!empty($entity) && $entity->getId()) {
            $q->andWhere('e.id != :id');
            $q->setParameter('id', $entity->getId());
        }

        $results = $q->getQuery()->getSingleResult();

        return $results['aliascount'];
    }

    /**
     * Examines the arguments passed to getEntities and converts ORM properties to dBAL column names.
     *
     * @param string $entityClass
     */
    public function convertOrmProperties($entityClass, array $args): array
    {
        $properties = $this->getBaseColumns($entityClass);

        // check force filters
        if (isset($args['filter']['force']) && is_array($args['filter']['force'])) {
            $this->convertOrmPropertiesToColumns($args['filter']['force'], $properties);
        }

        if (isset($args['filter']['where']) && is_array($args['filter']['where'])) {
            $this->convertOrmPropertiesToColumns($args['filter']['where'], $properties);
        }

        // check order by
        if (isset($args['order'])) {
            if (is_array($args['order'])) {
                foreach ($args['order'] as &$o) {
                    $alias = '';
                    if (str_contains($o, '.')) {
                        [$alias, $o] = explode('.', $o);
                    }

                    if (in_array($o, $properties)) {
                        $o = preg_replace('/(?<=\\w)(?=[A-Z])/', '_$1', $o);
                        $o = strtolower($o);
                    }

                    $o = (!empty($alias)) ? $alias.'.'.$o : $o;
                }
            }
        }

        return $args;
    }

    /**
     * @param class-string $className
     *
     * @return mixed
     *
     * @throws \Doctrine\ORM\Mapping\MappingException
     * @throws \Exception
     */
    public function createFromArray($className, &$data)
    {
        $entity        = new $className();
        $meta          = $this->_em->getClassMetadata($className);
        $ormProperties = $this->getBaseColumns($className, true);

        foreach ($ormProperties as $property => $dbCol) {
            if (isset($data[$dbCol])) {
                $v = $data[$dbCol];

                if ($v && $meta->hasAssociation($property)) {
                    $map = $meta->getAssociationMapping($property);
                    $v   = $this->_em->getRepository($map['targetEntity'])->find($v);
                    if (empty($v)) {
                        throw new \Exception('Associate data not found');
                    }
                }

                $method = 'set'.ucfirst($property);
                if (method_exists($entity, $method)) {
                    $entity->$method($v);
                }

                unset($data[$dbCol]);
            }
        }

        return $entity;
    }

    /**
     * Delete an array of entities.
     *
     * @param array $entities
     */
    public function deleteEntities($entities): void
    {
        // iterate over the results so the events are dispatched on each delete
        $batchSize = 20;
        $i         = 0;
        foreach ($entities as $entity) {
            $this->deleteEntity($entity, false);

            if (0 === ++$i % $batchSize) {
                $this->_em->flush();
            }
        }
        $this->_em->flush();
    }

    /**
     * Delete an entity through the repository.
     *
     * @param object $entity
     * @param bool   $flush  true by default; use false if persisting in batches
     */
    public function deleteEntity($entity, $flush = true): void
    {
        // delete entity
        $this->_em->remove($entity);

        if ($flush) {
            $this->_em->flush();
        }
    }

    public function detachEntities(array $entities): void
    {
        foreach ($entities as $entity) {
            $this->getEntityManager()->detach($entity);
        }
    }

    /**
     * @param mixed $entity
     */
    public function detachEntity($entity): void
    {
        $this->getEntityManager()->detach($entity);
    }

    public function refetchEntity(object &$entity): void
    {
        if ($this->getEntityManager()->contains($entity)) {
            $this->getEntityManager()->detach($entity);

            $metadata         = $this->getEntityManager()->getClassMetadata(ClassUtils::getClass($entity));
            $identifierValues = $metadata->getIdentifierValues($entity);
            if (count($identifierValues) > 1) {
                throw new \RuntimeException('Multiple identifiers are not supported.');
            }

            $entity = $this->getEntity(array_pop($identifierValues));
        }
    }

    /**
     * @return mixed|null
     */
    public function findOneBySlugs($alias, $catAlias = null, $lang = null)
    {
        try {
            $q = $this->createQueryBuilder($this->getTableAlias())
                      ->setParameter('alias', $alias);

            $expr = $q->expr()->andX(
                $q->expr()->eq($this->getTableAlias().'.alias', ':alias')
            );

            $metadata = $this->getClassMetadata();

            if (null !== $catAlias) {
                if (isset($metadata->associationMappings['category'])) {
                    $q->leftJoin($this->getTableAlias().'.category', 'category')
                      ->setParameter('catAlias', $catAlias);

                    $expr->add(
                        $q->expr()->eq('category.alias', ':catAlias')
                    );
                } else {
                    // This entity does not have a category mapping so return null

                    return null;
                }
            }

            if (isset($metadata->fieldMappings['language'])) {
                if ($lang) {
                    // Find the landing page with the specific requested locale
                    $q->setParameter('lang', $lang);

                    $expr->add(
                        $q->expr()->eq($this->getTableAlias().'.language', ':lang')
                    );
                } elseif (isset($metadata->associationMappings['translationParent'])) {
                    // Find the parent translation
                    $expr->add(
                        $q->expr()->isNull($this->getTableAlias().'.translationParent')
                    );
                }
            }

            // Check for variants and return parent only
            if (isset($metadata->associationMappings['variantParent'])) {
                $expr->add(
                    $q->expr()->isNull($this->getTableAlias().'.variantParent')
                );
            }

            $q->where($expr);

            $entity = $q->getQuery()->getSingleResult();
        } catch (\Exception) {
            $entity = null;
        }

        return $entity;
    }

    /**
     * Gets the properties of an ORM entity.
     *
     * @param string $entityClass
     * @param bool   $returnColumnNames
     *
     * @return array
     */
    public function getBaseColumns($entityClass, $returnColumnNames = false)
    {
        static $baseCols = [true => [], false => []];

        if ($this->getEntityName() === $entityClass) {
            if (empty($baseCols[$returnColumnNames][$entityClass])) {
                // Use metadata
                $metadata                      = $this->getClassMetadata();
                $baseCols[true][$entityClass]  = $metadata->getColumnNames();
                $baseCols[false][$entityClass] = $metadata->getFieldNames();

                foreach ($metadata->getAssociationMappings() as $field => $association) {
                    if (in_array($association['type'], [ClassMetadataInfo::ONE_TO_ONE, ClassMetadataInfo::MANY_TO_ONE])) {
                        $baseCols[true][$entityClass][]  = $association['joinColumns'][0]['name'];
                        $baseCols[false][$entityClass][] = $field;
                    }
                }
            }

            return $baseCols[$returnColumnNames][$entityClass];
        }

        return $this->getEntityManager()->getRepository($entityClass)->getBaseColumns($entityClass, $returnColumnNames);
    }

    /**
     * Get a list of entities.
     *
     * @param array<string,mixed> $args
     *
     * @return object[]|array<int,mixed>|iterable<object>|\Doctrine\ORM\Internal\Hydration\IterableResult<object>|Paginator<object>|SimplePaginator<mixed>
     */
    public function getEntities(array $args = [])
    {
        $alias = $this->getTableAlias();

        if (isset($args['qb'])) {
            $q = $args['qb'];
        } else {
            $q = $this->_em
                ->createQueryBuilder()
                ->select($alias)
                ->from($this->getEntityName(), $alias, "{$alias}.id");

            if ($this->getClassMetadata()->hasAssociation('category')) {
                $q->leftJoin($this->getTableAlias().'.category', 'cat');
            }
        }

        $this->buildClauses($q, $args);
        $query = $q->getQuery();

        if (isset($args['result_cache'])) {
            if (!$args['result_cache'] instanceof ResultCacheOptions) {
                throw new \InvalidArgumentException(sprintf('The value of the key "result_cache" must be an instance of "%s"', ResultCacheOptions::class));
            }

            ResultCacheHelper::enableOrmQueryCache($query, $args['result_cache']);
        }

        if (isset($args['hydration_mode'])) {
            $hydrationMode = constant('\\Doctrine\\ORM\\Query::'.strtoupper($args['hydration_mode']));
            $query->setHydrationMode($hydrationMode);
        } else {
            $hydrationMode = Query::HYDRATE_OBJECT;
        }

        if (array_key_exists('iterable_mode', $args) && true === $args['iterable_mode']) {
            // Hydrate one by one
            return $query->toIterable([], $hydrationMode);
        }

        if (!empty($args['iterator_mode'])) {
            // When you remove the following, please search for the "iterator_mode" in the project.
            @\trigger_error('Using "iterator_mode" is deprecated. Use "iterable_mode" instead. Usage of "iterator_mode" will be removed in 6.0.', \E_USER_DEPRECATED);

            return $query->iterate(null, $hydrationMode);
        } elseif (empty($args['ignore_paginator'])) {
            if (!empty($args['use_simple_paginator'])) {
                // FAST paginator that can handle only simple queries using no joins or ManyToOne joins.
                return new SimplePaginator($query);
            } else {
                // SLOW paginator that can handle complex queries using oneToMany/ManyToMany joins.
                return new Paginator($query, false);
            }
        } else {
            // All results
            return $query->getResult($hydrationMode);
        }
    }

    /**
     * Get a single entity.
     *
     * @param int $id
     */
    public function getEntity($id = 0): ?object
    {
        try {
            if (is_array($id)) {
                $q = $this->createQueryBuilder($this->getTableAlias());
                $this->buildSelectClause($q, $id['select']);
                $q->where($this->getTableAlias().'.id = :id')
                ->setParameter('id', (int) $id['id']);
                $entity = $q->getQuery()->getSingleResult();
            } else {
                $entity = $this->find((int) $id);
            }
        } catch (\Exception) {
            $entity = null;
        }

        return $entity;
    }

    /**
     * @return ExpressionBuilder
     */
    public function getExpressionBuilder()
    {
        if (null === $this->expressionBuilder) {
            $this->expressionBuilder = new ExpressionBuilder();
        }

        return $this->expressionBuilder;
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $q
     * @param array<mixed>                  $filter
     */
    public function getFilterExpr($q, array $filter, ?string $unique = null): array
    {
        $unique    = ($unique) ?: $this->generateRandomParameterName();
        $parameter = [];

        if (isset($filter['group'])) {
            $expr = $q->expr()->orX();
            foreach ($filter['group'] as $orGroup) {
                $groupExpr = $q->expr()->andX();
                foreach ($orGroup as $subFilter) {
                    [$subExpr, $subParameters] = $this->getFilterExpr($q, $subFilter);

                    $groupExpr->add($subExpr);
                    if (!empty($subParameters)) {
                        $parameter = array_merge($parameter, $subParameters);
                    }
                }
                $expr->add($groupExpr);
            }
        } elseif (str_contains($filter['column'], ',')) {
            $columns      = explode(',', $filter['column']);
            $expr         = $q->expr()->orX();
            $setParameter = false;
            foreach ($columns as $c) {
                $subFilter           = $filter;
                $subFilter['column'] = trim($c);

                [$subExpr, $parameterUsed] = $this->getFilterExpr($q, $subFilter, $unique);

                if ($parameterUsed) {
                    $setParameter = true;
                }

                $expr->add($subExpr);
            }
            if ($setParameter) {
                $parameter = [$unique => $filter['value']];
            }
        } else {
            $func = (!empty($filter['operator'])) ? $filter['operator'] : $filter['expr'];

            if (in_array($func, ['isNull', 'isNotNull'])) {
                $expr = $q->expr()->{$func}($filter['column']);
            } elseif (in_array($func, ['in', 'notIn'])) {
                $expr = $q->expr()->{$func}($filter['column'], ':'.$unique);
                $q->setParameter($unique, $filter['value'], ArrayParameterType::STRING);
            } elseif (in_array($func, ['like', 'notLike'])) {
                if (isset($filter['strict']) && !$filter['strict']) {
                    if (is_numeric($filter['value'])) {
                        // Postgres doesn't like using "LIKE" with numbers
                        $func = ('like' == $func) ? 'eq' : 'neq';
                    } else {
                        $filter['value'] = "%{$filter['value']}%";
                    }
                }
                $expr      = $q->expr()->{$func}($filter['column'], ':'.$unique);
                $parameter = [$unique => $filter['value']];
            } else {
                if (isset($filter['strict']) && !$filter['strict']) {
                    $filter['value'] = "%{$filter['value']}%";
                }
                $expr      = $q->expr()->{$func}($filter['column'], ':'.$unique);
                $parameter = [$unique => $filter['value']];
            }
            if (!empty($filter['not'])) {
                $expr = $q->expr()->not($expr);
            }
        }

        return [$expr, $parameter];
    }

    /**
     * Returns a andX Expr() that takes into account isPublished, publishUp and publishDown dates
     * The Expr() sets a :now and :true parameter that must be set in the calling function.
     *
     * @param string|null $alias
     * @param bool        $setNowParameter
     * @param bool        $setTrueParameter
     * @param bool        $allowNullForPublishedUp Allow entities without a published up date
     *
     * @return mixed
     */
    public function getPublishedByDateExpression(
        $q,
        $alias = null,
        $setNowParameter = true,
        $setTrueParameter = true,
        $allowNullForPublishedUp = true
    ) {
        $isORM = $q instanceof QueryBuilder;

        if (null === $alias) {
            $alias = $this->getTableAlias();
        }

        if ($setNowParameter) {
            $now = new \DateTime();
            if (!$isORM) {
                $dtHelper = new DateTimeHelper($now, 'Y-m-d H:i:s', 'local');
                $now      = $dtHelper->toUtcString();
            }
            $q->setParameter('now', $now);
        }

        if ($setTrueParameter) {
            $q->setParameter('true', true, 'boolean');
        }

        if ($isORM) {
            $pub     = 'isPublished';
            $pubUp   = 'publishUp';
            $pubDown = 'publishDown';
        } else {
            $pub     = 'is_published';
            $pubUp   = 'publish_up';
            $pubDown = 'publish_down';
        }

        $expr = $q->expr()->andX(
            $q->expr()->eq("$alias.$pub", ':true'),
            $q->expr()->orX(
                $q->expr()->isNull("$alias.$pubDown"),
                $q->expr()->gte("$alias.$pubDown", ':now')
            )
        );

        if ($allowNullForPublishedUp) {
            $expr->add(
                $q->expr()->orX(
                    $q->expr()->isNull("$alias.$pubUp"),
                    $q->expr()->lte("$alias.$pubUp", ':now')
                )
            );
        } else {
            $expr->add(
                $q->expr()->andX(
                    $q->expr()->isNotNull("$alias.$pubUp"),
                    $q->expr()->lte("$alias.$pubUp", ':now')
                )
            );
        }

        return $expr;
    }

    /**
     * Get an array of rows from one table using DBAL.
     *
     * @param int $start
     * @param int $limit
     */
    public function getRows($start = 0, $limit = 100, array $order = [], array $where = [], array $select = null, array $allowedJoins = []): array
    {
        $alias    = $this->getTableAlias();
        $metadata = $this->getClassMetadata();
        $table    = $metadata->getTableName();
        $q        = $this->_em->getConnection()->createQueryBuilder();

        $q->select('count(*)')
            ->from($table, $alias);

        // Join associations for permission filtering
        $this->buildDbalJoinsFromAssociations($q, $metadata->getAssociationMappings(), $alias, $allowedJoins);

        $this->buildWhereClauseFromArray($q, $where);

        $count = $q->executeQuery()->fetchOne();

        if ($select) {
            foreach ($select as &$column) {
                if (!str_contains($column, '.')) {
                    $column = $alias.'.'.$column;
                }
            }
            $selectString = implode(', ', $select);
        } else {
            $selectString = $alias.'.*';
        }

        $q->resetQueryPart('select')
          ->select($selectString)
          ->setFirstResult($start)
          ->setMaxResults($limit);

        $this->buildOrderByClauseFromArray($q, $order);

        $results = $q->executeQuery()->fetchAllAssociative();

        return [
            'total'   => $count,
            'results' => $results,
        ];
    }

    /**
     * Returns a single value for a single row.
     *
     * @param int    $id
     * @param string $column
     *
     * @return string|null
     */
    public function getValue($id, $column)
    {
        $q = $this->_em->getConnection()->createQueryBuilder();
        $q->select($this->getTableAlias().'.'.$column)
            ->from($this->getClassMetadata()->getTableName(), $this->getTableAlias())
            ->where($this->getTableAlias().'.id = :id')
            ->setParameter('id', $id);

        $result = $q->executeQuery()->fetchAssociative();

        return $result[$column] ?? null;
    }

    /**
     * Array of search commands supported by the repository.
     *
     * @return string[]|array<string, string[]>
     */
    public function getSearchCommands(): array
    {
        return ['mautic.core.searchcommand.ids'];
    }

    /**
     * Gets a list of published entities as an array id => label.
     *
     * @param array  $parameters   Parameters used in $expr
     * @param string $labelColumn  Column that houses the label
     * @param string $valueColumn  Column that houses the value
     * @param string $extraColumns String of extra select columns
     * @param int    $limit        Limit for results
     *
     * @return mixed[]
     */
    public function getSimpleList(CompositeExpression $expr = null, array $parameters = [], $labelColumn = null, $valueColumn = 'id', $extraColumns = null, $limit = 0): array
    {
        $q = $this->_em->getConnection()->createQueryBuilder();

        $alias = $prefix = $this->getTableAlias();
        if (!empty($prefix)) {
            $prefix .= '.';
        }

        $tableName = $this->_em->getClassMetadata($this->getEntityName())->getTableName();

        $class      = '\\'.$this->getClassName();
        $reflection = new \ReflectionClass(new $class());

        // Get the label column if necessary
        if (null == $labelColumn) {
            $labelColumn = $reflection->hasMethod('getTitle') ? 'title' : 'name';
        }

        $q->select($prefix.$valueColumn.' as value, '.$prefix.$labelColumn.' as label'.($extraColumns ? ", $extraColumns" : ''))
          ->from($tableName, $alias)
          ->orderBy($prefix.$labelColumn);

        if (null !== $expr && $expr->count()) {
            $q->where($expr);
        }

        foreach ($parameters as $key => $value) {
            $q->setParameter($key, $value, is_array($value) ? ArrayParameterType::STRING : null);
        }

        // Published only
        if ($reflection->hasMethod('getIsPublished')) {
            $q->andWhere(
                $q->expr()->eq($prefix.'is_published', ':true')
            )
              ->setParameter('true', true, 'boolean');
        }

        if ($limit) {
            $q->setMaxResults((int) $limit);
        }

        return $q->executeQuery()->fetchAllAssociative();
    }

    /**
     * @return string[]
     */
    public function getStandardSearchCommands(): array
    {
        return [
            'mautic.core.searchcommand.ispublished',
            'mautic.core.searchcommand.isunpublished',
            'mautic.core.searchcommand.isuncategorized',
            'mautic.core.searchcommand.ismine',
            'mautic.core.searchcommand.category',
            'mautic.core.searchcommand.ids',
        ];
    }

    /**
     * @return literal-string
     */
    public function getTableAlias(): string
    {
        return 'e';
    }

    /**
     * @return mixed[]
     */
    public function getTableColumns(): array
    {
        $columns = $this->getClassMetadata()->getColumnNames();

        if ($associations = $this->getClassMetadata()->getAssociationMappings()) {
            foreach ($associations as $association) {
                if (!empty($association['joinColumnFieldNames'])) {
                    $columns = array_merge($columns, array_values($association['joinColumnFieldNames']));
                }
            }
        }

        natcasesort($columns);

        return array_values($columns);
    }

    /**
     * Returns entity table name.
     *
     * @return string
     */
    public function getTableName()
    {
        return $this->getClassMetadata()->getTableName();
    }

    /**
     * Persist an array of entities.
     *
     * @param array|ArrayCollection $entities
     */
    public function saveEntities($entities): void
    {
        // iterate over the results so the events are dispatched on each delete
        $batchSize = 20;
        $i         = 0;

        foreach ($entities as $entity) {
            $this->saveEntity($entity, false);

            if (0 === ++$i % $batchSize) {
                $this->getEntityManager()->flush();
            }
        }
        $this->getEntityManager()->flush();
    }

    /**
     * Save an entity through the repository.
     *
     * @param object $entity
     * @param bool   $flush  true by default; use false if persisting in batches
     */
    public function saveEntity($entity, $flush = true): void
    {
        $this->getEntityManager()->persist($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    /**
     * Insert entity if it does not exist, update if it does.
     * ID is set to the enity after upsert.
     * Main reason to use this over fetch/save is to avoid race conditions.
     *
     * Warning: This method use DBAL, not ORM. It will save only the entity you send it.
     * It will NOT save the entity's associations. Entity manager won't know that the entity was flushed.
     */
    public function upsert(object $entity): void
    {
        $connection = $this->getEntityManager()->getConnection();
        $metadata   = $this->getClassMetadata();
        $identifier = $metadata->getSingleIdentifierFieldName();
        $makeUpdate = fn (string $column) => "{$column} = VALUES({$column})";
        $columns    = [];
        $values     = [];
        $types      = [];
        $set        = [];
        $update     = [];
        $hasId      = $metadata->containsForeignIdentifier;

        foreach ($metadata->getFieldNames() as $fieldName) {
            $value = $metadata->getFieldValue($entity, $fieldName);
            if ($metadata->isIdentifier($fieldName)) {
                if ($value) {
                    $hasId = true;
                } elseif ($fieldName === $identifier) {
                    // https://bugs.php.net/bug.php?id=76896
                    // mysql_last_insert_id might return 0 if our insert updates a row
                    // Call LAST_INSERT_ID() for the column to ensure the correct value
                    $column   = $metadata->getColumnName($fieldName);
                    $update[] = "{$column} = LAST_INSERT_ID({$column})";
                    continue;
                } else {
                    continue;
                }
            }
            $column    = $metadata->getColumnName($fieldName);
            $columns[] = $column;
            $values[]  = $value;
            $types[]   = $metadata->getTypeOfField($fieldName);
            $set[]     = '?';
            $update[]  = $makeUpdate($column);
        }

        foreach ($metadata->getAssociationNames() as $fieldName) {
            $assocEntity = $metadata->getFieldValue($entity, $fieldName);
            if (!$metadata->isAssociationWithSingleJoinColumn($fieldName) || !is_object($assocEntity)) {
                continue;
            }
            $idCol     = ucfirst($metadata->getSingleAssociationReferencedJoinColumnName($fieldName));
            $idGetter  = "get{$idCol}";
            $column    = $metadata->getSingleAssociationJoinColumnName($fieldName);
            $columns[] = $column;
            $values[]  = $assocEntity->$idGetter();
            $types[]   = Types::STRING;
            $set[]     = '?';
            $update[]  = $makeUpdate($column);
        }

        $numberOfRowsAffected = $connection->executeStatement(
            'INSERT INTO '.$this->getTableName().' ('.implode(', ', $columns).')'.
            ' VALUES ('.implode(', ', $set).')'.
            ' ON DUPLICATE KEY UPDATE '.implode(', ', $update),
            $values,
            $types
        );

        if ($entity instanceof UpsertInterface) {
            $entity->setHasBeenInserted(UpsertInterface::ROWS_AFFECTED_ON_INSERT === $numberOfRowsAffected);
            $entity->setHasBeenUpdated(UpsertInterface::ROWS_AFFECTED_ON_UPDATE === $numberOfRowsAffected);
        }
        if ($hasId) {
            return;
        }

        $id = (int) $connection->lastInsertId();

        $metadata->setFieldValue($entity, $identifier, $id);
    }

    /**
     * Set the current user (i.e. from security context) for use within repositories.
     *
     * @param User $user
     */
    public function setCurrentUser($user): void
    {
        if (!$user instanceof User) {
            // just create a blank user entity
            $user = new User();
        }
        $this->currentUser = $user;
    }

    public function setTranslator(TranslatorInterface $translator): void
    {
        $this->translator = $translator;
    }

    /**
     * Validate array for one order by condition.
     *
     * @param array $clause ['col' => 'column_a', 'dir' => 'ASC']
     *
     * @return array
     *
     * @throws \InvalidArgumentException
     */
    protected function validateOrderByClause($clause)
    {
        $msg = '"%s" is missing in the order by clause array.';
        if (empty($clause['col'])) {
            throw new \InvalidArgumentException(sprintf($msg, 'col'));
        }

        if (empty($clause['dir'])) {
            $clause['dir'] = 'ASC';
        }

        $clause['dir'] = $this->sanitize(strtoupper($clause['dir']));
        $clause['col'] = $this->sanitize($clause['col'], ['_.']);

        return $clause;
    }

    /**
     * Validate the array for one where condition.
     *
     * @param array $clause ['expr' => 'expression', 'col' => 'DB column', 'val' => 'value to search for']
     *
     * @throws \InvalidArgumentException
     */
    protected function validateWhereClause(array $clause): array
    {
        $msg = '"%s" is missing in the where clause array.';
        if (empty($clause['expr'])) {
            throw new \InvalidArgumentException(sprintf($msg, 'expr'));
        }

        if (empty($clause['col']) && empty($clause['column'])) {
            throw new \InvalidArgumentException(sprintf($msg, 'col'));
        }

        if (!array_key_exists('val', $clause) && !array_key_exists('value', $clause)) {
            $clause['val'] = '';
        }

        $clause['expr'] = $this->sanitize($clause['expr']);
        $clause['col']  = $this->sanitize($clause['column'] ?? $clause['col'], ['_', '.']);
        if (isset($clause['value'])) {
            $clause['val'] = $clause['value'];
        }
        unset($clause['value'], $clause['column']);

        // Value will be sanitized by Doctrine

        return $clause;
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $qb
     * @param \StdClass|mixed[]             $filters
     *
     * @return mixed[]
     */
    protected function addAdvancedSearchWhereClause($qb, $filters): array
    {
        $parseFilters = [];
        if (isset($filters->root[0])) {
            // Function is determined by the second clause type
            $type         = (isset($filters->root[1])) ? $filters->root[1]->type : $filters->root[0]->type;
            $parseFilters = &$filters->root;
        } elseif (isset($filters->children[0])) {
            $type         = (isset($filters->children[1])) ? $filters->children[1]->type : $filters->children[0]->type;
            $parseFilters = &$filters->children;
        } elseif (is_array($filters)) {
            $type         = (isset($filters[1])) ? $filters[1]->type : $filters[0]->type;
            $parseFilters = &$filters;
        }

        if (empty($type)) {
            $type = 'and';
        }

        $parameters  = [];
        $expressions = $qb->expr()->{"{$type}X"}();

        if ($parseFilters) {
            $this->parseSearchFilters($parseFilters, $qb, $expressions, $parameters);
        }

        return [$expressions, $parameters];
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $qb
     * @param \StdClass                     $filter
     */
    protected function addCatchAllWhereClause($qb, $filter): array
    {
        foreach (['name', 'title'] as $column) {
            if ($this->getClassMetadata()->hasField($column)) {
                return $this->addStandardCatchAllWhereClause(
                    $qb,
                    $filter,
                    [
                        $this->getTableAlias().'.'.$column,
                    ]
                );
            }
        }

        return [
            false,
            [],
        ];
    }

    /**
     * Unique handling for $filter->not since dbal does not support the not() function with it's QueryBuilder.
     *
     * @param QueryBuilder $q
     * @param object       $filter
     */
    protected function addDbalCatchAllWhereClause(&$q, $filter, array $columns): array
    {
        $unique = $this->generateRandomParameterName(); // ensure that the string has a unique parameter identifier
        $string = ($filter->strict) ? $filter->string : "{$filter->string}";
        if ($filter->not) {
            $xFunc    = 'andX';
            $exprFunc = 'notLike';
        } else {
            $xFunc    = 'orX';
            $exprFunc = 'like';
        }
        $expr = $q->expr()->$xFunc();

        foreach ($columns as $column) {
            $expr->add(
                $q->expr()->$exprFunc($column, ":$unique")
            );
        }

        return [
            $expr,
            ["$unique" => $string],
        ];
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $q
     * @param \StdClass                     $filter
     */
    protected function addSearchCommandWhereClause($q, $filter): array
    {
        $command = $filter->command;
        $expr    = false;

        switch ($command) {
            case $this->translator->trans('mautic.core.searchcommand.ids'):
            case $this->translator->trans('mautic.core.searchcommand.ids', [], null, 'en_US'):
                $expr = $this->getIdsExpr($q, $filter);
                break;
        }

        return [
            $expr,
            [],
        ];
    }

    /**
     * @param QueryBuilder $q
     * @param object       $filter
     */
    protected function addStandardCatchAllWhereClause(&$q, $filter, array $columns): array
    {
        $unique = $this->generateRandomParameterName(); // ensure that the string has a unique parameter identifier
        $string = $filter->string;

        if (!$filter->strict) {
            if (!str_contains($string, '%')) {
                $string = "%$string%";
            }
        }

        $ormQb = true;

        if ($q instanceof QueryBuilder) {
            $xFunc    = 'orX';
            $exprFunc = 'like';
        } else {
            $ormQb = false;
            if ($filter->not) {
                $xFunc    = 'andX';
                $exprFunc = 'notLike';
            } else {
                $xFunc    = 'orX';
                $exprFunc = 'like';
            }
        }

        $expr = $q->expr()->$xFunc();
        foreach ($columns as $col) {
            $expr->add(
                $q->expr()->$exprFunc($col, ":$unique")
            );
        }

        if ($ormQb && $filter->not) {
            $expr = $q->expr()->not($expr);
        }

        return [
            $expr,
            ["$unique" => $string],
        ];
    }

    /**
     * @param DbalQueryBuilder|QueryBuilder $q
     * @param \StdClass                     $filter
     */
    protected function addStandardSearchCommandWhereClause(&$q, $filter): array
    {
        $command         = $filter->command;
        $unique          = $this->generateRandomParameterName();
        $returnParameter = true; // returning a parameter that is not used will lead to a Doctrine error
        $expr            = false;
        $prefix          = $this->getTableAlias();
        $isDbalQB        = $q instanceof DbalQueryBuilder;

        switch ($command) {
            case $this->translator->trans('mautic.core.searchcommand.ispublished'):
            case $this->translator->trans('mautic.core.searchcommand.ispublished', [], null, 'en_US'):
                $column          = $isDbalQB ? 'is_published' : 'isPublished';
                $expr            = $q->expr()->eq("{$prefix}.{$column}", ":{$unique}");
                $forceParameters = [$unique => true];
                break;
            case $this->translator->trans('mautic.core.searchcommand.isunpublished'):
            case $this->translator->trans('mautic.core.searchcommand.isunpublished', [], null, 'en_US'):
                $column          = $isDbalQB ? 'is_published' : 'isPublished';
                $expr            = $q->expr()->eq("{$prefix}.{$column}", ":{$unique}");
                $forceParameters = [$unique => false];
                break;
            case $this->translator->trans('mautic.core.searchcommand.isuncategorized'):
            case $this->translator->trans('mautic.core.searchcommand.isuncategorized', [], null, 'en_US'):
                $expr = $q->expr()->orX(
                    $q->expr()->isNull("$prefix.category"),
                    $q->expr()->eq("$prefix.category", $q->expr()->literal(''))
                );
                $returnParameter = false;
                break;
            case $this->translator->trans('mautic.core.searchcommand.ismine'):
            case $this->translator->trans('mautic.core.searchcommand.ismine', [], null, 'en_US'):
                $column          = $isDbalQB ? 'created_by' : 'createdBy';
                $expr            = $q->expr()->eq("{$prefix}.{$column}", ":{$unique}");
                $forceParameters = [$unique => $this->currentUser->getId()];
                break;
            case $this->translator->trans('mautic.core.searchcommand.category'):
            case $this->translator->trans('mautic.core.searchcommand.category', [], null, 'en_US'):
                // Find the category prefix
                $joins     = $q->getDQLPart('join');
                $catPrefix = false;
                foreach ($joins as $joinStatements) {
                    /** @var Query\Expr\Join $join */
                    foreach ($joinStatements as $join) {
                        if (str_contains($join->getJoin(), '.category')) {
                            $catPrefix = $join->getAlias();
                            break;
                        }
                    }
                    if (false !== $catPrefix) {
                        break;
                    }
                }
                if (false === $catPrefix) {
                    $catPrefix = 'c';
                }
                $expr           = $q->expr()->like("{$catPrefix}.alias", ":{$unique}");
                $filter->strict = true;
                break;
            case $this->translator->trans('mautic.core.searchcommand.ids'):
            case $this->translator->trans('mautic.core.searchcommand.ids', [], null, 'en_US'):
                $expr            = $this->getIdsExpr($q, $filter);
                $returnParameter = false;
                break;
        }

        if ($expr && $filter->not) {
            $expr = $q->expr()->not($expr);
        }

        if (!empty($forceParameters)) {
            $parameters = $forceParameters;
        } elseif (!$returnParameter) {
            $parameters = [];
        } else {
            $string = $filter->string;
            if (!$filter->strict) {
                if (!str_contains($string, '%')) {
                    $string = "$string%";
                }
            }

            $parameters = ["$unique" => $string];
        }

        return [$expr, $parameters];
    }

    protected function appendExpression($appendTo, $expr)
    {
        if ($expr instanceof CompositeExpression || $expr instanceof Query\Expr\Composite) {
            if ($expr->count()) {
                $appendTo->add($expr);
            }
        } elseif (!empty($expr)) {
            $appendTo->add($expr);
        }
    }

    /**
     * @param QueryBuilder $q
     */
    protected function buildClauses($q, array $args): bool
    {
        $this->buildSelectClause($q, $args);
        $this->buildIndexByClause($q, $args);
        $this->buildWhereClause($q, $args);
        $this->buildOrderByClause($q, $args);
        $this->buildLimiterClauses($q, $args);

        return true;
    }

    protected function buildDbalJoinsFromAssociations(DbalQueryBuilder $q, $associations, $alias, array $allowed): bool
    {
        $joinAdded = false;
        foreach ($associations as $property => $association) {
            $subJoinAdded  = false;
            $targetMetdata = $this->_em->getRepository($association['targetEntity'])->getClassMetadata();
            if ($propertyAllowedJoins = preg_grep('/^'.$property.'\..*/', $allowed)) {
                foreach ($propertyAllowedJoins as $key => $join) {
                    $propertyAllowedJoins[$key] = str_replace($property.'.', '', $join);
                }

                $subJoinAdded = $this->buildDbalJoinsFromAssociations($q, $targetMetdata->getAssociationMappings(), $property, $propertyAllowedJoins);
            }

            if ($subJoinAdded || in_array($property, $allowed)) {
                // Unset the property so that it's not used again in other the next level
                unset($allowed[$property]);
                $targetTable = $targetMetdata->getTableName();
                $hasNullable = false;
                $joinColumns = [];
                foreach ($association['joinColumns'] as $join) {
                    if (!empty($join['nullable'])) {
                        $hasNullable = true;
                    }

                    $joinColumns[] = $alias.'.'.$join['name'].' = '.$property.'.'.$join['referencedColumnName'];
                }

                $joinType = ($hasNullable) ? 'leftJoin' : 'join';
                $q->$joinType($alias, $targetTable, $property, implode(' AND ', $joinColumns));
                $joinAdded = true;
            }
        }

        return $joinAdded;
    }

    protected function buildIndexByClause($q, array $args)
    {
        if (!empty($args['index_by'])) {
            if (is_array($args['index_by'])) {
                [$indexAlias, $indexBy] = $args['index_by'];
            } else {
                $indexAlias = $this->getTableAlias();
                $indexBy    = $args['index_by'];
            }
            if (!str_starts_with($indexBy, $indexAlias)) {
                $indexBy = $indexAlias.'.'.$indexBy;
            }
            $q->indexBy($indexAlias, $indexBy);
        }
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $q
     */
    protected function buildLimiterClauses($q, array $args): void
    {
        $start = array_key_exists('start', $args) ? $args['start'] : 0;
        $limit = array_key_exists('limit', $args) ? $args['limit'] : 0;

        if (!empty($limit)) {
            $q->setFirstResult($start)
              ->setMaxResults($limit);
        }
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $q
     */
    protected function buildOrderByClause($q, array $args): void
    {
        $orderBy = array_key_exists('orderBy', $args) ? $args['orderBy'] : '';

        if (!empty($args['filter']['order'])) {
            $this->buildOrderByClauseFromArray($q, $args['filter']['order']);
        } elseif (empty($orderBy)) {
            $defaultOrder = $this->getDefaultOrder();

            foreach ($defaultOrder as $order) {
                $q->addOrderBy($order[0], $order[1]);
            }
        } else {
            $orderByDir = $this->sanitize(
                array_key_exists('orderByDir', $args) ? $args['orderByDir'] : ''
            );
            // add direction after each column
            $parts = explode(',', $orderBy);
            foreach ($parts as $order) {
                $order = $this->sanitize($order, ['_', '.']);

                $q->addOrderBy($order, $orderByDir);
            }
        }
    }

    /**
     * Build order by from an array.
     *
     * @param QueryBuilder|DbalQueryBuilder $query
     * @param array                         $clauses [['col' => 'column_a', 'dir' => 'ASC']]
     *
     * @return array
     */
    protected function buildOrderByClauseFromArray($query, array $clauses)
    {
        if ($clauses && is_array($clauses)) {
            foreach ($clauses as $clause) {
                $clause = $this->validateOrderByClause($clause);
                $column = (!str_contains($clause['col'], '.')) ? $this->getTableAlias().'.'.$clause['col'] : $clause['col'];
                $query->addOrderBy($column, $clause['dir']);
            }
        }
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $q
     */
    protected function buildSelectClause($q, array $args)
    {
        $isOrm = $q instanceof QueryBuilder;
        if (isset($args['select'])) {
            // Build a custom select
            if (is_string($args['select'])) {
                $args['select'] = explode(',', $args['select']);
            }

            $selects = [];
            foreach ($args['select'] as $select) {
                if (str_contains($select, '.')) {
                    [$alias, $select] = explode('.', $select);
                } else {
                    $alias = $this->getTableAlias();
                }

                if (!isset($selects[$alias])) {
                    $selects[$alias] = [];
                }

                $selects[$alias][] = $select;
            }

            $partials    = [];
            $ormColumns  = $this->getBaseColumns($this->getClassName());
            $dbalColumns = $this->getTableColumns();
            foreach ($selects as $alias => $columns) {
                if ($isOrm) {
                    if ($columns = array_intersect($columns, $ormColumns)) {
                        $columns    = array_map([$this, 'sanitize'], $columns);
                        $partials[] = 'partial '.$alias.'.{'.implode(',', $columns).'}';
                    }
                } else {
                    if ($columns = array_intersect($columns, $dbalColumns)) {
                        foreach ($columns as $column) {
                            $partials[] = $alias.'.'.$this->sanitize($column);
                        }
                    }
                }
            }

            if ($partials) {
                $newSelect = implode(', ', $partials);
                $select    = ($isOrm) ? $q->getDQLPart('select') : $q->getQueryPart('select');
                if ($isOrm) {
                    $q->select($newSelect);
                } else {
                    if (!$select || $this->getTableAlias() === $select || $this->getTableAlias().'.*' === $select) {
                        $q->select($newSelect);
                    } elseif (is_string($select) && str_contains($select, $this->getTableAlias().',')) {
                        $q->select(str_replace($this->getTableAlias().',', $newSelect.',', $select));
                    } elseif (is_string($select) && str_contains($select, $this->getTableAlias().'.*,')) {
                        $q->select(str_replace($this->getTableAlias().'.*,', $newSelect.',', $select));
                    }
                }
            }
        }

        if ($isOrm) {
            if (!$q->getDQLPart('select')) {
                $q->select($this->getTableAlias());
            }
        } else {
            if (!$q->getQueryPart('select')) {
                $q->select($this->getTableAlias().'.*');
            }
        }
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $q
     */
    protected function buildWhereClause($q, array $args)
    {
        $filter                    = array_key_exists('filter', $args) ? $args['filter'] : '';
        $filterHelper              = new SearchStringHelper();
        $advancedFilters           = new \stdClass();
        $advancedFilters->root     = [];
        $advancedFilters->commands = [];
        // Reset advanced filter commands to be used in search query building
        $this->advancedFilterCommands = [];
        $advancedFilterStrings        = [];
        $queryParameters              = [];
        $queryExpression              = $q->expr()->andX();

        if (isset($args['ids'])) {
            $ids   = array_map('intval', $args['ids']);
            $param = $this->generateRandomParameterName();
            if ($q instanceof QueryBuilder) {
                $queryExpression->add(
                    $q->expr()->in($this->getTableAlias().'.id', ':'.$param)
                );
                $queryParameters[$param] = $ids;
            } else {
                $queryExpression->add(
                    $q->expr()->in($this->getTableAlias().'.id', ':'.$param)
                );
                $q->setParameter($param, $ids, ArrayParameterType::INTEGER);
            }
        } elseif (!empty($args['ownedBy'])) {
            $queryExpression->add(
                $q->expr()->in($this->getTableAlias().'.'.$args['ownedBy'][0], (string) $args['ownedBy'][1])
            );
        }

        if (!empty($filter)) {
            if (is_array($filter)) {
                if (!empty($filter['where'])) {
                    // build clauses from array
                    $this->buildWhereClauseFromArray($q, $filter['where']);
                } elseif (!empty($filter['criteria']) || !empty($filter['force'])) {
                    $criteria = !empty($filter['criteria']) ? $filter['criteria'] : $filter['force'];
                    if (is_array($criteria)) {
                        // defined columns with keys of column, expr, value
                        foreach ($criteria as $criterion) {
                            if ($criterion instanceof Query\Expr || $criterion instanceof CompositeExpression) {
                                $queryExpression->add($criterion);

                                if (isset($criterion->parameters) && is_array($criterion->parameters)) {
                                    $queryParameters = array_merge($queryParameters, $criterion->parameters);
                                    unset($criterion->parameters);
                                }
                            } elseif (is_array($criterion)) {
                                [$expr, $parameters] = $this->getFilterExpr($q, $criterion);
                                $queryExpression->add($expr);
                                if (is_array($parameters)) {
                                    $queryParameters = array_merge($queryParameters, $parameters);
                                }
                            } else {
                                // string so parse as advanced search
                                $advancedFilterStrings[] = $criterion;
                            }
                        }
                    } else {
                        // string so parse as advanced search
                        $advancedFilterStrings[] = $criteria;
                    }
                }

                if (!empty($filter['string'])) {
                    $advancedFilterStrings[] = $filter['string'];
                }
            } else {
                $advancedFilterStrings[] = $filter;
            }

            if (!empty($advancedFilterStrings)) {
                foreach ($advancedFilterStrings as $parseString) {
                    $parsed = $filterHelper->parseString($parseString);

                    $advancedFilters->root = array_merge($advancedFilters->root, $parsed->root);
                    $filterHelper->mergeCommands($advancedFilters, $parsed->commands);
                }
                $this->advancedFilterCommands = $advancedFilters->commands;

                [$expr, $parameters] = $this->addAdvancedSearchWhereClause($q, $advancedFilters);
                $this->appendExpression($queryExpression, $expr);

                if (is_array($parameters)) {
                    $queryParameters = array_merge($queryParameters, $parameters);
                }
            }
        }

        // parse the filter if set
        if ($queryExpression->count()) {
            $q->andWhere($queryExpression);
        }

        // Parameters have to be set even if there are no expressions just in case a search command
        // passed back a parameter it used
        foreach ($queryParameters as $k => $v) {
            if (true === $v || false === $v) {
                $q->setParameter($k, $v, 'boolean');
            } else {
                $q->setParameter($k, $v);
            }
        }
    }

    /**
     * @param QueryBuilder|DbalQueryBuilder $query
     * @param array                         $clauses [['expr' => 'expression', 'col' => 'DB column', 'val' => 'value to search for']]
     */
    protected function buildWhereClauseFromArray($query, array $clauses, $expr = null)
    {
        $columnValue = ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'like', 'notLike', 'in', 'notIn', 'between', 'notBetween'];
        $justColumn  = ['isNull', 'isNotNull', 'isEmpty', 'isNotEmpty'];
        $andOr       = ['andX', 'orX'];

        if ($clauses && is_array($clauses)) {
            foreach ($clauses as $clause) {
                if (!empty($clause['internal']) && 'formula' === $clause['expr']) {
                    $whereClause = array_key_exists('value', $clause) ? $clause['value'] : $clause['val'];
                    if ($expr) {
                        $expr->add($whereClause);
                    } else {
                        $query->andWhere($whereClause);
                    }

                    continue;
                }

                if (in_array($clause['expr'], $andOr)) {
                    $composite = $query->expr()->{$clause['expr']}();
                    $this->buildWhereClauseFromArray($query, $clause['val'], $composite);

                    if (null === $expr) {
                        $query->andWhere($composite);
                    } else {
                        $expr->add($composite);
                    }
                } else {
                    $clause = $this->validateWhereClause($clause);
                    $column = (!str_contains($clause['col'], '.')) ? $this->getTableAlias().'.'.$clause['col'] : $clause['col'];

                    $whereClause = null;
                    switch ($clause['expr']) {
                        case 'between':
                        case 'notBetween':
                            if (is_array($clause['val']) && 2 === count($clause['val'])) {
                                $not   = 'notBetween' === $clause['expr'] ? ' NOT' : '';
                                $param = $this->generateRandomParameterName();
                                $query->setParameter($param, $clause['val'][0]);
                                $param2 = $this->generateRandomParameterName();
                                $query->setParameter($param2, $clause['val'][1]);

                                $whereClause = $column.$not.' BETWEEN :'.$param.' AND :'.$param2;
                            }
                            break;
                        case 'isEmpty':
                        case 'isNotEmpty':
                            if ('isEmpty' === $clause['expr']) {
                                $whereClause = $query->expr()->orX(
                                    $query->expr()->eq($column, $query->expr()->literal('')),
                                    $query->expr()->isNull($column)
                                );
                            } else {
                                $whereClause = $query->expr()->andX(
                                    $query->expr()->neq($column, $query->expr()->literal('')),
                                    $query->expr()->isNotNull($column)
                                );
                            }
                            break;
                        case 'in':
                        case 'notIn':
                            $parsed = str_getcsv(html_entity_decode($clause['val']), ',', '"');

                            $param = $this->generateRandomParameterName();
                            $arg   = count($parsed) > 1 ? $parsed : array_shift($parsed);

                            if (is_array($arg)) {
                                $whereClause = $query->expr()->{$clause['expr']}($column, ':'.$param);
                                $query->setParameter($param, $arg, ArrayParameterType::STRING);
                            } else {
                                $expression  = 'in' === $clause['expr'] ? 'eq' : 'neq';
                                $whereClause = $query->expr()->{$expression}($column, ':'.$param);
                                $query->setParameter($param, $arg);
                            }
                            break;
                        default:
                            if (method_exists($query->expr(), $clause['expr'])) {
                                if (in_array($clause['expr'], $columnValue)) {
                                    $param       = $this->generateRandomParameterName();
                                    $whereClause = $query->expr()->{$clause['expr']}($column, ':'.$param);
                                    $query->setParameter($param, $clause['val']);
                                } elseif (in_array($clause['expr'], $justColumn)) {
                                    $whereClause = $query->expr()->{$clause['expr']}($column);
                                }
                            }
                    }

                    if ($whereClause) {
                        if ($expr) {
                            $expr->add($whereClause);
                        } else {
                            $query->andWhere($whereClause);
                        }
                    }
                }
            }
        }
    }

    /**
     * Generate a unique parameter name from int using base conversion.
     * This eliminates chance for parameter name collision and provides unique result for each number.
     * Duplicate method because of DI refactoring difficulty.
     *
     * @see \Mautic\LeadBundle\Segment\RandomParameterName
     * @see https://stackoverflow.com/questions/307486/short-unique-id-in-php/1516430#1516430
     */
    public function generateRandomParameterName(): string
    {
        $value = base_convert((string) $this->lastUsedParameterId, 10, 36);

        ++$this->lastUsedParameterId;

        return 'par'.$value;
    }

    protected function getDefaultOrder(): array
    {
        return [];
    }

    /**
     * @return mixed
     */
    protected function getIdsExpr(&$q, $filter)
    {
        if ($ids = array_map('intval', explode(',', $filter->string))) {
            return $q->expr()->in($this->getTableAlias().'.id', $ids);
        }

        return false;
    }

    /**
     * Test to see if a given command is supported by the repository.
     *
     * @param string $command
     * @param string $subcommand
     */
    protected function isSupportedSearchCommand(&$command, &$subcommand): bool
    {
        $commands = $this->getSearchCommands();
        foreach ($commands as $k => $c) {
            if (is_array($c)) {
                // subcommands
                if ($this->translator->trans($k) == $command || $this->translator->trans($k, [], null, 'en_US') == $command) {
                    foreach ($c as $subc) {
                        if ($this->translator->trans($subc) == $subcommand || $this->translator->trans($subc, [], null, 'en_US') == $subcommand) {
                            return true;
                        }
                    }
                }
            } elseif ($this->translator->trans($c) == $command || $this->translator->trans($c, [], null, 'en_US') == $command) {
                return true;
            } elseif ($this->translator->trans($c) == "{$command}:{$subcommand}"
                || $this->translator->trans($c, [], null, 'en_US') == "{$command}:{$subcommand}"
            ) {
                $command    = "{$command}:{$subcommand}";
                $subcommand = '';

                return true;
            }
        }

        return false;
    }

    /**
     * @param \StdClass                     $parseFilters
     * @param QueryBuilder|DbalQueryBuilder $qb
     */
    protected function parseSearchFilters($parseFilters, $qb, $expressions, &$parameters)
    {
        foreach ($parseFilters as $f) { /** @phpstan-ignore-line we are iterating over StdClass. We should refactor this into a collection of DTO objects in M6 */
            if (isset($f->children)) {
                [$expr, $params] = $this->addAdvancedSearchWhereClause($qb, $f);
            } else {
                if (!empty($f->command)) {
                    if ($this->isSupportedSearchCommand($f->command, $f->string)) {
                        [$expr, $params] = $this->addSearchCommandWhereClause($qb, $f);
                    } else {
                        // treat the command:string as if its a single word
                        $f->string       = $f->command.':'.$f->string;
                        $f->not          = false;
                        $f->strict       = true;
                        [$expr, $params] = $this->addCatchAllWhereClause($qb, $f);
                    }
                } else {
                    [$expr, $params] = $this->addCatchAllWhereClause($qb, $f);
                }
            }
            if (!empty($params)) {
                $parameters = array_merge($parameters, $params);
            }

            $this->appendExpression($expressions, $expr);
        }
    }

    /**
     * Sanitizes a string to alphanum plus characters in the second argument.
     *
     * @param string $sqlAttr
     * @param array  $allowedCharacters
     */
    protected function sanitize($sqlAttr, $allowedCharacters = []): string
    {
        return InputHelper::alphanum($sqlAttr, false, null, $allowedCharacters);
    }

    private function convertOrmPropertiesToColumns(array &$filters, array $properties): void
    {
        foreach ($filters as &$f) {
            $key   = (isset($f['col'])) ? 'col' : 'column';
            $col   = $f[$key];
            $alias = '';
            if (str_contains($col, '.')) {
                [$alias, $col] = explode('.', $col);
            }

            if (in_array($col, $properties)) {
                $col = preg_replace('/(?<=\\w)(?=[A-Z])/', '_$1', $col);
                $col = strtolower($col);
            }

            $f[$key] = (!empty($alias)) ? $alias.'.'.$col : $col;
        }
    }

    /**
     * Checks if table contains any rows.
     */
    protected function tableHasRows(string $table): bool
    {
        $query = $this->getEntityManager()->getConnection()->createQueryBuilder();
        $query->select('null')
            ->from($table)
            ->setMaxResults(1);

        return (bool) count($query->executeQuery()->fetchAllAssociative());
    }
}
