Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f49fc5d
fix(DynamicContentSubscriber): complete Mautic 5 migration of filter …
edouard-mangel Mar 9, 2026
6f22f41
refactor(ContactFilterMatcher): remove class_alias pattern, use polyf…
edouard-mangel Mar 9, 2026
8c8f300
refactor(config): remove explicit service definitions, use autowiring
edouard-mangel Mar 10, 2026
e49bc8d
refactor(ContactFilterMatcher): use #[Autowire] for scalar parameter
edouard-mangel Mar 11, 2026
442f0af
test(ContactFilterMatcher): add unit tests
edouard-mangel Mar 11, 2026
3a9335a
fix(ContactFilterMatcher): replace deprecated DBAL execute() with exe…
edouard-mangel Mar 11, 2026
7227295
fix(MatchFilterForLeadTrait): handle empty operator when no custom it…
edouard-mangel Mar 11, 2026
63639a8
fix(ContactFilterMatcher): replace #[Autowire] with explicit arg bind…
edouard-mangel Mar 11, 2026
57cf15a
ci: add fix/** branch trigger and workflow_dispatch for local testing
edouard-mangel Mar 11, 2026
4878ed9
fix(cs): remove extra blank line, extra parentheses, and PHP 8.1 inte…
edouard-mangel Mar 11, 2026
ecd58ed
fix(tests): remove MAUTIC_TABLE_PREFIX define from test file (defined…
edouard-mangel Mar 11, 2026
c7302cb
fix(phpstan): add baseline entry for transformFilterDataForLeadPolyfi…
edouard-mangel Mar 11, 2026
6002c35
refactor: apply Rector rules (constructor promotion, str_starts_with,…
edouard-mangel Mar 11, 2026
99974b5
fix(phpstan): suppress array element type errors for transformFilterD…
edouard-mangel Mar 11, 2026
6e6ce5b
refactor(dynamic-content): replace MatchFilterForLeadTrait polyfill w…
edouard-mangel Apr 15, 2026
528977d
fix(cs): apply PHP CS Fixer rules for new files and modified classes
edouard-mangel Apr 15, 2026
60fcb53
fix(tests): update ContactFilterMatcherTest for new constructor signa…
edouard-mangel Apr 15, 2026
9fb226e
fix(di): use string service IDs for FilterEvaluator and ContactFilter…
edouard-mangel Apr 15, 2026
2d81055
fix(dynamic-content): include contact id in lead array and fix any-ma…
edouard-mangel Apr 15, 2026
803c0cb
fix(test): update unit test expectation to include id in lead array f…
edouard-mangel Apr 15, 2026
de7cc71
test: add FilterEvaluatorTest unit tests and fill functional test gaps
edouard-mangel Apr 15, 2026
b6fdc86
fix(test): use number-test-field alias for int type (translates to Nu…
edouard-mangel Apr 15, 2026
6ef4427
fix(evaluator): handle int type key in coerceTypes, use int type in f…
edouard-mangel Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ on:
- '[0-9]+\.[0-9]+'
- development
- beta
- 'fix/**'
pull_request:
workflow_dispatch:

env:
PLUGIN_DIR: plugins/CustomObjectsBundle # Same as extra.install-directory-name in composer.json
Expand Down
17 changes: 17 additions & 0 deletions Config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@
'mautic.campaign.model.event',
'event_dispatcher',
'custom_object.helper.token_formatter',
'custom_object.helper.filter_evaluator',
'%mautic.custom_item_fetch_limit_per_lead%',
],
],
Expand Down Expand Up @@ -636,6 +637,22 @@
'custom_object.helper.token_formatter' => [
'class' => MauticPlugin\CustomObjectsBundle\Helper\TokenFormatter::class,
],
'custom_object.helper.filter_evaluator' => [
'class' => MauticPlugin\CustomObjectsBundle\Helper\FilterEvaluator::class,
],
'custom_object.helper.contact_filter_matcher' => [
'class' => MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher::class,
'arguments' => [
'mautic.custom.model.field',
'mautic.custom.model.object',
'mautic.custom.model.item',
'mautic.lead.repository.company',
'doctrine.dbal.default_connection',
'custom_object.helper.filter_evaluator',
'%mautic.custom_item_fetch_limit_per_lead%',
],
],

'custom_object.data_persister.custom_item' => [
'class' => MauticPlugin\CustomObjectsBundle\DataPersister\CustomItemDataPersister::class,
'tag' => 'api_platform.data_persister',
Expand Down
9 changes: 9 additions & 0 deletions Config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
declare(strict_types=1);

use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher;
use MauticPlugin\CustomObjectsBundle\Helper\FilterEvaluator;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function (ContainerConfigurator $configurator): void {
Expand All @@ -17,10 +19,17 @@
'Report/ReportColumnsBuilder.php',
'Serializer/ApiNormalizer.php',
'Extension/CustomItemListeningExtension.php',
// Registered explicitly in config.php so the int $leadCustomItemFetchLimit arg can be set
'Helper/ContactFilterMatcher.php',
'Helper/FilterEvaluator.php',
];

$services->load('MauticPlugin\\CustomObjectsBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');

$services->load('MauticPlugin\\CustomObjectsBundle\\Repository\\', '../Repository/*Repository.php');

// Aliases so autowiring resolves the config.php-registered services by class name
$services->alias(ContactFilterMatcher::class, 'custom_object.helper.contact_filter_matcher')->public();
$services->alias(FilterEvaluator::class, 'custom_object.helper.filter_evaluator')->public();
};
70 changes: 26 additions & 44 deletions EventListener/DynamicContentSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,23 @@

use Mautic\DynamicContentBundle\DynamicContentEvents;
use Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent;
use Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait;
use MauticPlugin\CustomObjectsBundle\Exception\InvalidArgumentException;
use MauticPlugin\CustomObjectsBundle\Exception\InvalidSegmentFilterException;
use MauticPlugin\CustomObjectsBundle\Exception\NotFoundException;
use MauticPlugin\CustomObjectsBundle\Helper\QueryFilterHelper;
use MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher;
use MauticPlugin\CustomObjectsBundle\Provider\ConfigProvider;
use MauticPlugin\CustomObjectsBundle\Repository\DbalQueryTrait;
use MauticPlugin\CustomObjectsBundle\Segment\Query\Filter\QueryFilterFactory;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class DynamicContentSubscriber implements EventSubscriberInterface
{
use MatchFilterForLeadTrait;
use DbalQueryTrait;

public function __construct(
private QueryFilterFactory $queryFilterFactory,
private QueryFilterHelper $queryFilterHelper,
private ContactFilterMatcher $contactFilterMatcher,
private ConfigProvider $configProvider,
Comment thread
edouard-mangel marked this conversation as resolved.
private LoggerInterface $logger
) {
}

/**
* @return mixed[]
* @return array<string,array{string,int}>
*/
public static function getSubscribedEvents(): array
{
Expand All @@ -40,47 +31,38 @@ public static function getSubscribedEvents(): array
];
}

/**
* @throws InvalidArgumentException
* @throws NotFoundException
*/
public function evaluateFilters(ContactFiltersEvaluateEvent $event): void
{
if (!$this->configProvider->pluginIsEnabled()) {
if ($event->isEvaluated()
|| !$this->configProvider->pluginIsEnabled()
|| !$this->hasCustomObjectFilters($event->getFilters())
) {
return;
}

$eventFilters = $event->getFilters();

if ($event->isEvaluated()) {
return;
}

foreach ($eventFilters as $key => $eventFilter) {
$queryAlias = "filter_{$key}";

try {
$filterQueryBuilder = $this->queryFilterFactory->configureQueryBuilderFromSegmentFilter($eventFilter, $queryAlias);
} catch (InvalidSegmentFilterException $e) {
continue;
}

$this->queryFilterHelper->addContactIdRestriction($filterQueryBuilder, $queryAlias, (int) $event->getContact()->getId());
$event->setIsEvaluated(true);
$event->stopPropagation();
$contact = $event->getContact();
$event->setIsMatched($this->contactFilterMatcher->match(
$event->getFilters(),
array_merge(['id' => $contact->getId()], $contact->getProfileFields())
));
}

/**
* @param mixed[] $filters
*/
private function hasCustomObjectFilters(array $filters): bool
{
foreach ($filters as $filter) {
try {
if ($this->executeSelect($filterQueryBuilder)->rowCount()) {
$event->setIsEvaluated(true);
$event->setIsMatched(true);
} else {
$event->setIsEvaluated(true);
}
} catch (\PDOException $e) {
$this->logger->error('Failed to evaluate dynamic content for custom object '.$e->getMessage());
$this->queryFilterFactory->configureQueryBuilderFromSegmentFilter($filter, 'filter');

throw $e;
return true;
} catch (InvalidSegmentFilterException) {
}

$event->stopPropagation(); // The filter is ours, we won't allow no more processing
}

return false;
}
}
183 changes: 3 additions & 180 deletions EventListener/TokenSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Exception\OperatorsNotFoundException;
use Mautic\LeadBundle\Segment\OperatorOptions;
use MauticPlugin\CustomObjectsBundle\CustomItemEvents;
use MauticPlugin\CustomObjectsBundle\CustomObjectEvents;
use MauticPlugin\CustomObjectsBundle\DTO\TableConfig;
Expand All @@ -28,6 +25,7 @@
use MauticPlugin\CustomObjectsBundle\Exception\InvalidCustomObjectFormatListException;
use MauticPlugin\CustomObjectsBundle\Exception\InvalidSegmentFilterException;
use MauticPlugin\CustomObjectsBundle\Exception\NotFoundException;
use MauticPlugin\CustomObjectsBundle\Helper\FilterEvaluator;
use MauticPlugin\CustomObjectsBundle\Helper\QueryBuilderManipulatorTrait;
use MauticPlugin\CustomObjectsBundle\Helper\QueryFilterHelper;
use MauticPlugin\CustomObjectsBundle\Helper\TokenFormatter;
Expand All @@ -45,7 +43,6 @@
*/
class TokenSubscriber implements EventSubscriberInterface
{
use MatchFilterForLeadTrait;
use QueryBuilderManipulatorTrait;

public function __construct(
Expand All @@ -59,6 +56,7 @@ public function __construct(
private EventModel $eventModel,
private EventDispatcherInterface $eventDispatcher,
private TokenFormatter $tokenFormatter,
private FilterEvaluator $filterEvaluator,
private int $leadCustomItemFetchLimit
) {
}
Expand Down Expand Up @@ -300,7 +298,7 @@ public function onTokenReplacement(TokenReplacementEvent $event): void
$lead = array_merge($lead, $customFieldValues);
}

if ($isCustomObject && $this->matchFilterForLeadInCustomObject($filter['filters'], $lead)) {
if ($isCustomObject && $this->filterEvaluator->evaluate($filter['filters'], $lead)) {
$filterContent = $filter['content'];
break;
}
Expand Down Expand Up @@ -418,179 +416,4 @@ private function getCustomItems(CustomObject $customObject, string $leadId): arr

return $this->customItemModel->getArrayTableData($tableConfig);
}

// We have a similar function in MatchFilterForLeadTrait since we are unable to alter anything in Mautic 4.4,
// hence there is some duplication of code.

/**
* @param array<mixed> $filter
* @param array<mixed> $lead
*
* @throws OperatorsNotFoundException
*/
protected function matchFilterForLeadInCustomObject(array $filter, array $lead): bool
{
if (empty($lead['id'])) {
// Lead in generated for preview with faked data
return false;
}

$groups = [];
$groupNum = 0;

foreach ($filter as $data) {
if (!array_key_exists($data['field'], $lead)) {
continue;
}

/*
* Split the filters into groups based on the glue.
* The first filter and any filters whose glue is
* "or" will start a new group.
*/
if (0 === $groupNum || 'or' === $data['glue']) {
++$groupNum;
$groups[$groupNum] = null;
}

/*
* If the group has been marked as false, there
* is no need to continue checking the others
* in the group.
*/
if (false === $groups[$groupNum]) {
continue;
}

/*
* If we are checking the first filter in a group
* assume that the group will not match.
*/
if (null === $groups[$groupNum]) {
$groups[$groupNum] = false;
}

$leadValues = $lead[$data['field']];
$leadValues = 'custom_object' === $data['object'] ? $leadValues : [$leadValues];
$filterVal = $data['filter'];
$subgroup = null;

if (is_array($leadValues)) {
foreach ($leadValues as $leadVal) {
if ($subgroup) {
break;
}

switch ($data['type']) {
case 'boolean':
if (null !== $leadVal) {
$leadVal = (bool) $leadVal;
}

if (null !== $filterVal) {
$filterVal = (bool) $filterVal;
}
break;
case 'datetime':
case 'time':
if (!is_null($leadVal) && !is_null($filterVal)) {
$leadValCount = substr_count($leadVal, ':');
$filterValCount = substr_count($filterVal, ':');

if (2 === $leadValCount && 1 === $filterValCount) {
$filterVal .= ':00';
}
}
break;
case 'tags':
case 'select':
case 'multiselect':
if (!is_array($leadVal) && !empty($leadVal)) {
$leadVal = explode('|', $leadVal);
}
if (!is_null($filterVal) && !is_array($filterVal)) {
$filterVal = explode('|', $filterVal);
}
break;
case 'number':
$leadVal = (int) $leadVal;
$filterVal = (int) $filterVal;
break;
}

switch ($data['operator']) {
case '=':
if ('boolean' === $data['type']) {
$groups[$groupNum] = $leadVal === $filterVal;
} else {
$groups[$groupNum] = $leadVal == $filterVal;
}
break;
case '!=':
if ('boolean' === $data['type']) {
$groups[$groupNum] = $leadVal !== $filterVal;
} else {
$groups[$groupNum] = $leadVal != $filterVal;
}
break;
case 'gt':
$groups[$groupNum] = $leadVal > $filterVal;
break;
case 'gte':
$groups[$groupNum] = $leadVal >= $filterVal;
break;
case 'lt':
$groups[$groupNum] = $leadVal < $filterVal;
break;
case 'lte':
$groups[$groupNum] = $leadVal <= $filterVal;
break;
case 'empty':
$groups[$groupNum] = empty($leadVal);
break;
case '!empty':
$groups[$groupNum] = !empty($leadVal);
break;
case 'like':
$matchVal = str_replace(['.', '*', '%'], ['\.', '\*', '.*'], $filterVal);
$groups[$groupNum] = 1 === preg_match('/'.$matchVal.'/', $leadVal);
break;
case '!like':
$matchVal = str_replace(['.', '*'], ['\.', '\*'], $filterVal);
$matchVal = str_replace('%', '.*', $matchVal);
$groups[$groupNum] = 1 !== preg_match('/'.$matchVal.'/', $leadVal);
break;
case OperatorOptions::IN:
$groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, false);
break;
case OperatorOptions::NOT_IN:
$groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, true);
break;
case 'regexp':
$groups[$groupNum] = 1 === preg_match('/'.$filterVal.'/i', $leadVal);
break;
case '!regexp':
$groups[$groupNum] = 1 !== preg_match('/'.$filterVal.'/i', $leadVal);
break;
case 'startsWith':
$groups[$groupNum] = str_starts_with($leadVal, $filterVal);
break;
case 'endsWith':
$endOfString = substr($leadVal, strlen($leadVal) - strlen($filterVal));
$groups[$groupNum] = 0 === strcmp($endOfString, $filterVal);
break;
case 'contains':
$groups[$groupNum] = str_contains((string) $leadVal, (string) $filterVal);
break;
default:
throw new OperatorsNotFoundException('Operator is not defined or invalid operator found.');
}

$subgroup = $groups[$groupNum];
}
}
}

return in_array(true, $groups);
}
}
Loading