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,230 @@
<?php
namespace Mautic\LeadBundle\Segment;
use Doctrine\DBAL\Schema\Column;
use Mautic\LeadBundle\Segment\Decorator\ContactDecoratorForeignInterface;
use Mautic\LeadBundle\Segment\Decorator\FilterDecoratorInterface;
use Mautic\LeadBundle\Segment\DoNotContact\DoNotContactParts;
use Mautic\LeadBundle\Segment\Exception\FieldNotFoundException;
use Mautic\LeadBundle\Segment\Exception\TableNotFoundException;
use Mautic\LeadBundle\Segment\IntegrationCampaign\IntegrationCampaignParts;
use Mautic\LeadBundle\Segment\Query\Filter\FilterQueryBuilderInterface;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
/**
* Used for accessing $filter as an object and to keep logic in an object.
*/
class ContactSegmentFilter implements \Stringable
{
/**
* @var ContactSegmentFilterCrate
*/
public $contactSegmentFilterCrate;
/**
* @param array<string, mixed> $batchLimiters
*/
public function __construct(
ContactSegmentFilterCrate $contactSegmentFilterCrate,
private FilterDecoratorInterface $filterDecorator,
private TableSchemaColumnsCache $schemaCache,
private FilterQueryBuilderInterface $filterQueryBuilder,
private array $batchLimiters = [],
) {
$this->contactSegmentFilterCrate = $contactSegmentFilterCrate;
}
/**
* @return Column
*
* @throws FieldNotFoundException
*/
public function getColumn()
{
$currentDBName = $this->schemaCache->getCurrentDatabaseName();
$table = preg_replace("/^{$currentDBName}\./", '', $this->getTable());
if (!$table) {
throw new TableNotFoundException('Segment Query is missing table, perhaps incorrectly registered custom Query Builder. ');
}
$columns = $this->schemaCache->getColumns($table);
if (!isset($columns[$this->getField()])) {
throw new FieldNotFoundException(sprintf('Database schema does not contain field %s.%s', $this->getTable(), $this->getField()));
}
return $columns[$this->getField()];
}
/**
* @return string
*/
public function getQueryType()
{
return $this->filterDecorator->getQueryType($this->contactSegmentFilterCrate);
}
/**
* @return string|null
*/
public function getOperator()
{
return $this->filterDecorator->getOperator($this->contactSegmentFilterCrate);
}
/**
* @return mixed
*/
public function getField()
{
return $this->filterDecorator->getField($this->contactSegmentFilterCrate);
}
/**
* @return string
*/
public function getType()
{
return $this->contactSegmentFilterCrate->getType();
}
/**
* @return mixed
*/
public function getTable()
{
return $this->filterDecorator->getTable($this->contactSegmentFilterCrate);
}
public function getForeignContactColumn(): ?string
{
if ($this->filterDecorator instanceof ContactDecoratorForeignInterface) {
return $this->filterDecorator->getForeignContactColumn($this->contactSegmentFilterCrate);
} else {
return 'lead_id';
}
}
/**
* @return mixed
*/
public function getParameterHolder($argument)
{
return $this->filterDecorator->getParameterHolder($this->contactSegmentFilterCrate, $argument);
}
/**
* @return mixed
*/
public function getParameterValue()
{
return $this->filterDecorator->getParameterValue($this->contactSegmentFilterCrate);
}
/**
* @return string|null
*/
public function getWhere()
{
return $this->filterDecorator->getWhere($this->contactSegmentFilterCrate);
}
/**
* @return string|null
*/
public function getGlue()
{
return $this->contactSegmentFilterCrate->getGlue();
}
public function getAggregateFunction(): string|bool
{
return $this->filterDecorator->getAggregateFunc($this->contactSegmentFilterCrate);
}
public function getFilterQueryBuilder(): FilterQueryBuilderInterface
{
return $this->filterQueryBuilder;
}
public function applyQuery(QueryBuilder $queryBuilder): QueryBuilder
{
return $this->filterQueryBuilder->applyQuery($queryBuilder, $this);
}
/**
* Whether the filter references another ContactSegment.
*/
public function isContactSegmentReference(): bool
{
return 'leadlist' === $this->getField();
}
public function isColumnTypeBoolean(): bool
{
return $this->contactSegmentFilterCrate->isBooleanType();
}
/**
* @return mixed
*/
public function getNullValue()
{
return $this->contactSegmentFilterCrate->getNullValue();
}
public function getDoNotContactParts(): DoNotContactParts
{
return new DoNotContactParts($this->contactSegmentFilterCrate->getField());
}
public function getIntegrationCampaignParts(): IntegrationCampaignParts
{
return new IntegrationCampaignParts($this->getParameterValue());
}
public function __toString(): string
{
return sprintf(
'table: %s, %s on %s %s %s',
$this->getTable(),
$this->getField(),
$this->getQueryType(),
$this->getOperator(),
json_encode($this->getParameterValue())
);
}
/**
* @return string|null
*/
public function getRelationJoinTable()
{
return method_exists($this->filterDecorator, 'getRelationJoinTable') ? $this->filterDecorator->getRelationJoinTable() : null;
}
/**
* @return string|null
*/
public function getRelationJoinTableField()
{
return method_exists($this->filterDecorator, 'getRelationJoinTableField') ?
$this->filterDecorator->getRelationJoinTableField() : null;
}
public function doesColumnSupportEmptyValue(): bool
{
return !in_array($this->contactSegmentFilterCrate->getType(), ['date', 'datetime'], true);
}
/**
* @return array<string, mixed>
*/
public function getBatchLimiters(): array
{
return $this->batchLimiters;
}
}

View File

@@ -0,0 +1,225 @@
<?php
namespace Mautic\LeadBundle\Segment;
class ContactSegmentFilterCrate
{
public const CONTACT_OBJECT = 'lead';
public const COMPANY_OBJECT = 'company';
public const BEHAVIORS_OBJECT = 'behaviors';
/**
* @var string|null
*/
private $glue;
/**
* @var string|null
*/
private $field;
/**
* @var string|null
*/
private $object;
/**
* @var string|null
*/
private $type;
/**
* @var string|array|bool|float|null
*/
private $filter;
/**
* @var string|null
*/
private $operator;
private array $sourceArray;
private $nullValue;
/**
* @var array|mixed[]
*/
private array $mergedProperty;
public function __construct(array $filter)
{
$bcFilter = $filter['filter'] ?? null;
$this->glue = $filter['glue'] ?? null;
$this->field = $filter['field'] ?? null;
$this->object = $filter['object'] ?? self::CONTACT_OBJECT;
$this->type = $filter['type'] ?? null;
$this->filter = $filter['properties']['filter'] ?? $bcFilter;
$this->nullValue = $filter['null_value'] ?? null;
$this->mergedProperty = $filter['merged_property'] ?? [];
$this->sourceArray = $filter;
$this->setOperator($filter);
}
/**
* @return string|null
*/
public function getGlue()
{
return $this->glue;
}
/**
* @return string|null
*/
public function getField()
{
return $this->field;
}
public function isContactType(): bool
{
return self::CONTACT_OBJECT === $this->object;
}
public function isCompanyType(): bool
{
return self::COMPANY_OBJECT === $this->object;
}
public function isBehaviorsType(): bool
{
return self::BEHAVIORS_OBJECT === $this->object;
}
/**
* @return string|array|bool|float|null
*/
public function getFilter()
{
$excludeTypecastOperators = [
OperatorOptions::INCLUDING_ANY,
OperatorOptions::EXCLUDING_ANY,
OperatorOptions::INCLUDING_ALL,
OperatorOptions::EXCLUDING_ALL,
OperatorOptions::REGEXP,
OperatorOptions::NOT_REGEXP,
];
if (!in_array($this->operator, $excludeTypecastOperators, true)) {
switch ($this->getType()) {
case 'number':
return (float) $this->filter;
case 'boolean':
return (bool) $this->filter;
}
}
return $this->filter;
}
/**
* @return string|null
*/
public function getOperator()
{
return $this->operator;
}
public function isBooleanType(): bool
{
return 'boolean' === $this->getType();
}
public function isNumberType(): bool
{
return 'number' === $this->getType();
}
public function isDateType(): bool
{
return 'date' === $this->getType() || $this->hasTimeParts();
}
public function hasTimeParts(): bool
{
return 'datetime' === $this->getType();
}
/**
* Filter value could be used directly - no modification (like regex etc.) needed.
*/
public function filterValueDoNotNeedAdjustment(): bool
{
return $this->isNumberType() || $this->isBooleanType();
}
/**
* @return string|null
*/
public function getType()
{
return $this->type;
}
/**
* @return array
*/
public function getArray()
{
return $this->sourceArray;
}
private function setOperator(array $filter): void
{
$operator = $filter['operator'] ?? null;
if ('multiselect' === $this->getType() && in_array($operator, [OperatorOptions::INCLUDING_ANY, OperatorOptions::EXCLUDING_ANY, OperatorOptions::INCLUDING_ALL, OperatorOptions::EXCLUDING_ALL])) {
$neg = !str_contains($operator, '!') ? '' : '!';
$this->operator = $neg.$this->getType();
return;
}
if ('page_id' === $this->getField() || 'email_id' === $this->getField() || 'redirect_id' === $this->getField() || 'notification' === $this->getField()) {
$operator = ('=' === $operator) === $this->getFilter() ? 'notEmpty' : 'empty';
}
if ('=' === $operator && is_array($this->getFilter())) { // Fix for old segments which can have stored = instead on in operator
$operator = 'in';
}
$this->operator = $operator;
}
/**
* @return mixed
*/
public function getNullValue()
{
return $this->nullValue;
}
public function getObject(): ?string
{
return $this->object;
}
/**
* @return array|mixed[]
*/
public function getMergedProperty(): array
{
return $this->mergedProperty;
}
/**
* @param array|mixed[] $mergedProperty
*/
public function setMergedProperty(array $mergedProperty): void
{
$this->mergedProperty = $mergedProperty;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace Mautic\LeadBundle\Segment;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Event\LeadListMergeFiltersEvent;
use Mautic\LeadBundle\LeadEvents;
use Mautic\LeadBundle\Segment\Decorator\DecoratorFactory;
use Mautic\LeadBundle\Segment\Decorator\FilterDecoratorInterface;
use Mautic\LeadBundle\Segment\Query\Filter\FilterQueryBuilderInterface;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class ContactSegmentFilterFactory
{
public const CUSTOM_OPERATOR = 'custom_operator';
/**
* @var array|string[]
*/
private array $operatorsWithEmptyValuesAllowed = ['empty', '!empty', self::CUSTOM_OPERATOR];
public function __construct(
private TableSchemaColumnsCache $schemaCache,
private Container $container,
private DecoratorFactory $decoratorFactory,
private EventDispatcherInterface $eventDispatcher,
) {
}
/**
* @param array<string, mixed> $batchLimiters
*
* @throws \Exception
*/
public function getSegmentFilters(LeadList $leadList, array $batchLimiters = []): ContactSegmentFilters
{
$contactSegmentFilters = new ContactSegmentFilters();
$filters = $this->mergeFilters($leadList->getFilters());
$event = new LeadListMergeFiltersEvent($filters);
$this->eventDispatcher->dispatch($event, LeadEvents::LIST_FILTERS_MERGE);
$filters = $event->getFilters();
foreach ($filters as $filter) {
if (self::CUSTOM_OPERATOR === $filter['operator']) {
$mergedProperty = $filter['merged_property'];
$factorSegmentFilter = null;
foreach ($filter['properties'] as $index => $nestedFilter) {
if (!in_array($nestedFilter['operator'], $this->operatorsWithEmptyValuesAllowed) && empty($nestedFilter['filter']) && !is_numeric($nestedFilter['filter'])) {
continue; // If no value set for the filter, don't consider it
}
$factorSegmentFilter = $this->factorSegmentFilter($nestedFilter, $batchLimiters);
$mergedProperty[$index] = [
'filter_value' => $factorSegmentFilter->getParameterValue(),
'operator' => $factorSegmentFilter->getOperator(),
'field' => $factorSegmentFilter->getField(),
'type' => $factorSegmentFilter->getType(),
'filter' => $factorSegmentFilter,
];
}
if ($factorSegmentFilter) {
$factorSegmentFilter->contactSegmentFilterCrate->setMergedProperty($mergedProperty);
$contactSegmentFilters->addContactSegmentFilter($factorSegmentFilter);
}
} else {
$contactSegmentFilters->addContactSegmentFilter($this->factorSegmentFilter($filter, $batchLimiters));
}
}
return $contactSegmentFilters;
}
/**
* @param array<string, mixed> $filter
* @param array<string, mixed> $batchLimiters
*
* @throws \Exception
*/
public function factorSegmentFilter(array $filter, array $batchLimiters = []): ContactSegmentFilter
{
$contactSegmentFilterCrate = new ContactSegmentFilterCrate($filter);
$decorator = $this->decoratorFactory->getDecoratorForFilter($contactSegmentFilterCrate);
$filterQueryBuilder = $this->getQueryBuilderForFilter($decorator, $contactSegmentFilterCrate);
return new ContactSegmentFilter($contactSegmentFilterCrate, $decorator, $this->schemaCache, $filterQueryBuilder, $batchLimiters);
}
private function getQueryBuilderForFilter(FilterDecoratorInterface $decorator, ContactSegmentFilterCrate $contactSegmentFilterCrate): FilterQueryBuilderInterface
{
$qbServiceId = $decorator->getQueryType($contactSegmentFilterCrate);
return $this->container->get($qbServiceId);
}
/**
* Merge multiple filters of same field with OR.
*
* @param mixed[] $filters
*
* @return mixed[]
*/
private function mergeFilters(array $filters): array
{
$shrinkedFilters = [];
$arrStacks = []; // Put the same filters from array into Stacks
$previousKey = ''; // preserve the key from previous iteration
// replace filters with glue OR and operator = , with IN operator
foreach ($filters as $filter) {
// easy to compare
$key = implode('_', [
$filter['object'] ?? '',
$filter['field'],
$filter['glue'],
$filter['operator'],
]);
if ('or' === strtolower($filter['glue']) && '=' === $filter['operator']) {
if (!isset($arrStacks[$key])) {
$arrStacks[$key] = [];
}
array_push($arrStacks[$key], $filter);
} else { // glue = and
// if 'or' followed by 'and', it becomes - or (cond1 and cond2)
if (isset($arrStacks[$previousKey]) && count($arrStacks[$previousKey]) > 0) { /** @phpstan-ignore-line `Comparison operation ">" between 0 and 0 is always false.` I don't see anything wrong. Seems to be a PHPSTAN issue https://github.com/phpstan/phpstan/issues/3831 */
$previousFilter = array_pop($arrStacks[$previousKey]);
array_push($shrinkedFilters, $previousFilter);
}
array_push($shrinkedFilters, $filter);
}
$previousKey = $key;
}
// add all grouped conditions back
foreach ($arrStacks as $stack) {
$groupedFilter = $this->groupFilters($stack);
if (!empty($groupedFilter)) {
$shrinkedFilters[] = $groupedFilter;
}
}
return $shrinkedFilters;
}
/**
* @param mixed[] $stack
*
* @return mixed[]
*/
private function groupFilters(array $stack): array
{
if (empty($stack)) {
return [];
}
if (count($stack) <= 1) {
return $stack[0];
}
$filter = $stack[0];
$filter['operator'] = 'in';
$filter['properties']['filter'] = $filter['filter'] = array_map(function ($ele) {
return $ele['filter'];
}, $stack);
return $filter;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Mautic\LeadBundle\Segment;
use Mautic\LeadBundle\Provider\FilterOperatorProviderInterface;
class ContactSegmentFilterOperator
{
public function __construct(
private FilterOperatorProviderInterface $filterOperatorProvider,
) {
}
/**
* @param string $operator
*
* @return string
*/
public function fixOperator($operator)
{
$options = $this->filterOperatorProvider->getAllOperators();
if (empty($options[$operator])) {
return $operator;
}
$operatorDetails = $options[$operator];
return $operatorDetails['expr'];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Mautic\LeadBundle\Segment;
/**
* Array object containing filters.
*/
class ContactSegmentFilters implements \Iterator, \Countable
{
private int $position = 0;
/**
* @var array|ContactSegmentFilter[]
*/
private array $contactSegmentFilters = [];
/**
* @return $this
*/
public function addContactSegmentFilter(ContactSegmentFilter $contactSegmentFilter)
{
$this->contactSegmentFilters[] = $contactSegmentFilter;
return $this;
}
/**
* Return the current element.
*
* @see http://php.net/manual/en/iterator.current.php
*
* @return ContactSegmentFilter
*/
public function current(): mixed
{
return $this->contactSegmentFilters[$this->position];
}
/**
* Move forward to next element.
*
* @see http://php.net/manual/en/iterator.next.php
*/
public function next(): void
{
++$this->position;
}
/**
* Return the key of the current element.
*
* @see http://php.net/manual/en/iterator.key.php
*
* @return int
*/
public function key(): mixed
{
return $this->position;
}
/**
* Checks if current position is valid.
*
* @see http://php.net/manual/en/iterator.valid.php
*/
public function valid(): bool
{
return isset($this->contactSegmentFilters[$this->position]);
}
/**
* Rewind the Iterator to the first element.
*
* @see http://php.net/manual/en/iterator.rewind.php
*/
public function rewind(): void
{
$this->position = 0;
}
/**
* Count elements of an object.
*
* @see http://php.net/manual/en/countable.count.php
*/
public function count(): int
{
return count($this->contactSegmentFilters);
}
}

View File

@@ -0,0 +1,347 @@
<?php
namespace Mautic\LeadBundle\Segment;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Segment\Query\ContactSegmentQueryBuilder;
use Mautic\LeadBundle\Segment\Query\LeadBatchLimiterTrait;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
class ContactSegmentService
{
use LeadBatchLimiterTrait;
public function __construct(
private ContactSegmentFilterFactory $contactSegmentFilterFactory,
private ContactSegmentQueryBuilder $contactSegmentQueryBuilder,
private \Psr\Log\LoggerInterface $logger,
) {
}
/**
* @return array<int,mixed[]>
*
* @throws Exception\SegmentQueryException
* @throws \Doctrine\DBAL\Exception
*/
public function getNewLeadListLeadsCount(LeadList $segment, array $batchLimiters): array
{
$segmentFilters = $this->contactSegmentFilterFactory->getSegmentFilters($segment, $batchLimiters);
if (!count($segmentFilters)) {
$this->logger->debug('Segment QB: Segment has no filters', ['segmentId' => $segment->getId()]);
return [
$segment->getId() => [
'count' => '0',
'maxId' => '0',
],
];
}
$qb = $this->getNewSegmentContactsQuery($segment, $batchLimiters);
$this->addLeadAndMinMaxLimiters($qb, $batchLimiters, 'leads', 'id');
if (!empty($batchLimiters['excludeVisitors'])) {
$this->excludeVisitors($qb);
}
$qb = $this->contactSegmentQueryBuilder->wrapInCount($qb);
$this->logger->debug('Segment QB: Create SQL: '.$qb->getDebugOutput(), ['segmentId' => $segment->getId()]);
$result = $this->timedFetch($qb, $segment->getId());
return [$segment->getId() => $result];
}
/**
* @param array|null $batchLimiters for debug purpose only
*
* @throws \Exception
*/
public function getTotalLeadListLeadsCount(LeadList $segment, ?array $batchLimiters = null): array
{
$segmentFilters = $this->contactSegmentFilterFactory->getSegmentFilters($segment);
if (!count($segmentFilters)) {
$this->logger->debug('Segment QB: Segment has no filters', ['segmentId' => $segment->getId()]);
return [
$segment->getId() => [
'count' => '0',
'maxId' => '0',
],
];
}
$qb = $this->getTotalSegmentContactsQuery($segment);
if (!empty($batchLimiters['excludeVisitors'])) {
$this->excludeVisitors($qb);
}
$qb = $this->contactSegmentQueryBuilder->wrapInCount($qb);
$this->logger->debug('Segment QB: Create SQL: '.$qb->getDebugOutput(), ['segmentId' => $segment->getId()]);
$result = $this->timedFetch($qb, $segment->getId());
return [$segment->getId() => $result];
}
/**
* @param int $limit
*
* @return array<int,mixed[]>
*
* @throws \Doctrine\DBAL\Exception
* @throws Exception\SegmentQueryException
*/
public function getNewLeadListLeads(LeadList $segment, array $batchLimiters, $limit = 1000): array
{
$queryBuilder = $this->getNewLeadListLeadsQueryBuilder($segment, $batchLimiters);
$queryBuilder->setMaxResults($limit);
$result = $this->timedFetchAll($queryBuilder, $segment->getId());
return [$segment->getId() => $result];
}
/**
* @param mixed[] $batchLimiters
*/
public function getNewLeadListLeadsQueryBuilder(LeadList $segment, array $batchLimiters, bool $addNewContactsRestrictions = true): QueryBuilder
{
$queryBuilder = $this->getNewSegmentContactsQuery($segment, $batchLimiters, $addNewContactsRestrictions);
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
// Prepend the DISTINCT to the beginning of the select array
$select = $queryBuilder->getQueryPart('select');
// We are removing it because we will have to add it later
// to make sure it's the first column in the query
$key = array_search($leadsTableAlias.'.id', $select);
if (false !== $key) {
unset($select[$key]);
}
// We only need to use distinct if we join other tables to the leads table
$join = $queryBuilder->getQueryPart('join');
$distinct = is_array($join) && (0 < count($join)) ? 'DISTINCT ' : '';
// Make sure that leads.id is the first column
array_unshift($select, $distinct.$leadsTableAlias.'.id');
$queryBuilder->resetQueryPart('select');
$queryBuilder->select($select);
$this->logger->debug('Segment QB: Create Leads SQL: '.$queryBuilder->getDebugOutput(), ['segmentId' => $segment->getId()]);
$this->addLeadAndMinMaxLimiters($queryBuilder, $batchLimiters, 'leads', 'id');
if (!empty($batchLimiters['dateTime'])) {
// Only leads in the list at the time of count
$queryBuilder->andWhere(
$queryBuilder->expr()->or(
$queryBuilder->expr()->lte($leadsTableAlias.'.date_added', $queryBuilder->expr()->literal($batchLimiters['dateTime'])),
$queryBuilder->expr()->isNull($leadsTableAlias.'.date_added')
)
);
}
if (!empty($batchLimiters['excludeVisitors'])) {
$this->excludeVisitors($queryBuilder);
}
return $queryBuilder;
}
/**
* @throws Exception\SegmentQueryException
* @throws \Doctrine\DBAL\Exception
*/
public function getOrphanedLeadListLeadsCount(LeadList $segment, array $batchLimiters = []): array
{
$queryBuilder = $this->getOrphanedLeadListLeadsQueryBuilder($segment, $batchLimiters);
$queryBuilder = $this->contactSegmentQueryBuilder->wrapInCount($queryBuilder);
$this->logger->debug('Segment QB: Orphan Leads Count SQL: '.$queryBuilder->getDebugOutput(), ['segmentId' => $segment->getId()]);
$result = $this->timedFetch($queryBuilder, $segment->getId());
return [$segment->getId() => $result];
}
/**
* @param int|null $limit
*
* @throws Exception\SegmentQueryException
* @throws \Doctrine\DBAL\Exception
*/
public function getOrphanedLeadListLeads(LeadList $segment, array $batchLimiters = [], $limit = null): array
{
$queryBuilder = $this->getOrphanedLeadListLeadsQueryBuilder($segment, $batchLimiters, $limit);
$this->logger->debug('Segment QB: Orphan Leads SQL: '.$queryBuilder->getDebugOutput(), ['segmentId' => $segment->getId()]);
$result = $this->timedFetchAll($queryBuilder, $segment->getId());
return [$segment->getId() => $result];
}
/**
* @param array<string, mixed> $batchLimiters
*
* @throws Exception\SegmentQueryException
* @throws \Exception
*/
private function getNewSegmentContactsQuery(LeadList $segment, array $batchLimiters = [], bool $addNewContactsRestrictions = true): QueryBuilder
{
$queryBuilder = $this->contactSegmentQueryBuilder->assembleContactsSegmentQueryBuilder(
$segment->getId(),
$this->contactSegmentFilterFactory->getSegmentFilters($segment, $batchLimiters)
);
if ($addNewContactsRestrictions) {
$queryBuilder = $this->contactSegmentQueryBuilder->addNewContactsRestrictions($queryBuilder, (int) $segment->getId(), $batchLimiters);
}
$this->contactSegmentQueryBuilder->queryBuilderGenerated($segment, $queryBuilder);
return $queryBuilder;
}
/**
* @throws Exception\SegmentQueryException
* @throws \Exception
*/
private function getTotalSegmentContactsQuery(LeadList $segment): QueryBuilder
{
$segmentFilters = $this->contactSegmentFilterFactory->getSegmentFilters($segment);
$queryBuilder = $this->contactSegmentQueryBuilder->assembleContactsSegmentQueryBuilder($segment->getId(), $segmentFilters);
$queryBuilder = $this->contactSegmentQueryBuilder->addManuallySubscribedQuery($queryBuilder, $segment->getId());
return $this->contactSegmentQueryBuilder->addManuallyUnsubscribedQuery($queryBuilder, $segment->getId());
}
/**
* @param int|null $limit
*
* @return QueryBuilder
*
* @throws Exception\SegmentQueryException
* @throws \Doctrine\DBAL\Exception
*/
public function getOrphanedLeadListLeadsQueryBuilder(LeadList $segment, array $batchLimiters = [], $limit = null)
{
$segmentFilters = $this->contactSegmentFilterFactory->getSegmentFilters($segment, $batchLimiters);
$queryBuilder = $this->contactSegmentQueryBuilder->assembleContactsSegmentQueryBuilder($segment->getId(), $segmentFilters);
$this->addLeadAndMinMaxLimiters($queryBuilder, $batchLimiters, 'leads', 'id');
$this->contactSegmentQueryBuilder->queryBuilderGenerated($segment, $queryBuilder);
$expr = $queryBuilder->expr();
$qbO = $queryBuilder->createQueryBuilder();
$qbO->select('orp.lead_id as id, orp.leadlist_id');
$qbO->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'orp');
$qbO->setParameters($queryBuilder->getParameters(), $queryBuilder->getParameterTypes());
$qbO->andWhere($expr->eq('orp.leadlist_id', ':orpsegid'));
$qbO->andWhere($expr->eq('orp.manually_added', $expr->literal(0)));
$qbO->andWhere($expr->notIn('orp.lead_id', $queryBuilder->getSQL()));
$qbO->setParameter('orpsegid', $segment->getId());
$this->addLeadAndMinMaxLimiters($qbO, $batchLimiters, 'lead_lists_leads');
if ($limit) {
$qbO->setMaxResults((int) $limit);
}
return $qbO;
}
private function excludeVisitors(QueryBuilder $queryBuilder): void
{
$leadsTableAlias = $queryBuilder->getTableAlias(MAUTIC_TABLE_PREFIX.'leads');
$queryBuilder->andWhere($queryBuilder->expr()->isNotNull($leadsTableAlias.'.date_identified'));
}
/***** DEBUG *****/
/**
* Formatting helper.
*
* @return string
*/
private function formatPeriod($inputSeconds)
{
$now = \DateTime::createFromFormat('U.u', number_format($inputSeconds, 6, '.', ''));
return $now->format('H:i:s.u');
}
/**
* @param int $segmentId
*
* @return mixed
*
* @throws \Exception
*/
private function timedFetch(QueryBuilder $qb, $segmentId)
{
try {
$start = microtime(true);
$result = $qb->executeQuery()->fetchAssociative();
$end = microtime(true) - $start;
$this->logger->debug('Segment QB: Query took: '.$this->formatPeriod($end).', Result count: '.count($result), ['segmentId' => $segmentId]);
} catch (\Exception $e) {
$this->logger->error(
'Segment QB: Query Exception: '.$e->getMessage(),
[
'query' => $qb->getSQL(),
'parameters' => $qb->getParameters(),
]
);
throw $e;
}
return $result;
}
/**
* @param int $segmentId
*
* @return mixed
*
* @throws \Exception
*/
private function timedFetchAll(QueryBuilder $qb, $segmentId)
{
try {
$start = microtime(true);
$result = $qb->executeQuery()->fetchAllAssociative();
$end = microtime(true) - $start;
$this->logger->debug(
'Segment QB: Query took: '.$this->formatPeriod($end).'ms. Result count: '.count($result),
['segmentId' => $segmentId]
);
} catch (\Exception $e) {
$this->logger->error(
'Segment QB: Query Exception: '.$e->getMessage(),
[
'query' => $qb->getSQL(),
'parameters' => $qb->getParameters(),
]
);
throw $e;
}
return $result;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Mautic\LeadBundle\Entity\RegexTrait;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\ContactSegmentFilterOperator;
use Mautic\LeadBundle\Segment\Query\Filter\BaseFilterQueryBuilder;
class BaseDecorator implements FilterDecoratorInterface
{
use RegexTrait;
public function __construct(
protected ContactSegmentFilterOperator $contactSegmentFilterOperator,
) {
}
/**
* @return string|null
*/
public function getField(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $contactSegmentFilterCrate->getField();
}
public function getTable(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
if ($contactSegmentFilterCrate->isContactType()) {
return MAUTIC_TABLE_PREFIX.'leads';
}
if ($contactSegmentFilterCrate->isCompanyType()) {
return MAUTIC_TABLE_PREFIX.'companies';
}
return '';
}
/**
* @return string
*/
public function getOperator(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
$operator = $this->contactSegmentFilterOperator->fixOperator($contactSegmentFilterCrate->getOperator());
return match ($operator) {
'startsWith', 'endsWith', 'contains' => 'like',
default => $operator,
};
}
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return BaseFilterQueryBuilder::getServiceId();
}
/**
* @param array|string $argument
*
* @return array|string
*/
public function getParameterHolder(ContactSegmentFilterCrate $contactSegmentFilterCrate, $argument)
{
if (is_array($argument)) {
$result = [];
foreach ($argument as $arg) {
$result[] = $this->getParameterHolder($contactSegmentFilterCrate, $arg);
}
return $result;
}
return ':'.$argument;
}
/**
* @return array|bool|float|string|null
*/
public function getParameterValue(ContactSegmentFilterCrate $contactSegmentFilterCrate): mixed
{
$filter = $contactSegmentFilterCrate->getFilter();
if ($contactSegmentFilterCrate->filterValueDoNotNeedAdjustment()) {
return $filter;
}
switch ($contactSegmentFilterCrate->getOperator()) {
case 'in':
case '!in':
return !is_array($filter) ? explode('|', $filter) : $filter;
case 'like':
case '!like':
return !str_contains($filter, '%') ? '%'.$filter.'%' : $filter;
case 'contains':
return '%'.$filter.'%';
case 'startsWith':
return $filter.'%';
case 'endsWith':
return '%'.$filter;
case 'regexp':
case '!regexp':
return $this->prepareRegex($filter);
case 'multiselect':
case '!multiselect':
$filter = (array) $filter;
foreach ($filter as $key => $value) {
$filter[$key] = sprintf('(([|]|^)%s([|]|$))', preg_quote($value, '/'));
}
return $filter;
}
return $filter;
}
public function getAggregateFunc(ContactSegmentFilterCrate $contactSegmentFilterCrate): bool|string
{
return false;
}
public function getWhere(ContactSegmentFilterCrate $contactSegmentFilterCrate): CompositeExpression|string|null
{
return null;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Query\Filter\ComplexRelationValueFilterQueryBuilder;
class CompanyDecorator extends BaseDecorator
{
public function getRelationJoinTable(): string
{
return MAUTIC_TABLE_PREFIX.'companies_leads';
}
public function getRelationJoinTableField(): string
{
return 'company_id';
}
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return ComplexRelationValueFilterQueryBuilder::getServiceId();
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
interface ContactDecoratorForeignInterface
{
/**
* Returns the name of a foreign contact column used in JOIN condition (usually contact_id or lead_id).
*/
public function getForeignContactColumn(ContactSegmentFilterCrate $contactSegmentFilterCrate): string;
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Mautic\LeadBundle\Exception\FilterNotFoundException;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\ContactSegmentFilterOperator;
use Mautic\LeadBundle\Services\ContactSegmentFilterDictionary;
class CustomMappedDecorator extends BaseDecorator implements ContactDecoratorForeignInterface
{
public function __construct(
ContactSegmentFilterOperator $contactSegmentFilterOperator,
protected ContactSegmentFilterDictionary $dictionary,
) {
parent::__construct($contactSegmentFilterOperator);
}
/**
* @return string|null
*/
public function getField(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
$originalField = $contactSegmentFilterCrate->getField();
try {
return $this->dictionary->getFilterProperty($originalField, 'field');
} catch (FilterNotFoundException) {
return parent::getField($contactSegmentFilterCrate);
}
}
public function getTable(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
$originalField = $contactSegmentFilterCrate->getField();
try {
return MAUTIC_TABLE_PREFIX.$this->dictionary->getFilterProperty($originalField, 'foreign_table');
} catch (FilterNotFoundException) {
return parent::getTable($contactSegmentFilterCrate);
}
}
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
$originalField = $contactSegmentFilterCrate->getField();
try {
return $this->dictionary->getFilterProperty($originalField, 'type');
} catch (FilterNotFoundException) {
return parent::getQueryType($contactSegmentFilterCrate);
}
}
public function getAggregateFunc(ContactSegmentFilterCrate $contactSegmentFilterCrate): string|bool
{
$originalField = $contactSegmentFilterCrate->getField();
try {
return $this->dictionary->getFilterProperty($originalField, 'func');
} catch (FilterNotFoundException) {
return false;
}
}
public function getWhere(ContactSegmentFilterCrate $contactSegmentFilterCrate): CompositeExpression|string|null
{
$originalField = $contactSegmentFilterCrate->getField();
try {
return $this->dictionary->getFilterProperty($originalField, 'where');
} catch (FilterNotFoundException) {
return parent::getWhere($contactSegmentFilterCrate);
}
}
/**
* Get foreign table field used in JOIN condition.
*/
public function getForeignContactColumn(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
$originalField = $contactSegmentFilterCrate->getField();
try {
return $this->dictionary->getFilterProperty($originalField, 'foreign_table_field');
} catch (FilterNotFoundException) {
return 'lead_id';
}
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\DateDecorator;
use Mautic\LeadBundle\Segment\Decorator\FilterDecoratorInterface;
abstract class DateOptionAbstract implements FilterDecoratorInterface
{
public function __construct(
protected DateDecorator $dateDecorator,
protected DateOptionParameters $dateOptionParameters,
) {
}
/**
* This function is responsible for setting date. $this->dateTimeHelper holds date with midnight today.
* Eg. +1 day for "tomorrow", -1 for yesterday etc.
*/
abstract protected function modifyBaseDate(DateTimeHelper $dateTimeHelper);
/**
* This function is responsible for date modification for between operator.
* Eg. +1 day for "today", "tomorrow" and "yesterday", +1 week for "this week", "last week", "next week" etc.
*
* @return string
*/
abstract protected function getModifierForBetweenRange();
/**
* This function returns a value if between range is needed. Could return string for like operator or array for between operator
* Eg. //LIKE 2018-01-23% for today, //LIKE 2017-12-% for last month, //LIKE 2017-% for last year, array for this week.
*
* @return string|array
*/
abstract protected function getValueForBetweenRange(DateTimeHelper $dateTimeHelper);
/**
* This function returns an operator if between range is needed. Could return like or between.
*
* @return string
*/
abstract protected function getOperatorForBetweenRange(ContactSegmentFilterCrate $leadSegmentFilterCrate);
/**
* @return string|null
*/
public function getField(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getField($contactSegmentFilterCrate);
}
/**
* @return string
*/
public function getTable(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getTable($contactSegmentFilterCrate);
}
/**
* @return string
*/
public function getOperator(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
if ($this->dateOptionParameters->isBetweenRequired()) {
return $this->getOperatorForBetweenRange($contactSegmentFilterCrate);
}
return $this->dateDecorator->getOperator($contactSegmentFilterCrate);
}
/**
* @param array|string $argument
*
* @return array|string
*/
public function getParameterHolder(ContactSegmentFilterCrate $contactSegmentFilterCrate, $argument)
{
return $this->dateDecorator->getParameterHolder($contactSegmentFilterCrate, $argument);
}
/**
* @return array|bool|float|string|null
*/
public function getParameterValue(ContactSegmentFilterCrate $contactSegmentFilterCrate): mixed
{
$dateTimeHelper = $this->dateOptionParameters->getDefaultDate();
$this->modifyBaseDate($dateTimeHelper);
$dateFormat = $this->dateOptionParameters->hasTimePart() ? 'Y-m-d H:i:s' : 'Y-m-d';
if ($this->dateOptionParameters->isBetweenRequired()) {
return $this->getValueForBetweenRange($dateTimeHelper);
}
if ($this->dateOptionParameters->shouldUseLastDayOfRange()) {
$modifier = $this->getModifierForBetweenRange();
$modifier .= ' -1 second';
$dateTimeHelper->modify($modifier);
}
return $dateTimeHelper->toLocalString($dateFormat);
}
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return $this->dateDecorator->getQueryType($contactSegmentFilterCrate);
}
public function getAggregateFunc(ContactSegmentFilterCrate $contactSegmentFilterCrate): string|bool
{
return $this->dateDecorator->getAggregateFunc($contactSegmentFilterCrate);
}
/**
* @return \Doctrine\DBAL\Query\Expression\CompositeExpression|string|null
*/
public function getWhere(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getWhere($contactSegmentFilterCrate);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\Date\Day\DateDayToday;
use Mautic\LeadBundle\Segment\Decorator\Date\Day\DateDayTomorrow;
use Mautic\LeadBundle\Segment\Decorator\Date\Day\DateDayYesterday;
use Mautic\LeadBundle\Segment\Decorator\Date\Month\DateMonthLast;
use Mautic\LeadBundle\Segment\Decorator\Date\Month\DateMonthNext;
use Mautic\LeadBundle\Segment\Decorator\Date\Month\DateMonthThis;
use Mautic\LeadBundle\Segment\Decorator\Date\Other\DateAnniversary;
use Mautic\LeadBundle\Segment\Decorator\Date\Other\DateDefault;
use Mautic\LeadBundle\Segment\Decorator\Date\Other\DateRelativeInterval;
use Mautic\LeadBundle\Segment\Decorator\Date\Week\DateWeekLast;
use Mautic\LeadBundle\Segment\Decorator\Date\Week\DateWeekNext;
use Mautic\LeadBundle\Segment\Decorator\Date\Week\DateWeekThis;
use Mautic\LeadBundle\Segment\Decorator\Date\Year\DateYearLast;
use Mautic\LeadBundle\Segment\Decorator\Date\Year\DateYearNext;
use Mautic\LeadBundle\Segment\Decorator\Date\Year\DateYearThis;
use Mautic\LeadBundle\Segment\Decorator\DateDecorator;
use Mautic\LeadBundle\Segment\Decorator\FilterDecoratorInterface;
use Mautic\LeadBundle\Segment\RelativeDate;
class DateOptionFactory
{
public function __construct(
private DateDecorator $dateDecorator,
private RelativeDate $relativeDate,
private TimezoneResolver $timezoneResolver,
) {
}
public function getDateOption(ContactSegmentFilterCrate $leadSegmentFilterCrate): FilterDecoratorInterface
{
$originalValue = $leadSegmentFilterCrate->getFilter();
$relativeDateStrings = $this->relativeDate->getRelativeDateStrings();
$dateOptionParameters = new DateOptionParameters($leadSegmentFilterCrate, $relativeDateStrings, $this->timezoneResolver);
$timeframe = $dateOptionParameters->getTimeframe();
if (!$timeframe) {
return new DateDefault($this->dateDecorator, $originalValue);
}
switch ($timeframe) {
case 'birthday':
case 'anniversary':
case $timeframe && (
str_contains($timeframe, 'anniversary')
|| str_contains($timeframe, 'birthday')
):
return new DateAnniversary($this->dateDecorator, $dateOptionParameters);
case 'today':
return new DateDayToday($this->dateDecorator, $dateOptionParameters);
case 'tomorrow':
return new DateDayTomorrow($this->dateDecorator, $dateOptionParameters);
case 'yesterday':
return new DateDayYesterday($this->dateDecorator, $dateOptionParameters);
case 'week_last':
return new DateWeekLast($this->dateDecorator, $dateOptionParameters);
case 'week_next':
return new DateWeekNext($this->dateDecorator, $dateOptionParameters);
case 'week_this':
return new DateWeekThis($this->dateDecorator, $dateOptionParameters);
case 'month_last':
return new DateMonthLast($this->dateDecorator, $dateOptionParameters);
case 'month_next':
return new DateMonthNext($this->dateDecorator, $dateOptionParameters);
case 'month_this':
return new DateMonthThis($this->dateDecorator, $dateOptionParameters);
case 'year_last':
return new DateYearLast($this->dateDecorator, $dateOptionParameters);
case 'year_next':
return new DateYearNext($this->dateDecorator, $dateOptionParameters);
case 'year_this':
return new DateYearThis($this->dateDecorator, $dateOptionParameters);
case $timeframe && (
str_contains($timeframe[0], '-') // -5 days
|| str_contains($timeframe[0], '+') // +5 days
|| false !== $this->isRelativeFormatsPresent($timeframe)
):
return new DateRelativeInterval($this->dateDecorator, $originalValue, $dateOptionParameters);
default:
return new DateDefault($this->dateDecorator, $originalValue);
}
}
protected function isRelativeFormatsPresent(string $timeframe): bool
{
$notations = [
'first day of ', // first day of January 2021
'last day of ', // last day of January 2021
' ago', // 5 days ago
];
foreach ($notations as $notation) {
if (str_contains($timeframe, $notation)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
class DateOptionParameters
{
private bool $hasTimePart;
/**
* @var mixed
*/
private $timeframe;
private bool $requiresBetween;
private bool $shouldUseLastDayOfRange;
private DateTimeHelper $dateTimeHelper;
public function __construct(
ContactSegmentFilterCrate $leadSegmentFilterCrate,
array $relativeDateStrings,
TimezoneResolver $timezoneResolver,
) {
$this->hasTimePart = $leadSegmentFilterCrate->hasTimeParts();
$this->timeframe = $this->parseTimeFrame($leadSegmentFilterCrate, $relativeDateStrings);
$this->requiresBetween = in_array($leadSegmentFilterCrate->getOperator(), ['=', '!='], true);
$this->shouldUseLastDayOfRange = in_array($leadSegmentFilterCrate->getOperator(), ['gt', 'lte'], true);
$this->setDateTimeHelper($timezoneResolver);
}
public function hasTimePart(): bool
{
return $this->hasTimePart;
}
/**
* @return string
*/
public function getTimeframe()
{
return $this->timeframe;
}
public function isBetweenRequired(): bool
{
return $this->requiresBetween;
}
/**
* This function indicates that we need to modify date to the last date of range.
* "Less than or equal" operator means that we need to include whole week / month / year > last day from range
* "Grater than" needs same logic.
*/
public function shouldUseLastDayOfRange(): bool
{
return $this->shouldUseLastDayOfRange;
}
/**
* @return DateTimeHelper
*/
public function getDefaultDate()
{
return $this->dateTimeHelper;
}
/**
* @return string|mixed
*/
private function parseTimeFrame(ContactSegmentFilterCrate $leadSegmentFilterCrate, array $relativeDateStrings)
{
$key = array_search($leadSegmentFilterCrate->getFilter(), $relativeDateStrings, true);
if (false === $key) {
// Time frame does not match any option from $relativeDateStrings, so return original value
return $leadSegmentFilterCrate->getFilter();
}
return str_replace('mautic.lead.list.', '', $key);
}
private function setDateTimeHelper(TimezoneResolver $timezoneResolver): void
{
$this->dateTimeHelper = $timezoneResolver->getDefaultDate($this->hasTimePart());
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Day;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\Date\DateOptionAbstract;
abstract class DateDayAbstract extends DateOptionAbstract
{
/**
* @return string
*/
protected function getModifierForBetweenRange()
{
return '+1 day';
}
protected function getValueForBetweenRange(DateTimeHelper $dateTimeHelper)
{
return $dateTimeHelper->toLocalString('Y-m-d%');
}
protected function getOperatorForBetweenRange(ContactSegmentFilterCrate $leadSegmentFilterCrate)
{
return '!=' === $leadSegmentFilterCrate->getOperator() ? 'notLike' : 'like';
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Day;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateDayToday extends DateDayAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->modify('midnight today');
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Day;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateDayTomorrow extends DateDayAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->modify('midnight tomorrow');
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Day;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateDayYesterday extends DateDayAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->modify('midnight yesterday');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Month;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\Date\DateOptionAbstract;
abstract class DateMonthAbstract extends DateOptionAbstract
{
/**
* @return string
*/
protected function getModifierForBetweenRange()
{
return '+1 month';
}
protected function getValueForBetweenRange(DateTimeHelper $dateTimeHelper)
{
return $dateTimeHelper->toLocalString('Y-m-%');
}
protected function getOperatorForBetweenRange(ContactSegmentFilterCrate $leadSegmentFilterCrate)
{
return '!=' === $leadSegmentFilterCrate->getOperator() ? 'notLike' : 'like';
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Month;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateMonthLast extends DateMonthAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight first day of last month', null);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Month;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateMonthNext extends DateMonthAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight first day of next month', null);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Month;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateMonthThis extends DateMonthAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight first day of this month', null);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Other;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\Date\DateOptionParameters;
use Mautic\LeadBundle\Segment\Decorator\DateDecorator;
use Mautic\LeadBundle\Segment\Decorator\FilterDecoratorInterface;
class DateAnniversary implements FilterDecoratorInterface
{
public function __construct(
private DateDecorator $dateDecorator,
private DateOptionParameters $dateOptionParameters,
) {
}
/**
* @return string|null
*/
public function getField(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getField($contactSegmentFilterCrate);
}
public function getTable(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return $this->dateDecorator->getTable($contactSegmentFilterCrate);
}
public function getOperator(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return 'like';
}
/**
* @param array|string $argument
*
* @return array|string
*/
public function getParameterHolder(ContactSegmentFilterCrate $contactSegmentFilterCrate, $argument)
{
return $this->dateDecorator->getParameterHolder($contactSegmentFilterCrate, $argument);
}
/**
* @return array|bool|float|string|null
*/
public function getParameterValue(ContactSegmentFilterCrate $contactSegmentFilterCrate): mixed
{
$date = $this->dateOptionParameters->getDefaultDate();
$filter = $contactSegmentFilterCrate->getFilter();
$relativeFilter = is_string($filter) ? trim(str_replace(['anniversary', 'birthday'], '', $filter)) : $filter;
if ($relativeFilter) {
$date->modify($relativeFilter);
}
return $date->toLocalString('%-m-d%');
}
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return $this->dateDecorator->getQueryType($contactSegmentFilterCrate);
}
public function getAggregateFunc(ContactSegmentFilterCrate $contactSegmentFilterCrate): string|bool
{
return $this->dateDecorator->getAggregateFunc($contactSegmentFilterCrate);
}
public function getWhere(ContactSegmentFilterCrate $contactSegmentFilterCrate): CompositeExpression|string|null
{
return $this->dateDecorator->getWhere($contactSegmentFilterCrate);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Other;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\DateDecorator;
use Mautic\LeadBundle\Segment\Decorator\FilterDecoratorInterface;
class DateDefault implements FilterDecoratorInterface
{
/**
* @param string $originalValue
*/
public function __construct(
private DateDecorator $dateDecorator,
private $originalValue,
) {
}
/**
* @return string|null
*/
public function getField(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getField($contactSegmentFilterCrate);
}
public function getTable(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return $this->dateDecorator->getTable($contactSegmentFilterCrate);
}
/**
* @return string
*/
public function getOperator(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getOperator($contactSegmentFilterCrate);
}
/**
* @param array|string $argument
*
* @return array|string
*/
public function getParameterHolder(ContactSegmentFilterCrate $contactSegmentFilterCrate, $argument)
{
return $this->dateDecorator->getParameterHolder($contactSegmentFilterCrate, $argument);
}
/**
* @return array|bool|float|string|null
*/
public function getParameterValue(ContactSegmentFilterCrate $contactSegmentFilterCrate): mixed
{
$filter = $this->originalValue;
return match ($contactSegmentFilterCrate->getOperator()) {
'like', '!like' => !str_contains($filter, '%') ? '%'.$filter.'%' : $filter,
'contains' => '%'.$filter.'%',
'startsWith' => $filter.'%',
'endsWith' => '%'.$filter,
default => $this->originalValue,
};
}
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return $this->dateDecorator->getQueryType($contactSegmentFilterCrate);
}
public function getAggregateFunc(ContactSegmentFilterCrate $contactSegmentFilterCrate): string|bool
{
return $this->dateDecorator->getAggregateFunc($contactSegmentFilterCrate);
}
public function getWhere(ContactSegmentFilterCrate $contactSegmentFilterCrate): CompositeExpression|string|null
{
return $this->dateDecorator->getWhere($contactSegmentFilterCrate);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Other;
use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\Date\DateOptionParameters;
use Mautic\LeadBundle\Segment\Decorator\DateDecorator;
use Mautic\LeadBundle\Segment\Decorator\FilterDecoratorInterface;
class DateRelativeInterval implements FilterDecoratorInterface
{
/**
* @param string $originalValue
*/
public function __construct(
private DateDecorator $dateDecorator,
private $originalValue,
private DateOptionParameters $dateOptionParameters,
) {
}
/**
* @return string|null
*/
public function getField(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getField($contactSegmentFilterCrate);
}
public function getTable(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return $this->dateDecorator->getTable($contactSegmentFilterCrate);
}
/**
* @return string
*/
public function getOperator(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
if ('=' === $contactSegmentFilterCrate->getOperator()) {
return 'like';
}
if ('!=' === $contactSegmentFilterCrate->getOperator()) {
return 'notLike';
}
return $this->dateDecorator->getOperator($contactSegmentFilterCrate);
}
/**
* @param array|string $argument
*
* @return array|string
*/
public function getParameterHolder(ContactSegmentFilterCrate $contactSegmentFilterCrate, $argument)
{
return $this->dateDecorator->getParameterHolder($contactSegmentFilterCrate, $argument);
}
/**
* @return array|bool|float|string|null
*/
public function getParameterValue(ContactSegmentFilterCrate $contactSegmentFilterCrate): mixed
{
$date = $this->dateOptionParameters->getDefaultDate();
$date->modify($this->originalValue);
$operator = $this->getOperator($contactSegmentFilterCrate);
$format = 'Y-m-d';
if ('like' === $operator || 'notLike' === $operator) {
$format .= '%';
}
return $date->toLocalString($format);
}
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return $this->dateDecorator->getQueryType($contactSegmentFilterCrate);
}
public function getAggregateFunc(ContactSegmentFilterCrate $contactSegmentFilterCrate): string|bool
{
return $this->dateDecorator->getAggregateFunc($contactSegmentFilterCrate);
}
public function getWhere(ContactSegmentFilterCrate $contactSegmentFilterCrate): CompositeExpression|string|null
{
return $this->dateDecorator->getWhere($contactSegmentFilterCrate);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class TimezoneResolver
{
public function __construct(
private CoreParametersHelper $coreParametersHelper,
) {
}
/**
* @param bool $hasTimePart
*/
public function getDefaultDate($hasTimePart): DateTimeHelper
{
/**
* $hasTimePart tells us if field in a database is date or datetime
* All datetime fields are stored in UTC
* Date field, however, is always stored in a local time (there is no time information, so it cannot be converted to UTC).
*
* We will generate default date according to this. We need midnight as a default date (for relative intervals like "today" or "-1 day"
* 1) in UTC for datetime fields
* 2) in the local timezone for date fields
*
* Later we use toLocalString() method - it gives us midnight in UTC for first condition and midnight in local timezone for second option.
*/
$timezone = $hasTimePart ? 'UTC' : $this->coreParametersHelper->getDefaultTimezone();
$date = new \DateTime('midnight today', new \DateTimeZone($timezone));
return new DateTimeHelper($date, null, $timezone);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Week;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\Date\DateOptionAbstract;
abstract class DateWeekAbstract extends DateOptionAbstract
{
/**
* @return string
*/
protected function getModifierForBetweenRange()
{
return '+1 week';
}
protected function getValueForBetweenRange(DateTimeHelper $dateTimeHelper)
{
$dateFormat = $this->dateOptionParameters->hasTimePart() ? 'Y-m-d H:i:s' : 'Y-m-d';
$startWith = $dateTimeHelper->toLocalString($dateFormat);
$modifier = $this->getModifierForBetweenRange().' -1 second';
$dateTimeHelper->modify($modifier);
$endWith = $dateTimeHelper->toLocalString($dateFormat);
return [$startWith, $endWith];
}
protected function getOperatorForBetweenRange(ContactSegmentFilterCrate $leadSegmentFilterCrate)
{
return '!=' === $leadSegmentFilterCrate->getOperator() ? 'notBetween' : 'between';
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Week;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateWeekLast extends DateWeekAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight monday last week', null);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Week;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateWeekNext extends DateWeekAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight monday next week', null);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Week;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateWeekThis extends DateWeekAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight monday this week', null);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Year;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\Date\DateOptionAbstract;
abstract class DateYearAbstract extends DateOptionAbstract
{
/**
* @return string
*/
protected function getModifierForBetweenRange()
{
return '+1 year';
}
protected function getValueForBetweenRange(DateTimeHelper $dateTimeHelper)
{
return $dateTimeHelper->toLocalString('Y-%');
}
protected function getOperatorForBetweenRange(ContactSegmentFilterCrate $leadSegmentFilterCrate)
{
return '!=' === $leadSegmentFilterCrate->getOperator() ? 'notLike' : 'like';
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Year;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateYearLast extends DateYearAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight first day of January last year', null);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Year;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateYearNext extends DateYearAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight first day of January next year', null);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator\Date\Year;
use Mautic\CoreBundle\Helper\DateTimeHelper;
class DateYearThis extends DateYearAbstract
{
protected function modifyBaseDate(DateTimeHelper $dateTimeHelper)
{
$dateTimeHelper->setDateTime('midnight first day of January this year', null);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Query\Filter\ComplexRelationValueFilterQueryBuilder;
class DateCompanyDecorator implements FilterDecoratorInterface
{
public function __construct(
private FilterDecoratorInterface $dateDecorator,
) {
}
/**
* @return string|null
*/
public function getField(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getField($contactSegmentFilterCrate);
}
/**
* @return string
*/
public function getTable(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getTable($contactSegmentFilterCrate);
}
/**
* @return string
*/
public function getOperator(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getOperator($contactSegmentFilterCrate);
}
/**
* @param array|string $argument
*
* @return array|string
*/
public function getParameterHolder(ContactSegmentFilterCrate $contactSegmentFilterCrate, $argument)
{
return $this->dateDecorator->getParameterHolder($contactSegmentFilterCrate, $argument);
}
/**
* @return array|bool|float|string|null
*/
public function getParameterValue(ContactSegmentFilterCrate $contactSegmentFilterCrate): mixed
{
return $this->dateDecorator->getParameterValue($contactSegmentFilterCrate);
}
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate): string
{
return ComplexRelationValueFilterQueryBuilder::getServiceId();
}
public function getAggregateFunc(ContactSegmentFilterCrate $contactSegmentFilterCrate): string|bool
{
return $this->dateDecorator->getAggregateFunc($contactSegmentFilterCrate);
}
/**
* @return \Doctrine\DBAL\Query\Expression\CompositeExpression|string|null
*/
public function getWhere(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
return $this->dateDecorator->getWhere($contactSegmentFilterCrate);
}
public function getRelationJoinTable(): string
{
return MAUTIC_TABLE_PREFIX.'companies_leads';
}
public function getRelationJoinTableField(): string
{
return 'company_id';
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
class DateDecorator extends CustomMappedDecorator
{
/**
* @throws \Exception
*/
public function getParameterValue(ContactSegmentFilterCrate $contactSegmentFilterCrate): mixed
{
throw new \Exception('Instance of Date option needs to implement this function');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator;
use Mautic\LeadBundle\Event\LeadListFiltersDecoratorDelegateEvent;
use Mautic\LeadBundle\Exception\FilterNotFoundException;
use Mautic\LeadBundle\LeadEvents;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
use Mautic\LeadBundle\Segment\Decorator\Date\DateOptionFactory;
use Mautic\LeadBundle\Services\ContactSegmentFilterDictionary;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class DecoratorFactory
{
public function __construct(
private ContactSegmentFilterDictionary $contactSegmentFilterDictionary,
private BaseDecorator $baseDecorator,
private CustomMappedDecorator $customMappedDecorator,
private DateOptionFactory $dateOptionFactory,
private CompanyDecorator $companyDecorator,
private EventDispatcherInterface $eventDispatcher,
) {
}
/**
* @return FilterDecoratorInterface
*/
public function getDecoratorForFilter(ContactSegmentFilterCrate $contactSegmentFilterCrate)
{
$decoratorEvent = new LeadListFiltersDecoratorDelegateEvent($contactSegmentFilterCrate);
$this->eventDispatcher->dispatch($decoratorEvent, LeadEvents::SEGMENT_ON_DECORATOR_DELEGATE);
if ($decorator = $decoratorEvent->getDecorator()) {
return $decorator;
}
if ($contactSegmentFilterCrate->isDateType()) {
$dateDecorator = $this->dateOptionFactory->getDateOption($contactSegmentFilterCrate);
if ($contactSegmentFilterCrate->isCompanyType()) {
return new DateCompanyDecorator($dateDecorator);
}
return $dateDecorator;
}
$originalField = $contactSegmentFilterCrate->getField();
try {
$this->contactSegmentFilterDictionary->getFilter($originalField);
return $this->customMappedDecorator;
} catch (FilterNotFoundException) {
if ($contactSegmentFilterCrate->isCompanyType()) {
return $this->companyDecorator;
}
return $this->baseDecorator;
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Mautic\LeadBundle\Segment\Decorator;
use Mautic\LeadBundle\Segment\ContactSegmentFilterCrate;
interface FilterDecoratorInterface
{
/**
* Returns filter's field (usually a column name in DB).
*
* @return string|null
*/
public function getField(ContactSegmentFilterCrate $contactSegmentFilterCrate);
/**
* Returns DB table.
*
* @return string
*/
public function getTable(ContactSegmentFilterCrate $contactSegmentFilterCrate);
/**
* Returns a string operator (like, eq, neq, ...).
*
* @return string
*/
public function getOperator(ContactSegmentFilterCrate $contactSegmentFilterCrate);
/**
* Returns an argument for QueryBuilder (usually ':arg' in case that $argument is equal to 'arg' string.
*
* @param array|string $argument
*
* @return array|string
*/
public function getParameterHolder(ContactSegmentFilterCrate $contactSegmentFilterCrate, $argument);
/**
* Returns formatted value for QueryBuilder ('%value%' for 'like', '%value' for 'Ends with', SQL-formatted date etc.).
*
* @return array|bool|float|string|null
*/
public function getParameterValue(ContactSegmentFilterCrate $contactSegmentFilterCrate);
/**
* Returns QueryBuilder's service name from the container.
*
* @return string
*/
public function getQueryType(ContactSegmentFilterCrate $contactSegmentFilterCrate);
/**
* Returns a name of aggregation function for SQL (SUM, COUNT etc.)
* Returns false if no aggregation function is needed.
*
* @return string|bool if no func needed
*/
public function getAggregateFunc(ContactSegmentFilterCrate $contactSegmentFilterCrate): string|bool;
/**
* Returns a special where condition which is needed to be added to QueryBuilder (like email_stats.is_read = 1 for 'Read emails')
* Returns null if no special condition is needed.
*
* @return \Doctrine\DBAL\Query\Expression\CompositeExpression|string|null
*/
public function getWhere(ContactSegmentFilterCrate $contactSegmentFilterCrate);
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Mautic\LeadBundle\Segment\DoNotContact;
use Mautic\LeadBundle\Entity\DoNotContact;
class DoNotContactParts
{
private string $channel = 'email';
private int $type = DoNotContact::UNSUBSCRIBED;
public function __construct(?string $field)
{
if ($field && str_contains($field, '_manual')) {
$this->type = DoNotContact::MANUAL;
}
if ($field && str_contains($field, '_bounced')) {
$this->type = DoNotContact::BOUNCED;
}
if ($field && str_contains($field, '_sms')) {
$this->channel = 'sms';
}
}
public function getChannel(): string
{
return $this->channel;
}
public function getParameterType(): int
{
return $this->type;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\LeadBundle\Segment\Exception;
class FieldNotFoundException extends \Exception
{
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Mautic\LeadBundle\Segment\Exception;
/**
* This exception is risen if functionality requested does not belong to give FilterQueryBuilder.
*/
class InvalidUseException extends SegmentQueryException
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\LeadBundle\Segment\Exception;
class PluginHandledFilterException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\LeadBundle\Segment\Exception;
class SegmentNotFoundException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Mautic\LeadBundle\Segment\Exception;
use Doctrine\DBAL\Query\QueryException;
class SegmentQueryException extends QueryException
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\LeadBundle\Segment\Exception;
class TableNotFoundException extends SegmentQueryException
{
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\LeadBundle\Segment\IntegrationCampaign;
class IntegrationCampaignParts
{
private string $integrationName;
private string $campaignId;
/**
* @param string $field
*/
public function __construct($field)
{
if (str_contains($field, '::')) {
[$integrationName, $campaignId] = explode('::', $field);
} else {
// Assuming this is a Salesforce integration for BC with pre 2.11.0
$integrationName = 'Salesforce';
$campaignId = $field;
}
$this->integrationName = $integrationName;
$this->campaignId = $campaignId;
}
public function getIntegrationName(): string
{
return $this->integrationName;
}
public function getCampaignId(): string
{
return $this->campaignId;
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace Mautic\LeadBundle\Segment;
class OperatorOptions
{
public const EQUAL_TO = '=';
public const NOT_EQUAL_TO = '!=';
public const GREATER_THAN = 'gt';
public const GREATER_THAN_OR_EQUAL = 'gte';
public const LESS_THAN = 'lt';
public const LESS_THAN_OR_EQUAL = 'lte';
public const EMPTY = 'empty';
public const NOT_EMPTY = '!empty';
public const LIKE = 'like';
public const NOT_LIKE = '!like';
public const BETWEEN = 'between';
public const NOT_BETWEEN = '!between';
/**
* @deprecated Use OperatorOptions::INCLUDING_ANY
*/
public const IN = 'in';
/**
* @deprecated Use OperatorOptions::EXCLUDING_ANY
*/
public const NOT_IN = '!in';
public const INCLUDING_ANY = 'in';
public const EXCLUDING_ANY = '!in';
public const INCLUDING_ALL = 'in_all';
public const EXCLUDING_ALL = '!in_all';
public const REGEXP = 'regexp';
public const NOT_REGEXP = '!regexp';
public const DATE = 'date';
public const STARTS_WITH = 'startsWith';
public const ENDS_WITH = 'endsWith';
public const CONTAINS = 'contains';
/**
* @var array<string,array<string,string|bool>>
*/
private static array $operatorOptions = [
self::EQUAL_TO => [
'label' => 'mautic.lead.list.form.operator.equals',
'expr' => 'eq',
'negate_expr' => 'neq',
],
self::NOT_EQUAL_TO => [
'label' => 'mautic.lead.list.form.operator.notequals',
'expr' => 'neq',
'negate_expr' => 'eq',
],
self::GREATER_THAN => [
'label' => 'mautic.lead.list.form.operator.greaterthan',
'expr' => 'gt',
'negate_expr' => 'lt',
],
self::GREATER_THAN_OR_EQUAL => [
'label' => 'mautic.lead.list.form.operator.greaterthanequals',
'expr' => 'gte',
'negate_expr' => 'lt',
],
self::LESS_THAN => [
'label' => 'mautic.lead.list.form.operator.lessthan',
'expr' => 'lt',
'negate_expr' => 'gt',
],
self::LESS_THAN_OR_EQUAL => [
'label' => 'mautic.lead.list.form.operator.lessthanequals',
'expr' => 'lte',
'negate_expr' => 'gt',
],
self::EMPTY => [
'label' => 'mautic.lead.list.form.operator.isempty',
'expr' => 'empty', // special case
'negate_expr' => 'notEmpty',
],
self::NOT_EMPTY => [
'label' => 'mautic.lead.list.form.operator.isnotempty',
'expr' => 'notEmpty', // special case
'negate_expr' => 'empty',
],
self::LIKE => [
'label' => 'mautic.lead.list.form.operator.islike',
'expr' => 'like',
'negate_expr' => 'notLike',
],
self::NOT_LIKE => [
'label' => 'mautic.lead.list.form.operator.isnotlike',
'expr' => 'notLike',
'negate_expr' => 'like',
],
self::BETWEEN => [
'label' => 'mautic.lead.list.form.operator.between',
'expr' => 'between', // special case
'negate_expr' => 'notBetween',
'hide' => true,
],
self::NOT_BETWEEN => [
'label' => 'mautic.lead.list.form.operator.notbetween',
'expr' => 'notBetween', // special case
'negate_expr' => 'between',
'hide' => true,
],
self::INCLUDING_ANY => [
'label' => 'mautic.lead.list.form.operator.in',
'expr' => 'in',
'negate_expr' => 'notIn',
],
self::EXCLUDING_ANY => [
'label' => 'mautic.lead.list.form.operator.notin',
'expr' => 'notIn',
'negate_expr' => 'in',
],
self::INCLUDING_ALL => [
'label' => 'mautic.lead.list.form.operator.in_all',
'expr' => self::INCLUDING_ALL,
'negate_expr' => self::EXCLUDING_ALL,
],
self::EXCLUDING_ALL => [
'label' => 'mautic.lead.list.form.operator.notin_all',
'expr' => self::EXCLUDING_ALL,
'negate_expr' => self::INCLUDING_ALL,
],
self::REGEXP => [
'label' => 'mautic.lead.list.form.operator.regexp',
'expr' => 'regexp', // special case
'negate_expr' => 'notRegexp',
],
self::NOT_REGEXP => [
'label' => 'mautic.lead.list.form.operator.notregexp',
'expr' => 'notRegexp', // special case
'negate_expr' => 'regexp',
],
self::DATE => [
'label' => 'mautic.lead.list.form.operator.date',
'expr' => 'date', // special case
'negate_expr' => 'date',
'hide' => true,
],
self::STARTS_WITH => [
'label' => 'mautic.core.operator.starts.with',
'expr' => 'startsWith',
'negate_expr' => 'startsWith',
],
self::ENDS_WITH => [
'label' => 'mautic.core.operator.ends.with',
'expr' => 'endsWith',
'negate_expr' => 'endsWith',
],
self::CONTAINS => [
'label' => 'mautic.core.operator.contains',
'expr' => 'contains',
'negate_expr' => 'contains',
],
];
/**
* @return array<string,array<string,string>>
*/
public static function getFilterExpressionFunctions()
{
return self::$operatorOptions;
}
/**
* @return array<string,array<string,string>>
*/
public function getFilterExpressionFunctionsNonStatic()
{
return self::$operatorOptions;
}
}

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).'.');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Mautic\LeadBundle\Segment;
class RandomParameterName
{
/**
* @var int
*/
protected $lastUsedParameterId = 0;
/**
* Generate a unique parameter name from int using base conversion.
* This eliminates chance for parameter name collision and provides unique result for each number.
*
* @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;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Mautic\LeadBundle\Segment;
use Symfony\Contracts\Translation\TranslatorInterface;
class RelativeDate
{
public function __construct(
private TranslatorInterface $translator,
) {
}
public function getRelativeDateStrings(): array
{
$keys = $this->getRelativeDateTranslationKeys();
$strings = [];
foreach ($keys as $key) {
$strings[$key] = $this->translator->trans($key);
}
return $strings;
}
private function getRelativeDateTranslationKeys(): array
{
return [
'mautic.lead.list.month_last',
'mautic.lead.list.month_next',
'mautic.lead.list.month_this',
'mautic.lead.list.today',
'mautic.lead.list.tomorrow',
'mautic.lead.list.yesterday',
'mautic.lead.list.week_last',
'mautic.lead.list.week_next',
'mautic.lead.list.week_this',
'mautic.lead.list.year_last',
'mautic.lead.list.year_next',
'mautic.lead.list.year_this',
'mautic.lead.list.anniversary',
];
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Mautic\LeadBundle\Segment;
trait SegmentFilterIconTrait
{
public function getSegmentFilterIcon(string $filterType): string
{
return match ($filterType) {
// lead
'address1' => 'ri-home-2-line',
'address2' => 'ri-home-3-line',
'attribution' => 'ri-cash-line',
'attribution_date' => 'ri-calendar-event-line',
'dnc_bounced' => 'ri-mail-close-line',
'dnc_bounced_sms' => 'ri-chat-delete-line',
'campaign' => 'ri-megaphone-line',
'city' => 'ri-building-2-line',
'country' => 'ri-earth-line',
'date_added' => 'ri-calendar-check-line',
'date_identified' => 'ri-calendar-todo-line',
'last_active' => 'ri-time-line',
'device_brand' => 'ri-smartphone-line',
'device_model' => 'ri-device-line',
'device_os' => 'ri-window-2-line',
'device_type' => 'ri-computer-line',
'email' => 'ri-mail-line',
'generated_email_domain' => 'ri-at-line',
'facebook' => 'ri-facebook-box-line',
'fax' => 'ri-printer-line',
'firstname' => 'ri-user-line',
'foursquare' => 'ri-map-pin-user-line',
'instagram' => 'ri-instagram-line',
'lastname' => 'ri-user-2-line',
'mobile' => 'ri-smartphone-line',
'date_modified' => 'ri-calendar-event-line',
'owner_id' => 'ri-user-star-line',
'phone' => 'ri-phone-line',
'points' => 'ri-coins-line',
'position' => 'ri-briefcase-4-line',
'preferred_locale' => 'ri-translate-2',
'timezone' => 'ri-time-zone-line',
'company' => 'ri-building-4-line',
'leadlist' => 'ri-list-check-2',
'skype' => 'ri-skype-line',
'stage' => 'ri-barricade-line',
'state' => 'ri-map-pin-2-line',
'globalcategory' => 'ri-folder-2-line',
'tags' => 'ri-hashtag',
'title' => 'ri-user-star-line',
'twitter' => 'ri-twitter-x-line',
'utm_campaign' => 'ri-bookmark-2-line',
'utm_content' => 'ri-file-text-line',
'utm_medium' => 'ri-share-line',
'utm_source' => 'ri-link',
'utm_term' => 'ri-hashtag',
'dnc_unsubscribed' => 'ri-forbid-2-line',
'dnc_unsubscribed_sms' => 'ri-forbid-2-line',
'dnc_manual_email' => 'ri-mail-forbid-line',
'dnc_manual_sms' => 'ri-chat-off-line',
'website' => 'ri-global-line',
'zipcode' => 'ri-mail-send-line',
'linkedin' => 'ri-linkedin-box-line',
// company
'companyaddress1' => 'ri-building-line',
'companyaddress2' => 'ri-building-2-line',
'companyannual_revenue' => 'ri-money-dollar-circle-line',
'companycity' => 'ri-building-4-line',
'companyemail' => 'ri-mail-line',
'companyname' => 'ri-building-3-line',
'companycountry' => 'ri-global-line',
'companydescription' => 'ri-file-text-line',
'companyfax' => 'ri-printer-line',
'companyindustry' => 'ri-briefcase-4-line',
'companynumber_of_employees' => 'ri-team-line',
'companyphone' => 'ri-phone-line',
'companystate' => 'ri-map-pin-2-line',
'companywebsite' => 'ri-global-line',
'companyzipcode' => 'ri-mail-send-line',
// behaviors
'redirect_id' => 'ri-cursor-line',
'email_id' => 'ri-cursor-line',
'email_clicked_link_date' => 'ri-cursor-line',
'sms_clicked_link' => 'ri-cursor-line',
'sms_clicked_link_date' => 'ri-cursor-line',
'lead_asset_download' => 'ri-download-2-line',
'sessions' => 'ri-timer-line',
'notification' => 'ri-notification-3-line',
'lead_email_received' => 'ri-mail-open-line',
'lead_email_read_date' => 'ri-mail-open-line',
'lead_email_read_count' => 'ri-mail-open-line',
'lead_email_sent_date' => 'ri-send-plane-2-line',
'hit_url' => 'ri-external-link-line',
'page_id' => 'ri-external-link-line',
'hit_url_date' => 'ri-external-link-line',
'hit_url_count' => 'ri-external-link-line',
'referer' => 'ri-external-link-line',
'source' => 'ri-external-link-line',
'source_id' => 'ri-external-link-line',
'url_title' => 'ri-external-link-line',
'lead_email_sent' => 'ri-mail-send-line',
default => 'ri-shapes-line',
};
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace Mautic\LeadBundle\Segment\Stat\ChartQuery;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Helper\ArrayHelper;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\LeadBundle\Entity\LeadEventLog;
use Mautic\LeadBundle\Segment\Exception\SegmentNotFoundException;
class SegmentContactsLineChartQuery extends ChartQuery
{
/**
* @var int
*/
private $segmentId;
private ?array $addedEventLogStats = null;
private ?array $removedEventLogStats = null;
/**
* @param string|null $unit
*
* @throws SegmentNotFoundException
*/
public function __construct(
Connection $connection,
\DateTime $dateFrom,
\DateTime $dateTo,
private array $filters = [],
$unit = null,
) {
$this->connection = $connection;
$this->dateFrom = $dateFrom;
$this->dateTo = $dateTo;
$this->unit = $unit;
if (!isset($this->filters['leadlist_id']['value'])) {
throw new SegmentNotFoundException('Segment ID required');
}
$this->segmentId = $this->filters['leadlist_id']['value'];
parent::__construct($connection, $dateFrom, $dateTo, $unit);
}
public function setDateRange(\DateTimeInterface $dateFrom, \DateTimeInterface $dateTo): void
{
parent::setDateRange($dateFrom, $dateTo);
$this->init();
}
public function getTotalStats(int $total): array
{
$totalCountDateTo = $this->getTotalToDateRange($total);
// count array SUM and then reverse
// require start from end and substract added/removed logs
$sums = array_reverse(ArrayHelper::sub($this->getAddedEventLogStats(), $this->getRemovedEventLogStats()));
$totalSum = 0;
$totals = array_map(function ($sum) use ($totalCountDateTo, &$totalSum) {
$total = $totalCountDateTo - $totalSum;
$totalSum += $sum;
if ($total > -1) {
return $total;
} else {
return 0;
}
}, $sums);
return array_reverse($totals);
}
/**
* Return total of contact to date end of graph.
*/
private function getTotalToDateRange(int $total): int
{
$queryForTotal = clone $this;
// try figure out total count in dateTo
$queryForTotal->setDateRange($this->dateTo, new \DateTime());
return $total - array_sum(ArrayHelper::sub($queryForTotal->getAddedEventLogStats(), $queryForTotal->getRemovedEventLogStats()));
}
/**
* Get data about add/remove from segment based on LeadEventLog.
*
* @param string $action
*/
public function getDataFromLeadEventLog($action): array
{
$qb = $this->prepareTimeDataQuery(
'lead_event_log',
'date_added',
[
'object' => 'segment',
'bundle' => 'lead',
'action' => $action,
'object_id' => $this->segmentId,
]
);
$qb = $this->optimizeSearchInLeadEventLog($qb);
return $this->loadAndBuildTimeData($qb);
}
/**
* @return int
*/
public function getSegmentId()
{
return $this->segmentId;
}
/**
* @return array
*/
public function getAddedEventLogStats()
{
return $this->addedEventLogStats;
}
/**
* @return array
*/
public function getRemovedEventLogStats()
{
return $this->removedEventLogStats;
}
/**
* Init basic stats.
*/
private function init(): void
{
$this->addedEventLogStats = $this->getDataFromLeadEventLog('added');
$this->removedEventLogStats = $this->getDataFromLeadEventLog('removed');
}
private function optimizeSearchInLeadEventLog(QueryBuilder $qb): QueryBuilder
{
$fromPart = $qb->getQueryPart('from');
$fromPart[0]['alias'] = sprintf('%s USE INDEX (%s)', $fromPart[0]['alias'], MAUTIC_TABLE_PREFIX.LeadEventLog::INDEX_SEARCH);
$qb->resetQueryPart('from');
$qb->from($fromPart[0]['table'], $fromPart[0]['alias']);
return $qb;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Mautic\LeadBundle\Segment\Stat;
use Doctrine\ORM\EntityManager;
use Mautic\CampaignBundle\Model\CampaignModel;
use Mautic\CoreBundle\Helper\CacheStorageHelper;
class SegmentCampaignShare
{
public function __construct(
private CampaignModel $campaignModel,
private CacheStorageHelper $cacheStorageHelper,
private EntityManager $entityManager,
) {
}
/**
* @param int $segmentId
* @param array $campaignIds
*
* @return array
*/
public function getCampaignsSegmentShare($segmentId, $campaignIds = [])
{
$campaigns = $this->campaignModel->getRepository()->getCampaignsSegmentShare($segmentId, $campaignIds);
foreach ($campaigns as $campaign) {
$this->cacheStorageHelper->set($this->getCachedKey($segmentId, $campaign['id']), $campaign['segmentCampaignShare']);
}
return $campaigns;
}
/**
* @param int $segmentId
*
* @return array
*/
public function getCampaignList($segmentId)
{
$q = $this->entityManager->getConnection()->createQueryBuilder();
$q->select('c.id, c.name, null as share')
->from(MAUTIC_TABLE_PREFIX.'campaigns', 'c')
->where($this->campaignModel->getRepository()->getPublishedByDateExpression($q))
->orderBy('c.id', 'DESC');
$campaigns = $q->executeQuery()->fetchAllAssociative();
foreach ($campaigns as &$campaign) {
// just load from cache If exists
if ($share = $this->cacheStorageHelper->get($this->getCachedKey($segmentId, $campaign['id']))) {
$campaign['share'] = $share;
}
}
return $campaigns;
}
/**
* @param int $segmentId
* @param int $campaignId
*/
private function getCachedKey($segmentId, $campaignId): string
{
return sprintf('%s|%s|%s|%s|%s', 'campaign', $campaignId, 'segment', $segmentId, 'share');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Mautic\LeadBundle\Segment\Stat;
use Mautic\LeadBundle\Model\ListModel;
use Mautic\LeadBundle\Segment\Stat\ChartQuery\SegmentContactsLineChartQuery;
class SegmentChartQueryFactory
{
public function getContactsTotal(SegmentContactsLineChartQuery $query, ListModel $listModel): array
{
$total = $listModel->getRepository()->getLeadCount($query->getSegmentId());
return $query->getTotalStats($total);
}
public function getContactsAdded(SegmentContactsLineChartQuery $query): array
{
return $query->getAddedEventLogStats();
}
/**
* @return array
*/
public function getContactsRemoved(SegmentContactsLineChartQuery $query)
{
return $query->getRemovedEventLogStats();
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Mautic\LeadBundle\Segment\Stat;
use Mautic\CampaignBundle\Model\CampaignModel;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\FormBundle\Model\ActionModel;
use Mautic\LeadBundle\Model\ListModel;
use Mautic\PointBundle\Model\TriggerEventModel;
use Mautic\ReportBundle\Model\ReportModel;
class SegmentDependencies
{
public function __construct(
private EmailModel $emailModel,
private CampaignModel $campaignModel,
private ActionModel $actionModel,
private ListModel $listModel,
private TriggerEventModel $triggerEventModel,
private ReportModel $reportModel,
) {
}
public function getChannelsIds($segmentId): array
{
$usage = [];
$usage[] = [
'label' => 'mautic.email.emails',
'route' => 'mautic_email_index',
'ids' => $this->emailModel->getEmailsIdsWithDependenciesOnSegment($segmentId),
];
$usage[] = [
'label' => 'mautic.campaign.campaigns',
'route' => 'mautic_campaign_index',
'ids' => $this->campaignModel->getCampaignIdsWithDependenciesOnSegment($segmentId),
];
$usage[] = [
'label' => 'mautic.lead.lead.lists',
'route' => 'mautic_segment_index',
'ids' => $this->listModel->getSegmentsWithDependenciesOnSegment($segmentId, 'id'),
];
$usage[] = [
'label' => 'mautic.report.reports',
'route' => 'mautic_report_index',
'ids' => $this->reportModel->getReportsIdsWithDependenciesOnSegment($segmentId),
];
$usage[] = [
'label' => 'mautic.form.forms',
'route' => 'mautic_form_index',
'ids' => $this->actionModel->getFormsIdsWithDependenciesOnSegment($segmentId),
];
$usage[] = [
'label' => 'mautic.point.trigger.header.index',
'route' => 'mautic_pointtrigger_index',
'ids' => $this->triggerEventModel->getReportIdsWithDependenciesOnSegment($segmentId),
];
return $usage;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Mautic\LeadBundle\Segment;
use Doctrine\ORM\EntityManager;
class TableSchemaColumnsCache
{
private array $cache;
public function __construct(
private EntityManager $entityManager,
) {
$this->cache = [];
}
/**
* @return array|false
*/
public function getColumns($tableName)
{
if (!isset($this->cache[$tableName])) {
$columns = $this->entityManager->getConnection()->createSchemaManager()->listTableColumns($tableName);
$this->cache[$tableName] = $columns ?: [];
}
return $this->cache[$tableName];
}
/**
* @return $this
*/
public function clear()
{
$this->cache = [];
return $this;
}
/**
* @return string
*/
public function getCurrentDatabaseName()
{
return $this->entityManager->getConnection()->getDatabase();
}
}