Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,301 @@
<?php
namespace Mautic\LeadBundle\Segment\Query;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManager;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Event\LeadListFilteringEvent;
use Mautic\LeadBundle\Event\LeadListQueryBuilderGeneratedEvent;
use Mautic\LeadBundle\LeadEvents;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\ContactSegmentFilters;
use Mautic\LeadBundle\Segment\Exception\PluginHandledFilterException;
use Mautic\LeadBundle\Segment\Exception\SegmentQueryException;
use Mautic\LeadBundle\Segment\RandomParameterName;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Responsible for building queries for segments.
*/
class ContactSegmentQueryBuilder
{
use LeadBatchLimiterTrait;
/**
* @var array Contains segment edges mapping
*/
private array $dependencyMap = [];
public function __construct(
private EntityManager $entityManager,
private RandomParameterName $randomParameterName,
private EventDispatcherInterface $dispatcher,
) {
}
/**
* @param int $segmentId
* @param ContactSegmentFilters $segmentFilters
*
* @throws SegmentQueryException
*/
public function assembleContactsSegmentQueryBuilder($segmentId, $segmentFilters, bool $changeAlias = false): QueryBuilder
{
/** @var Connection $connection */
$connection = $this->entityManager->getConnection();
if ($connection instanceof \Doctrine\DBAL\Connections\PrimaryReadReplicaConnection) {
// Prefer a replica connection if available.
$connection->ensureConnectedToReplica();
}
/** @var QueryBuilder $queryBuilder */
$queryBuilder = new QueryBuilder($connection);
$leadsTableAlias = $changeAlias ? $this->generateRandomParameterName() : Lead::DEFAULT_ALIAS;
$queryBuilder->select($leadsTableAlias.'.id')->from(MAUTIC_TABLE_PREFIX.'leads', $leadsTableAlias);
/*
* Validate the plan, check for circular dependencies.
*
* the bigger count($plan), the higher complexity of query
*/
$this->getResolutionPlan($segmentId);
$params = $queryBuilder->getParameters();
$paramTypes = $queryBuilder->getParameterTypes();
/** @var ContactSegmentFilter $filter */
foreach ($segmentFilters as $filter) {
try {
$this->dispatchPluginFilteringEvent($filter, $queryBuilder);
} catch (PluginHandledFilterException) {
continue;
}
$queryBuilder = $filter->applyQuery($queryBuilder);
// We need to collect params between union queries in this iteration,
// because they are overwritten by new union query build
$params = array_merge($params, $queryBuilder->getParameters());
$paramTypes = array_merge($paramTypes, $queryBuilder->getParameterTypes());
}
$queryBuilder->setParameters($params, $paramTypes);
$queryBuilder->applyStackLogic();
return $queryBuilder;
}
/**
* @throws \Doctrine\DBAL\Exception
*/
public function wrapInCount(QueryBuilder $qb): QueryBuilder
{
/** @var Connection $connection */
$connection = $this->entityManager->getConnection();
if ($connection instanceof \Doctrine\DBAL\Connections\PrimaryReadReplicaConnection) {
// Prefer a replica connection if available.
$connection->ensureConnectedToReplica();
}
// Add count functions to the query
$queryBuilder = new QueryBuilder($connection);
// If there is any right join in the query we need to select its it
$primary = $qb->guessPrimaryLeadContactIdColumn();
$currentSelects = [];
foreach ($qb->getQueryParts()['select'] as $select) {
if ($select != $primary) {
$currentSelects[] = $select;
}
}
$qb->select('DISTINCT '.$primary.' as leadIdPrimary');
foreach ($currentSelects as $select) {
$qb->addSelect($select);
}
$queryBuilder->select('count(leadIdPrimary) count, max(leadIdPrimary) maxId, min(leadIdPrimary) minId')
->from('('.$qb->getSQL().')', 'sss');
$queryBuilder->setParameters($qb->getParameters(), $qb->getParameterTypes());
return $queryBuilder;
}
/**
* Restrict the query to NEW members of segment.
*
* @param array<string, mixed> $batchLimiters
*
* @throws QueryException
*/
public function addNewContactsRestrictions(QueryBuilder $queryBuilder, int $segmentId, array $batchLimiters = []): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$expr = $queryBuilder->expr();
$tableAlias = $this->generateRandomParameterName();
$segmentIdParameter = ":{$tableAlias}segmentId";
$segmentQueryBuilder = $queryBuilder->createQueryBuilder()
->select($tableAlias.'.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', $tableAlias)
->andWhere($expr->eq($tableAlias.'.leadlist_id', $segmentIdParameter));
$queryBuilder->setParameter("{$tableAlias}segmentId", $segmentId);
$this->addLeadAndMinMaxLimiters($segmentQueryBuilder, $batchLimiters, 'lead_lists_leads');
$queryBuilder->andWhere($expr->notIn($leadsTableAlias.'.id', $segmentQueryBuilder->getSQL()));
return $queryBuilder;
}
public function addManuallySubscribedQuery(QueryBuilder $queryBuilder, int $leadListId): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$tableAlias = $this->generateRandomParameterName();
$existsQueryBuilder = $queryBuilder->createQueryBuilder();
$existsQueryBuilder
->select('null')
->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', $tableAlias)
->andWhere($queryBuilder->expr()->eq($tableAlias.'.leadlist_id', intval($leadListId)))
->andWhere(
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq($tableAlias.'.manually_added', 1),
$queryBuilder->expr()->eq($tableAlias.'.manually_removed', $queryBuilder->expr()->literal(''))
)
);
$existingQueryWherePart = $existsQueryBuilder->getQueryPart('where');
$existsQueryBuilder->where("$leadsTableAlias.id = $tableAlias.lead_id");
$existsQueryBuilder->andWhere($existingQueryWherePart);
$queryBuilder->orWhere(
$queryBuilder->expr()->exists($existsQueryBuilder->getSQL())
);
return $queryBuilder;
}
/**
* @throws QueryException
*/
public function addManuallyUnsubscribedQuery(QueryBuilder $queryBuilder, int $leadListId): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$tableAlias = $this->generateRandomParameterName();
$queryBuilder->leftJoin(
$leadsTableAlias,
MAUTIC_TABLE_PREFIX.'lead_lists_leads',
$tableAlias,
$leadsTableAlias.'.id = '.$tableAlias.'.lead_id and '.$tableAlias.'.leadlist_id = '.intval($leadListId)
);
$queryBuilder->addJoinCondition($tableAlias, $queryBuilder->expr()->eq($tableAlias.'.manually_removed', 1));
$queryBuilder->andWhere($queryBuilder->expr()->isNull($tableAlias.'.lead_id'));
return $queryBuilder;
}
public function queryBuilderGenerated(LeadList $segment, QueryBuilder $queryBuilder): void
{
if (!$this->dispatcher->hasListeners(LeadEvents::LIST_FILTERS_QUERYBUILDER_GENERATED)) {
return;
}
$event = new LeadListQueryBuilderGeneratedEvent($segment, $queryBuilder);
$this->dispatcher->dispatch($event, LeadEvents::LIST_FILTERS_QUERYBUILDER_GENERATED);
}
/**
* Generate a unique parameter name.
*/
private function generateRandomParameterName(): string
{
return $this->randomParameterName->generateRandomParameterName();
}
/**
* @throws PluginHandledFilterException
*/
private function dispatchPluginFilteringEvent(ContactSegmentFilter $filter, QueryBuilder $queryBuilder): void
{
if ($this->dispatcher->hasListeners(LeadEvents::LIST_FILTERS_ON_FILTERING)) {
// This has to run for every filter
$filterCrate = $filter->contactSegmentFilterCrate->getArray();
$alias = $this->generateRandomParameterName();
$event = new LeadListFilteringEvent($filterCrate, null, $alias, $filterCrate['operator'], $queryBuilder, $this->entityManager);
$this->dispatcher->dispatch($event, LeadEvents::LIST_FILTERS_ON_FILTERING);
if ($event->isFilteringDone()) {
$queryBuilder->addLogic($event->getSubQuery(), $filter->getGlue());
throw new PluginHandledFilterException();
}
}
}
/**
* Returns array with plan for processing.
*
* @param int $segmentId
* @param array $seen
* @param array $resolved
*
* @return array
*
* @throws SegmentQueryException
*/
private function getResolutionPlan($segmentId, $seen = [], &$resolved = [])
{
$seen[] = $segmentId;
if (!isset($this->dependencyMap[$segmentId])) {
$this->dependencyMap[$segmentId] = $this->getSegmentEdges($segmentId);
}
$edges = $this->dependencyMap[$segmentId];
foreach ($edges as $edge) {
if (!in_array($edge, $resolved)) {
if (in_array($edge, $seen)) {
throw new SegmentQueryException('Circular reference detected.');
}
$this->getResolutionPlan($edge, $seen, $resolved);
}
}
$resolved[] = $segmentId;
return $resolved;
}
/**
* @param int $segmentId
*/
private function getSegmentEdges($segmentId): array
{
$segment = $this->entityManager->getRepository(LeadList::class)->find($segmentId);
if (null === $segment) {
return [];
}
$segmentFilters = $segment->getFilters();
$segmentEdges = [];
foreach ($segmentFilters as $segmentFilter) {
if (isset($segmentFilter['field']) && 'leadlist' === $segmentFilter['field']) {
$bcFilter = $segmentFilter['filter'] ?? [];
$filterEdges = $segmentFilter['properties']['filter'] ?? $bcFilter;
$segmentEdges = array_merge($segmentEdges, $filterEdges);
}
}
return $segmentEdges;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Expression;
use Doctrine\DBAL\Query\Expression\ExpressionBuilder as BaseExpressionBuilder;
use Mautic\LeadBundle\Segment\Exception\SegmentQueryException;
class ExpressionBuilder extends BaseExpressionBuilder
{
public const REGEXP = 'REGEXP';
public const BETWEEN = 'BETWEEN';
/**
* Creates a between comparison expression.
*
* @throws SegmentQueryException
*/
public function between($x, $arr): string
{
if (!is_array($arr) || 2 != count($arr)) {
throw new SegmentQueryException('Between expression expects second argument to be an array with exactly two elements');
}
return $x.' '.self::BETWEEN.' '.$this->comparison($arr[0], 'AND', $arr[1]);
}
/**
* Creates a not between comparison expression.
*
* First argument is considered the left expression and the second is the right expression.
* When converted to string, it will generated a <left expr> = <right expr>. Example:
*
* [php]
* // u.id = ?
* $expr->eq('u.id', '?');
*
* @throws SegmentQueryException
*/
public function notBetween($x, $arr): string
{
return 'NOT '.$this->between($x, $arr);
}
/**
* Creates an equality comparison expression with the given arguments.
*
* First argument is considered the left expression and the second is the right expression.
* When converted to string, it will generated a <left expr> = <right expr>. Example:
*
* [php]
* // u.id = ?
* $expr->eq('u.id', '?');
*
* @param mixed $x the left expression
* @param mixed $y the right expression
*
* @return string
*/
public function regexp($x, $y)
{
return $this->comparison($x, self::REGEXP, $y);
}
/**
* Creates an equality comparison expression with the given arguments.
*
* First argument is considered the left expression and the second is the right expression.
* When converted to string, it will generated a <left expr> = <right expr>. Example:
*
* [php]
* // u.id = ?
* $expr->eq('u.id', '?');
*
* @param mixed $x the left expression
* @param mixed $y the right expression
*/
public function notRegexp($x, $y): string
{
return 'NOT '.$this->comparison($x, self::REGEXP, $y);
}
/**
* Puts argument into EXISTS mysql function.
*/
public function exists($input): string
{
return $this->func('EXISTS', $input);
}
/**
* Puts argument into NOT EXISTS mysql function.
*/
public function notExists($input): string
{
return $this->func('NOT EXISTS', $input);
}
/**
* Creates a functional expression.
*
* @param string $func any function to be applied on $x
* @param mixed $x the left expression
* @param string|array $y the placeholder or the array of values to be used by IN() comparison
*/
public function func($func, $x, $y = null): string
{
$functionArguments = func_get_args();
$additionArguments = array_splice($functionArguments, 2);
foreach ($additionArguments as $k=> $v) {
$additionArguments[$k] = is_numeric($v) && intval($v) === $v ? $v : $this->literal($v);
}
return $func.'('.$x.(count($additionArguments) ? ', ' : '').join(',', $additionArguments).')';
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Mautic\LeadBundle\Event\SegmentOperatorQueryBuilderEvent;
use Mautic\LeadBundle\LeadEvents;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
use Mautic\LeadBundle\Segment\RandomParameterName;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class BaseFilterQueryBuilder implements FilterQueryBuilderInterface
{
public function __construct(
private RandomParameterName $parameterNameGenerator,
private EventDispatcherInterface $dispatcher,
) {
}
public static function getServiceId(): string
{
return 'mautic.lead.query.builder.basic';
}
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter): QueryBuilder
{
// Check if the column exists in the table
$filter->getColumn();
$filterParameters = $filter->getParameterValue();
if (is_array($filterParameters)) {
$parameters = [];
foreach ($filterParameters as $filterParameter) {
$parameters[] = $this->generateRandomParameterName();
}
} else {
$parameters = $this->generateRandomParameterName();
}
$event = new SegmentOperatorQueryBuilderEvent($queryBuilder, $filter, $filter->getParameterHolder($parameters));
$this->dispatcher->dispatch($event, LeadEvents::LIST_FILTERS_OPERATOR_QUERYBUILDER_ON_GENERATE);
if (!$event->wasOperatorHandled()) {
throw new \Exception('Dunno how to handle operator "'.$filter->getOperator().'"');
}
$queryBuilder->setParametersPairs($parameters, $filterParameters);
return $queryBuilder;
}
/**
* @param RandomParameterName $parameterNameGenerator
*
* @return BaseFilterQueryBuilder
*/
public function setParameterNameGenerator($parameterNameGenerator)
{
$this->parameterNameGenerator = $parameterNameGenerator;
return $this;
}
protected function generateRandomParameterName(): string
{
return $this->parameterNameGenerator->generateRandomParameterName();
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\Query\LeadBatchLimiterTrait;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
class ChannelClickQueryBuilder extends BaseFilterQueryBuilder
{
use LeadBatchLimiterTrait;
public static function getServiceId(): string
{
return 'mautic.lead.query.builder.channel_click.value';
}
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$filterOperator = $filter->getOperator();
$filterChannel = $this->getChannel($filter->getField());
$batchLimiters = $filter->getBatchLimiters();
$filterParameters = $filter->getParameterValue();
if (is_array($filterParameters)) {
$parameters = [];
foreach ($filterParameters as $filterParameter) {
$parameters[] = $this->generateRandomParameterName();
}
} else {
$parameters = $this->generateRandomParameterName();
}
$tableAlias = $this->generateRandomParameterName();
$subQb = $queryBuilder->createQueryBuilder();
$expr = $subQb->expr()->and(
$subQb->expr()->isNotNull($tableAlias.'.redirect_id'),
$subQb->expr()->isNotNull($tableAlias.'.lead_id'),
$subQb->expr()->eq($tableAlias.'.source', $subQb->expr()->literal($filterChannel))
);
if ($this->isDateBased($filter->getField())) {
$expr = $expr->with(
$subQb->expr()->$filterOperator($tableAlias.'.date_hit', $filter->getParameterHolder($parameters))
);
}
$subQb->select($tableAlias.'.lead_id')
->from(MAUTIC_TABLE_PREFIX.'page_hits', $tableAlias)
->where($expr);
$this->addLeadAndMinMaxLimiters($subQb, $batchLimiters, 'page_hits');
if ('empty' === $filterOperator && !$this->isDateBased($filter->getField())) {
$queryBuilder->addLogic($queryBuilder->expr()->notIn($leadsTableAlias.'.id', $subQb->getSQL()), $filter->getGlue());
} else {
$queryBuilder->addLogic($queryBuilder->expr()->in($leadsTableAlias.'.id', $subQb->getSQL()), $filter->getGlue());
}
$queryBuilder->setParametersPairs($parameters, $filterParameters);
return $queryBuilder;
}
private function getChannel(string $name): string
{
if ('email_id' === $name) {
// BC for existing filter
return 'email';
}
return str_replace(['_clicked_link', '_date'], '', $name);
}
private function isDateBased(string $name): bool
{
return str_contains($name, '_date');
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
/**
* Used to connect foreign tables using third table.
*
* Currently only company decorator uses this functionality but it may be used by plugins in the future
*
* filter decorator must implement methods:
* $filter->getRelationJoinTable()
* $filter->getRelationJoinTableField()
*
* @see \Mautic\LeadBundle\Segment\Decorator\CompanyDecorator
*/
class ComplexRelationValueFilterQueryBuilder extends BaseFilterQueryBuilder
{
public static function getServiceId(): string
{
return 'mautic.lead.query.builder.complex_relation.value';
}
/**
* @throws \Exception
*/
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$filterOperator = $filter->getOperator();
$filterParameters = $filter->getParameterValue();
if (is_array($filterParameters)) {
$parameters = [];
foreach ($filterParameters as $filterParameter) {
$parameters[] = $this->generateRandomParameterName();
}
} else {
$parameters = $this->generateRandomParameterName();
}
$filterParametersHolder = $filter->getParameterHolder($parameters);
$tableAlias = $queryBuilder->getTableAlias($filter->getTable());
if (!$tableAlias) {
$tableAlias = $this->generateRandomParameterName();
$relTable = $this->generateRandomParameterName();
$queryBuilder->leftJoin($leadsTableAlias, $filter->getRelationJoinTable(), $relTable, $relTable.'.lead_id = '.$leadsTableAlias.'.id');
$queryBuilder->leftJoin($relTable, $filter->getTable(), $tableAlias, $tableAlias.'.id = '.$relTable.'.'
.$filter->getRelationJoinTableField());
}
switch ($filterOperator) {
case 'empty':
$expression = new CompositeExpression(CompositeExpression::TYPE_OR,
[
$queryBuilder->expr()->isNull($tableAlias.'.'.$filter->getField()),
$queryBuilder->expr()->eq($tableAlias.'.'.$filter->getField(), $queryBuilder->expr()->literal('')),
]
);
break;
case 'notEmpty':
$expression = new CompositeExpression(CompositeExpression::TYPE_AND,
[
$queryBuilder->expr()->isNotNull($tableAlias.'.'.$filter->getField()),
$queryBuilder->expr()->neq($tableAlias.'.'.$filter->getField(), $queryBuilder->expr()->literal('')),
]
);
break;
case 'neq':
$expression = $queryBuilder->expr()->or(
$queryBuilder->expr()->isNull($tableAlias.'.'.$filter->getField()),
$queryBuilder->expr()->$filterOperator(
$tableAlias.'.'.$filter->getField(),
$filterParametersHolder
)
);
break;
case 'startsWith':
case 'endsWith':
case 'gt':
case 'eq':
case 'gte':
case 'like':
case 'lt':
case 'lte':
case 'in':
case 'between': // Used only for date with week combination (EQUAL [this week, next week, last week])
case 'regexp':
case 'notRegexp': // Different behaviour from 'notLike' because of BC (do not use condition for NULL). Could be changed in Mautic 3.
$expression = $queryBuilder->expr()->$filterOperator(
$tableAlias.'.'.$filter->getField(),
$filterParametersHolder
);
break;
case 'notLike':
case 'notBetween': // Used only for date with week combination (NOT EQUAL [this week, next week, last week])
case 'notIn':
$expression = $queryBuilder->expr()->or(
$queryBuilder->expr()->$filterOperator($tableAlias.'.'.$filter->getField(), $filterParametersHolder),
$queryBuilder->expr()->isNull($tableAlias.'.'.$filter->getField())
);
break;
case 'multiselect':
case '!multiselect':
$operator = 'multiselect' === $filterOperator ? 'regexp' : 'notRegexp';
$expressions = [];
foreach ($filterParametersHolder as $parameter) {
$expressions[] = $queryBuilder->expr()->$operator($tableAlias.'.'.$filter->getField(), $parameter);
}
$expression = $queryBuilder->expr()->and(...$expressions);
break;
default:
throw new \Exception('Dunno how to handle operator "'.$filterOperator.'"');
}
$queryBuilder->addLogic($expression, $filter->getGlue());
$queryBuilder->setParametersPairs($parameters, $filterParameters);
return $queryBuilder;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\Query\LeadBatchLimiterTrait;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
use Mautic\LeadBundle\Segment\Query\QueryException;
class DoNotContactFilterQueryBuilder extends BaseFilterQueryBuilder
{
use LeadBatchLimiterTrait;
public static function getServiceId(): string
{
return 'mautic.lead.query.builder.special.dnc';
}
/**
* @throws QueryException
*/
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$doNotContactParts = $filter->getDoNotContactParts();
$batchLimiters = $filter->getBatchLimiters();
$expr = $queryBuilder->expr();
$queryAlias = $this->generateRandomParameterName();
$reasonParameter = "{$queryAlias}reason";
$channelParameter = "{$queryAlias}channel";
$queryBuilder->setParameter($reasonParameter, $doNotContactParts->getParameterType());
$queryBuilder->setParameter($channelParameter, $doNotContactParts->getChannel());
$filterQueryBuilder = $queryBuilder->createQueryBuilder()
->select($queryAlias.'.lead_id')
->from(MAUTIC_TABLE_PREFIX.'lead_donotcontact', $queryAlias)
->andWhere($expr->eq($queryAlias.'.reason', ':'.$reasonParameter))
->andWhere($expr->eq($queryAlias.'.channel', ':'.$channelParameter));
$this->addLeadAndMinMaxLimiters($filterQueryBuilder, $batchLimiters, 'lead_donotcontact');
if ('eq' === $filter->getOperator() xor !$filter->getParameterValue()) {
$expression = $expr->in($leadsTableAlias.'.id', $filterQueryBuilder->getSQL());
} else {
$expression = $expr->notIn($leadsTableAlias.'.id', $filterQueryBuilder->getSQL());
}
$queryBuilder->addLogic($expression, $filter->getGlue());
return $queryBuilder;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
interface FilterQueryBuilderInterface
{
/**
* @return QueryBuilder
*/
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter);
/**
* @return string returns the service id in the DIC container
*/
public static function getServiceId();
}

View File

@@ -0,0 +1,137 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\Exception\FieldNotFoundException;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
use Mautic\LeadBundle\Segment\Query\QueryException;
class ForeignFuncFilterQueryBuilder extends BaseFilterQueryBuilder
{
public static function getServiceId(): string
{
return 'mautic.lead.query.builder.foreign.func';
}
/**
* @throws FieldNotFoundException
* @throws QueryException
*/
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$filterOperator = $filter->getOperator();
$filterAggr = $filter->getAggregateFunction();
$filterParameters = $filter->getParameterValue();
if (is_array($filterParameters)) {
$parameters = [];
foreach ($filterParameters as $filterParameter) {
$parameters[] = $this->generateRandomParameterName();
}
} else {
$parameters = $this->generateRandomParameterName();
}
// Check if the column exists in the table
$filter->getColumn();
$filterParametersHolder = $filter->getParameterHolder($parameters);
$tableAlias = $queryBuilder->getTableAlias($filter->getTable());
// for aggregate function we need to create new alias and not reuse the old one
if ($filterAggr) {
$tableAlias = false;
}
if (!$tableAlias) {
$tableAlias = $this->generateRandomParameterName();
if ($filterAggr) {
// No join needed, it is placed in exist/not exists
} else {
if ('companies' == $filter->getTable()) {
$relTable = $this->generateRandomParameterName();
$queryBuilder->leftJoin($leadsTableAlias, MAUTIC_TABLE_PREFIX.'companies_leads', $relTable, $relTable.'.lead_id = '.$leadsTableAlias.'.id');
$queryBuilder->leftJoin($relTable, $filter->getTable(), $tableAlias, $tableAlias.'.id = '.$relTable.'.company_id');
} else { // This should never happen
$queryBuilder->leftJoin(
$leadsTableAlias,
$filter->getTable(),
$tableAlias,
sprintf('%s.id = %s.lead_id', $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads'), $tableAlias)
);
}
}
}
switch ($filterOperator) {
case 'empty':
$expression = $queryBuilder->expr()->or(
$queryBuilder->expr()->isNull($tableAlias.'.'.$filter->getField()),
$queryBuilder->expr()->eq($tableAlias.'.'.$filter->getField(), ':'.$emptyParameter = $this->generateRandomParameterName())
);
$queryBuilder->setParameter($emptyParameter, '');
break;
case 'notEmpty':
$expression = $queryBuilder->expr()->and(
$queryBuilder->expr()->isNotNull($tableAlias.'.'.$filter->getField()),
$queryBuilder->expr()->neq($tableAlias.'.'.$filter->getField(), ':'.$emptyParameter = $this->generateRandomParameterName())
);
$queryBuilder->setParameter($emptyParameter, '');
break;
default:
if ($filterAggr) {
if (!is_null($filter)) {
if ('sum' === $filterAggr) {
$expressionArg = $queryBuilder->expr()->func('COALESCE',
$queryBuilder->expr()->func('SUM', $tableAlias.'.'.$filter->getField()),
0
);
$expression = $queryBuilder->expr()->$filterOperator($expressionArg,
$filterParametersHolder);
} else {
$expressionArg = sprintf('%s(DISTINCT %s)', $filterAggr, $tableAlias.'.'
.$filter->getField());
$expression = $queryBuilder->expr()->$filterOperator(
$expressionArg,
$filterParametersHolder
);
}
} else {
$expressionArg = $queryBuilder->expr()->func($filterAggr, $tableAlias.'.'.$filter->getField());
$expression = $queryBuilder->expr()->$filterOperator(
$expressionArg,
$filterParametersHolder
);
}
} else { // This should never happen
$expression = $queryBuilder->expr()->$filterOperator(
$tableAlias.'.'.$filter->getField(),
$filterParametersHolder
);
}
break;
}
if ($queryBuilder->isJoinTable($filter->getTable()) && !$filterAggr) { // This should never happen
$queryBuilder->addJoinCondition($tableAlias, ' ('.$expression.')');
$queryBuilder->addGroupBy($leadsTableAlias.'.id');
} else {
if ($filterAggr) {
$expression = $queryBuilder->expr()->exists('SELECT '.$expressionArg.' FROM '.$filter->getTable().' '.
$tableAlias.' WHERE '.$leadsTableAlias.'.id='.$tableAlias.'.lead_id HAVING '.$expression);
} else { // This should never happen
$queryBuilder->addGroupBy($leadsTableAlias.'.id');
}
$queryBuilder->addLogic($expression, $filter->getGlue());
}
$queryBuilder->setParametersPairs($parameters, $filterParameters);
return $queryBuilder;
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\OperatorOptions;
use Mautic\LeadBundle\Segment\Query\LeadBatchLimiterTrait;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
class ForeignValueFilterQueryBuilder extends BaseFilterQueryBuilder
{
use LeadBatchLimiterTrait;
public static function getServiceId(): string
{
return 'mautic.lead.query.builder.foreign.value';
}
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$filterOperator = $filter->getOperator();
$batchLimiters = $filter->getBatchLimiters();
$filterParameters = $filter->getParameterValue();
// allow use of `contact_id` column instead of deprecated `lead_id`
$foreignContactColumn = $filter->getForeignContactColumn();
if (is_array($filterParameters)) {
$parameters = [];
foreach ($filterParameters as $filterParameter) {
$parameters[] = $this->generateRandomParameterName();
}
} else {
$parameters = $this->generateRandomParameterName();
}
$filterParametersHolder = $filter->getParameterHolder($parameters);
$tableAlias = $this->generateRandomParameterName();
$subQueryBuilder = $queryBuilder->createQueryBuilder();
if (!is_null($filter->getWhere())) {
$subQueryBuilder->andWhere(str_replace(str_replace(MAUTIC_TABLE_PREFIX, '', $filter->getTable()).'.', $tableAlias.'.', $filter->getWhere()));
}
switch ($filterOperator) {
case 'empty':
$subQueryBuilder->select($tableAlias.'.'.$foreignContactColumn)->from($filter->getTable(), $tableAlias);
$queryBuilder->addLogic($queryBuilder->expr()->notIn($leadsTableAlias.'.id', $subQueryBuilder->getSQL()), $filter->getGlue());
break;
case 'notEmpty':
$subQueryBuilder->select($tableAlias.'.'.$foreignContactColumn)->from($filter->getTable(), $tableAlias);
$this->addLeadAndMinMaxLimiters($subQueryBuilder, $batchLimiters, str_replace(MAUTIC_TABLE_PREFIX, '', $filter->getTable()), $foreignContactColumn);
$queryBuilder->addLogic(
$queryBuilder->expr()->in($leadsTableAlias.'.id', $subQueryBuilder->getSQL()),
$filter->getGlue()
);
break;
case 'notIn':
$subQueryBuilder
->select('NULL')->from($filter->getTable(), $tableAlias)
->andWhere($tableAlias.'.'.$foreignContactColumn.' = '.$leadsTableAlias.'.id');
// The use of NOT EXISTS here requires the use of IN instead of NOT IN to prevent a "double negative."
// We are not using EXISTS...NOT IN because it results in including everyone who has at least one entry that doesn't
// match the criteria. For example, with tags, if the contact has the tag in the filter but also another tag, they'll
// be included in the results which is not what we want.
$expression = $subQueryBuilder->expr()->in(
$tableAlias.'.'.$filter->getField(),
$filterParametersHolder
);
$subQueryBuilder->andWhere($expression);
$queryBuilder->addLogic($queryBuilder->expr()->notExists($subQueryBuilder->getSQL()), $filter->getGlue());
break;
case 'neq':
$subQueryBuilder
->select('NULL')->from($filter->getTable(), $tableAlias)
->andWhere($tableAlias.'.'.$foreignContactColumn.' = '.$leadsTableAlias.'.id');
$expression = $subQueryBuilder->expr()->or(
$subQueryBuilder->expr()->eq($tableAlias.'.'.$filter->getField(), $filterParametersHolder),
$subQueryBuilder->expr()->isNull($tableAlias.'.'.$filter->getField())
);
$subQueryBuilder->andWhere($expression);
$queryBuilder->addLogic($queryBuilder->expr()->notExists($subQueryBuilder->getSQL()), $filter->getGlue());
break;
case 'notLike':
$subQueryBuilder
->select('NULL')->from($filter->getTable(), $tableAlias)
->andWhere($tableAlias.'.'.$foreignContactColumn.' = '.$leadsTableAlias.'.id');
$expression = $subQueryBuilder->expr()->or(
$subQueryBuilder->expr()->isNull($tableAlias.'.'.$filter->getField()),
$subQueryBuilder->expr()->like($tableAlias.'.'.$filter->getField(), $filterParametersHolder)
);
$subQueryBuilder->andWhere($expression);
$queryBuilder->addLogic($queryBuilder->expr()->notExists($subQueryBuilder->getSQL()), $filter->getGlue());
break;
case 'regexp':
case 'notRegexp':
$subQueryBuilder->select($tableAlias.'.'.$foreignContactColumn)
->from($filter->getTable(), $tableAlias);
$this->addLeadAndMinMaxLimiters($subQueryBuilder, $batchLimiters, str_replace(MAUTIC_TABLE_PREFIX, '', $filter->getTable()), $foreignContactColumn);
$not = ('notRegexp' === $filterOperator) ? ' NOT' : '';
$expression = $tableAlias.'.'.$filter->getField().$not.' REGEXP '.$filterParametersHolder;
$subQueryBuilder->andWhere($expression);
$queryBuilder->addLogic(
$queryBuilder->expr()->in($leadsTableAlias.'.id', $subQueryBuilder->getSQL()),
$filter->getGlue()
);
break;
case OperatorOptions::INCLUDING_ALL:
$subQueryBuilder->select($tableAlias.'.'.$foreignContactColumn)
->from($filter->getTable(), $tableAlias);
$this->addLeadAndMinMaxLimiters($subQueryBuilder, $batchLimiters, str_replace(MAUTIC_TABLE_PREFIX, '', $filter->getTable()), $foreignContactColumn);
$expression = $subQueryBuilder->expr()->in(
$tableAlias.'.'.$filter->getField(),
$filterParametersHolder
);
$subQueryBuilder->andWhere($expression);
$subQueryBuilder->groupBy($tableAlias.'.'.$foreignContactColumn);
$subQueryBuilder->having('COUNT(DISTINCT '.$tableAlias.'.'.$filter->getField().') = '.count($filterParametersHolder));
$queryBuilder->addLogic($queryBuilder->expr()->in($leadsTableAlias.'.id', $subQueryBuilder->getSQL()), $filter->getGlue());
break;
case OperatorOptions::EXCLUDING_ALL:
$subQueryBuilder
->select('NULL')->from($filter->getTable(), $tableAlias)
->andWhere($tableAlias.'.'.$foreignContactColumn.' = '.$leadsTableAlias.'.id');
// The use of NOT EXISTS here requires the use of IN instead of NOT IN to prevent a "double negative."
// We are not using EXISTS...NOT IN because it results in including everyone who has at least one entry that doesn't
// match the criteria. For example, with tags, if the contact has the tag in the filter but also another tag, they'll
// be included in the results which is not what we want.
$expression = $subQueryBuilder->expr()->in(
$tableAlias.'.'.$filter->getField(),
$filterParametersHolder
);
$subQueryBuilder->andWhere($expression);
$subQueryBuilder->groupBy($tableAlias.'.'.$foreignContactColumn);
$subQueryBuilder->having('COUNT(DISTINCT '.$tableAlias.'.'.$filter->getField().') = '.count($filterParametersHolder));
$queryBuilder->addLogic($queryBuilder->expr()->notExists($subQueryBuilder->getSQL()), $filter->getGlue());
break;
default:
$subQueryBuilder->select($tableAlias.'.'.$foreignContactColumn)
->from($filter->getTable(), $tableAlias);
$this->addLeadAndMinMaxLimiters($subQueryBuilder, $batchLimiters, str_replace(MAUTIC_TABLE_PREFIX, '', $filter->getTable()), $foreignContactColumn);
$expression = $subQueryBuilder->expr()->$filterOperator(
$tableAlias.'.'.$filter->getField(),
$filterParametersHolder
);
$subQueryBuilder->andWhere($expression);
$queryBuilder->addLogic($queryBuilder->expr()->in($leadsTableAlias.'.id', $subQueryBuilder->getSQL()), $filter->getGlue());
}
$queryBuilder->setParametersPairs($parameters, $filterParameters);
return $queryBuilder;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
use Mautic\LeadBundle\Segment\Query\QueryException;
class IntegrationCampaignFilterQueryBuilder extends BaseFilterQueryBuilder
{
public static function getServiceId(): string
{
return 'mautic.lead.query.builder.special.integration';
}
/**
* @throws QueryException
*/
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$integrationCampaignParts = $filter->getIntegrationCampaignParts();
$integrationNameParameter = $this->generateRandomParameterName();
$campaignIdParameter = $this->generateRandomParameterName();
$tableAlias = $this->generateRandomParameterName();
$queryBuilder->leftJoin(
$leadsTableAlias,
MAUTIC_TABLE_PREFIX.'integration_entity',
$tableAlias,
$tableAlias.'.integration_entity = "CampaignMember" AND '.
$tableAlias.".internal_entity = 'lead' AND ".
$tableAlias.'.internal_entity_id = '.$leadsTableAlias.'.id'
);
$expression = $queryBuilder->expr()->and(
$queryBuilder->expr()->eq($tableAlias.'.integration', ":$integrationNameParameter"),
$queryBuilder->expr()->eq($tableAlias.'.integration_entity_id', ":$campaignIdParameter")
);
$queryBuilder->addJoinCondition($tableAlias, $expression);
if ('eq' === $filter->getOperator()) {
$queryType = $filter->getParameterValue() ? 'isNotNull' : 'isNull';
} else {
$queryType = $filter->getParameterValue() ? 'isNull' : 'isNotNull';
}
$queryBuilder->addLogic($queryBuilder->expr()->$queryType($tableAlias.'.id'), $filter->getGlue());
$queryBuilder->setParameter($integrationNameParameter, $integrationCampaignParts->getIntegrationName());
$queryBuilder->setParameter($campaignIdParameter, $integrationCampaignParts->getCampaignId());
return $queryBuilder;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Mautic\LeadBundle\Segment\Query\Filter;
use Mautic\LeadBundle\Segment\ContactSegmentFilter;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
class SessionsFilterQueryBuilder extends BaseFilterQueryBuilder
{
public static function getServiceId(): string
{
return 'mautic.lead.query.builder.special.sessions';
}
public function applyQuery(QueryBuilder $queryBuilder, ContactSegmentFilter $filter): QueryBuilder
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$pageHitsAlias = $this->generateRandomParameterName();
$exclusionAlias = $this->generateRandomParameterName();
$expressionValueAlias = $this->generateRandomParameterName();
$expressionOperator = $filter->getOperator();
$expression = $queryBuilder->expr()->$expressionOperator('count(id)',
$filter->getParameterHolder($expressionValueAlias));
$queryBuilder->setParameter($expressionValueAlias, (int) $filter->getParameterValue());
$exclusionQueryBuilder = $queryBuilder->createQueryBuilder();
$exclusionQueryBuilder
->select($exclusionAlias.'.id')
->from(MAUTIC_TABLE_PREFIX.'page_hits', $exclusionAlias)
->where(
$queryBuilder->expr()->and(
$queryBuilder->expr()->eq($leadsTableAlias.'.id', $exclusionAlias.'.lead_id'),
$queryBuilder->expr()->gt(
$exclusionAlias.'.date_hit',
$pageHitsAlias.'.date_hit - INTERVAL 30 MINUTE'
),
$queryBuilder->expr()->lt($exclusionAlias.'.date_hit', $pageHitsAlias.'.date_hit')
)
);
$sessionQueryBuilder = $queryBuilder->createQueryBuilder();
$sessionQueryBuilder
->select('count(id)')
->from(MAUTIC_TABLE_PREFIX.'page_hits', $pageHitsAlias)
->where(
$queryBuilder->expr()->and(
$queryBuilder->expr()->eq($leadsTableAlias.'.id', $pageHitsAlias.'.lead_id'),
$queryBuilder->expr()->isNull($pageHitsAlias.'.email_id'),
$queryBuilder->expr()->isNull($pageHitsAlias.'.redirect_id'),
$queryBuilder->expr()->notExists(
$exclusionQueryBuilder->getSQL()
)
)
)
->having($expression);
$glue = $filter->getGlue().'Where';
$queryBuilder->$glue($queryBuilder->expr()->exists($sessionQueryBuilder->getSQL()));
return $queryBuilder;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Mautic\LeadBundle\Segment\Query;
trait LeadBatchLimiterTrait
{
/**
* @param array<string, mixed> $batchLimiters
*/
private function addMinMaxLimiters(QueryBuilder $queryBuilder, array $batchLimiters, string $tableName, string $columnName = 'lead_id'): void
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.$tableName);
if (!empty($batchLimiters['minId']) && !empty($batchLimiters['maxId'])) {
$queryBuilder->andWhere(
$queryBuilder->expr()->comparison($leadsTableAlias.'.'.$columnName, 'BETWEEN', "{$batchLimiters['minId']} and {$batchLimiters['maxId']}")
);
} elseif (!empty($batchLimiters['maxId'])) {
$queryBuilder->andWhere(
$queryBuilder->expr()->lte($leadsTableAlias.'.'.$columnName, (int) $batchLimiters['maxId'])
);
} elseif (!empty($batchLimiters['minId'])) {
$queryBuilder->andWhere(
$queryBuilder->expr()->gte($leadsTableAlias.'.'.$columnName, (int) $batchLimiters['minId'])
);
}
}
/**
* @param array<string, mixed> $batchLimiters
*/
private function addLeadLimiter(QueryBuilder $queryBuilder, array $batchLimiters, string $tableName, string $columnName = 'lead_id'): void
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.$tableName);
if (empty($batchLimiters['lead_id'])) {
return;
}
$queryBuilder->andWhere($leadsTableAlias.'.'.$columnName.' = '.$batchLimiters['lead_id']);
}
/**
* @param array<string, mixed> $batchLimiters
*/
private function addLeadAndMinMaxLimiters(QueryBuilder $queryBuilder, array $batchLimiters, string $tableName, string $columnName = 'lead_id'): void
{
$this->addLeadLimiter($queryBuilder, $batchLimiters, $tableName, $columnName);
$this->addMinMaxLimiters($queryBuilder, $batchLimiters, $tableName, $columnName);
}
}

View File

@@ -0,0 +1,473 @@
<?php
namespace Mautic\LeadBundle\Segment\Query;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Doctrine\DBAL\Query\QueryBuilder as BaseQueryBuilder;
use Mautic\LeadBundle\Segment\Query\Expression\ExpressionBuilder;
class QueryBuilder extends BaseQueryBuilder
{
private ?ExpressionBuilder $_expr = null;
/**
* Unprocessed logic for segment processing.
*/
private array $logicStack = [];
public function __construct(
private Connection $connection,
) {
parent::__construct($connection);
}
/**
* @return ExpressionBuilder
*/
public function expr()
{
if (!is_null($this->_expr)) {
return $this->_expr;
}
$this->_expr = new ExpressionBuilder($this->connection);
return $this->_expr;
}
public function setParameter($key, $value, $type = null)
{
if (str_starts_with($key, ':')) {
// For consistency sake, remove the :
$key = substr($key, 1);
@\trigger_error('Using query key with ":" is deprecated. Use key without ":" instead.', \E_USER_DEPRECATED);
}
if (is_bool($value)) {
$value = (int) $value;
}
return parent::setParameter($key, $value, $type);
}
/**
* @param string $queryPartName
* @param mixed $value
*
* @return $this
*/
public function setQueryPart($queryPartName, $value)
{
$this->resetQueryPart($queryPartName);
$this->add($queryPartName, $value);
return $this;
}
public function getSQL()
{
$sql = &$this->parentProperty('sql');
$state = &$this->parentProperty('state');
if (null !== $sql && 1 /* self::STATE_CLEAN */ === $state) {
return $sql;
}
$sql = match ($this->getType()) { /** @phpstan-ignore-line this method is deprecated. We'll have to find a way how to refactor this method. */
3 /* self::INSERT */ => $this->parentMethod('getSQLForInsert'),
1 /* self::DELETE */ => $this->parentMethod('getSQLForDelete'),
2 /* self::UPDATE */ => $this->parentMethod('getSQLForUpdate'),
default => $this->getSQLForSelect(),
};
$state = 1 /* self::STATE_CLEAN */;
return $sql;
}
private function getSQLForSelect(): string
{
$sqlParts = $this->getQueryParts();
$query = 'SELECT '.($sqlParts['distinct'] ? 'DISTINCT ' : '').
implode(', ', $sqlParts['select']);
$query .= ($sqlParts['from'] ? ' FROM '.implode(', ', $this->getFromClauses()) : '')
.(null !== $sqlParts['where'] ? ' WHERE '.($sqlParts['where']) : '')
.($sqlParts['groupBy'] ? ' GROUP BY '.implode(', ', $sqlParts['groupBy']) : '')
.(null !== $sqlParts['having'] ? ' HAVING '.($sqlParts['having']) : '')
.($sqlParts['orderBy'] ? ' ORDER BY '.implode(', ', $sqlParts['orderBy']) : '');
if ($this->parentMethod('isLimitQuery')) {
return $this->connection->getDatabasePlatform()->modifyLimitQuery(
$query,
$this->getMaxResults(),
$this->getFirstResult()
);
}
return $query;
}
/**
* @return string[]
*/
private function getFromClauses(): array
{
$fromClauses = [];
$knownAliases = [];
// Loop through all FROM clauses
foreach ($this->getQueryParts()['from'] as $from) {
if (null === $from['alias']) {
$tableSql = $from['table'];
$tableReference = $from['table'];
} else {
$tableSql = $from['table'].' '.$from['alias'];
$tableReference = $from['alias'];
}
if (isset($from['hint'])) {
$tableSql .= ' '.$from['hint'];
}
$knownAliases[$tableReference] = true;
$fromClauses[$tableReference] = $tableSql.\Closure::bind(
fn ($tableReference, &$knownAliases) => $this->{'getSQLForJoins'}($tableReference, $knownAliases),
$this,
parent::class
)($tableReference, $knownAliases);
}
$this->parentMethod('verifyAllAliasesAreKnown', $knownAliases);
return $fromClauses;
}
/**
* @param string $alias
*
* @return string|false
*/
public function getJoinCondition($alias)
{
$parts = $this->getQueryParts();
foreach ($parts['join']['l'] as $joinedTable) {
if ($joinedTable['joinAlias'] == $alias) {
return $joinedTable['joinCondition'];
}
}
return false;
}
/**
* Add AND condition to existing table alias.
*
* @return $this
*
* @throws QueryException
*/
public function addJoinCondition($alias, $expr)
{
$result = $parts = $this->getQueryPart('join');
foreach ($parts as $tbl => $joins) {
foreach ($joins as $key => $join) {
if ($join['joinAlias'] == $alias) {
$result[$tbl][$key]['joinCondition'] = $join['joinCondition'].' and ('.$expr.')';
$inserted = true;
}
}
}
if (!isset($inserted)) {
throw new QueryException('Inserting condition to nonexistent join '.$alias);
}
$this->setQueryPart('join', $result);
return $this;
}
/**
* @return $this
*/
public function replaceJoinCondition($alias, $expr)
{
$parts = $this->getQueryPart('join');
foreach ($parts['l'] as $key => $part) {
if ($part['joinAlias'] == $alias) {
$parts['l'][$key]['joinCondition'] = $expr;
}
}
$this->setQueryPart('join', $parts);
return $this;
}
/**
* @return QueryBuilder
*/
public function setParametersPairs($parameters, $filterParameters)
{
if (!is_array($parameters)) {
return $this->setParameter($parameters, $filterParameters);
}
foreach ($parameters as $parameter) {
$parameterValue = array_shift($filterParameters);
$this->setParameter($parameter, $parameterValue);
}
return $this;
}
/**
* @return array|bool|string
*/
public function getTableAlias(string $table, $joinType = null)
{
if (is_null($joinType)) {
$tables = $this->getTableAliases();
return $tables[$table] ?? false;
}
$tableJoins = $this->getTableJoins($table);
foreach ($tableJoins as $tableJoin) {
if ($tableJoin['joinType'] == $joinType) {
return $tableJoin['joinAlias'];
}
}
return false;
}
/**
* @return mixed[]
*/
public function getTableJoins(string $tableName): array
{
$found = [];
foreach ($this->getQueryParts()['join'] as $join) {
foreach ($join as $joinPart) {
if ($tableName == $joinPart['joinTable']) {
$found[] = $joinPart;
}
}
}
return count($found) ? $found : [];
}
/**
* Functions returns either the 'lead.id' or the primary key from right joined table.
*
* @return string
*/
public function guessPrimaryLeadContactIdColumn()
{
$parts = $this->getQueryParts();
$leadTable = $parts['from'][0]['alias'];
if ('orp' === $leadTable) {
return 'orp.lead_id';
}
if (!isset($parts['join'][$leadTable])) {
return $leadTable.'.id';
}
$joins = $parts['join'][$leadTable];
foreach ($joins as $join) {
if ('right' == $join['joinType']) {
$matches = null;
if (preg_match('/'.$leadTable.'\.id \= ([^\ ]+)/i', $join['joinCondition'], $matches)) {
return $matches[1];
}
}
}
return $leadTable.'.id';
}
/**
* Return aliases of all currently registered tables.
*
* @return array
*/
public function getTableAliases()
{
$queryParts = $this->getQueryParts();
$tables = array_reduce($queryParts['from'], function ($result, $item) {
$result[$item['table']] = $item['alias'];
return $result;
}, []);
foreach ($queryParts['join'] as $join) {
foreach ($join as $joinPart) {
$tables[$joinPart['joinTable']] = $joinPart['joinAlias'];
}
}
return $tables;
}
/**
* @param string $table
*/
public function isJoinTable($table): bool
{
$queryParts = $this->getQueryParts();
foreach ($queryParts['join'] as $join) {
foreach ($join as $joinPart) {
if ($joinPart['joinTable'] == $table) {
return true;
}
}
}
return false;
}
/**
* @return mixed|string
*
* @throws \Doctrine\DBAL\Exception
*/
public function getDebugOutput()
{
$params = $this->getParameters();
$sql = $this->getSQL();
foreach ($params as $key=>$val) {
if (!is_int($val) && !is_float($val) && !is_array($val)) {
$val = "'$val'";
} elseif (is_array($val)) {
if (ArrayParameterType::STRING === $this->getParameterType($key)) {
$val = array_map(static fn ($value) => "'$value'", $val);
}
$val = implode(', ', $val);
}
$sql = str_replace(":{$key}", $val, $sql);
}
return $sql;
}
public function hasLogicStack(): bool
{
return count($this->logicStack) > 0;
}
/**
* @return array
*/
public function getLogicStack()
{
return $this->logicStack;
}
public function popLogicStack(): array
{
$stack = $this->logicStack;
$this->logicStack = [];
return $stack;
}
/**
* @return $this
*/
private function addLogicStack($expression)
{
$this->logicStack[] = $expression;
return $this;
}
/**
* This function assembles correct logic for segment processing, this is to replace andWhere and orWhere (virtualy
* as they need to be kept). You may not use andWhere in filters!!!
*/
public function addLogic($expression, $glue): void
{
// little setup
$glue = strtolower($glue);
// Different handling
if ('or' == $glue) {
// Is this the first condition in query builder?
if (!is_null($this->getQueryPart('where'))) {
// Are the any queued conditions?
if ($this->hasLogicStack()) {
// We need to apply current stack to the query builder
$this->applyStackLogic();
}
// We queue current expression to stack
$this->addLogicStack($expression);
} else {
$this->andWhere($expression);
}
} else {
// Glue is AND
if ($this->hasLogicStack()) {
$this->addLogicStack($expression);
} else {
$this->andWhere($expression);
}
}
}
/**
* Apply content of stack.
*
* @return $this
*/
public function applyStackLogic()
{
if ($this->hasLogicStack()) {
$stackGroupExpression = new CompositeExpression(CompositeExpression::TYPE_AND, $this->popLogicStack());
$this->orWhere($stackGroupExpression);
}
return $this;
}
public function createQueryBuilder(?Connection $connection = null): QueryBuilder
{
return new self($connection ?: $this->connection);
}
/**
* @return mixed
*
* @noinspection PhpPassByRefInspection
*/
private function &parentProperty(string $property)
{
return \Closure::bind(function &() use ($property) {
return $this->$property;
}, $this, parent::class)();
}
/**
* @param mixed ...$arguments
*
* @return mixed
*/
private function parentMethod(string $method, ...$arguments)
{
return \Closure::bind(function () use ($method, $arguments) {
return $this->$method(...$arguments);
}, $this, parent::class)();
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Mautic\LeadBundle\Segment\Query;
/**
* @since 2.1.4
*/
class QueryException extends \Doctrine\DBAL\Exception
{
public static function unknownAlias($alias, $registeredAliases): self
{
return new self("The given alias '".$alias."' is not part of ".
'any FROM or JOIN clause table. The currently registered '.
'aliases are: '.implode(', ', $registeredAliases).'.');
}
public static function nonUniqueAlias($alias, $registeredAliases): self
{
return new self("The given alias '".$alias."' is not unique ".
'in FROM and JOIN clause table. The currently registered '.
'aliases are: '.implode(', ', $registeredAliases).'.');
}
}