Skip to content

Commit f49fc5d

Browse files
fix(DynamicContentSubscriber): complete Mautic 5 migration of filter evaluation
The 5.x branch left DynamicContentSubscriber in the old Mautic 4 state: traits (MatchFilterForLeadTrait, DbalQueryTrait) that no longer exist in mautic/core-lib ^5.0, and a QueryBuilder-based filter evaluation path. Changes: - Remove MatchFilterForLeadTrait and DbalQueryTrait (removed from core in 5.x) - Remove QueryFilterHelper and LoggerInterface from constructor - Wire ContactFilterMatcher as the delegate in evaluateFilters() - Simplify hasCustomObjectFilters() — returns true on first non-throwing filter - Rewrite DynamicContentSubscriberTest: correct collaborators, 6 tests / 21 assertions - Add docs/adr/0001-mautic5-migration-strategy.md Verified on Mautic 5.2.9 / Symfony 5.4 / PHP 8.3 via DDEV: - cache:clear succeeds - mautic:plugins:reload installs the plugin (13 DB tables created) - Custom Objects UI fully functional at /s/custom/object/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f9c3d1d commit f49fc5d

5 files changed

Lines changed: 613 additions & 136 deletions

File tree

Config/config.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,14 @@
413413
'%mautic.custom_item_fetch_limit_per_lead%',
414414
],
415415
],
416+
'custom_object.dynamic_content.subscriber' => [
417+
'class' => MauticPlugin\CustomObjectsBundle\EventListener\DynamicContentSubscriber::class,
418+
'arguments' => [
419+
'custom_object.query.filter.factory',
420+
'custom_object.helper.contact_filter_matcher',
421+
'custom_object.config.provider',
422+
],
423+
],
416424
],
417425
'forms' => [
418426
'custom_field.field.params.to.string.transformer' => [
@@ -636,6 +644,18 @@
636644
'custom_object.helper.token_formatter' => [
637645
'class' => MauticPlugin\CustomObjectsBundle\Helper\TokenFormatter::class,
638646
],
647+
'custom_object.helper.contact_filter_matcher' => [
648+
'class' => MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher::class,
649+
'arguments' => [
650+
'mautic.custom.model.field',
651+
'mautic.custom.model.object',
652+
'mautic.custom.model.item',
653+
'mautic.lead.repository.lead_list',
654+
'mautic.lead.repository.company',
655+
'doctrine.dbal.default_connection',
656+
'%mautic.custom_item_fetch_limit_per_lead%',
657+
],
658+
],
639659
'custom_object.data_persister.custom_item' => [
640660
'class' => MauticPlugin\CustomObjectsBundle\DataPersister\CustomItemDataPersister::class,
641661
'tag' => 'api_platform.data_persister',

EventListener/DynamicContentSubscriber.php

Lines changed: 25 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,23 @@
66

77
use Mautic\DynamicContentBundle\DynamicContentEvents;
88
use Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent;
9-
use Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait;
10-
use MauticPlugin\CustomObjectsBundle\Exception\InvalidArgumentException;
119
use MauticPlugin\CustomObjectsBundle\Exception\InvalidSegmentFilterException;
12-
use MauticPlugin\CustomObjectsBundle\Exception\NotFoundException;
13-
use MauticPlugin\CustomObjectsBundle\Helper\QueryFilterHelper;
10+
use MauticPlugin\CustomObjectsBundle\Helper\ContactFilterMatcher;
1411
use MauticPlugin\CustomObjectsBundle\Provider\ConfigProvider;
15-
use MauticPlugin\CustomObjectsBundle\Repository\DbalQueryTrait;
1612
use MauticPlugin\CustomObjectsBundle\Segment\Query\Filter\QueryFilterFactory;
17-
use Psr\Log\LoggerInterface;
1813
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
1914

2015
class DynamicContentSubscriber implements EventSubscriberInterface
2116
{
22-
use MatchFilterForLeadTrait;
23-
use DbalQueryTrait;
24-
2517
public function __construct(
2618
private QueryFilterFactory $queryFilterFactory,
27-
private QueryFilterHelper $queryFilterHelper,
19+
private ContactFilterMatcher $contactFilterMatcher,
2820
private ConfigProvider $configProvider,
29-
private LoggerInterface $logger
3021
) {
3122
}
3223

3324
/**
34-
* @return mixed[]
25+
* @return array<string,array{string,int}>
3526
*/
3627
public static function getSubscribedEvents(): array
3728
{
@@ -40,47 +31,37 @@ public static function getSubscribedEvents(): array
4031
];
4132
}
4233

43-
/**
44-
* @throws InvalidArgumentException
45-
* @throws NotFoundException
46-
*/
4734
public function evaluateFilters(ContactFiltersEvaluateEvent $event): void
4835
{
49-
if (!$this->configProvider->pluginIsEnabled()) {
50-
return;
51-
}
52-
53-
$eventFilters = $event->getFilters();
54-
55-
if ($event->isEvaluated()) {
36+
if ($event->isEvaluated()
37+
|| !$this->configProvider->pluginIsEnabled()
38+
|| !$this->hasCustomObjectFilters($event->getFilters())
39+
) {
5640
return;
5741
}
5842

59-
foreach ($eventFilters as $key => $eventFilter) {
60-
$queryAlias = "filter_{$key}";
61-
62-
try {
63-
$filterQueryBuilder = $this->queryFilterFactory->configureQueryBuilderFromSegmentFilter($eventFilter, $queryAlias);
64-
} catch (InvalidSegmentFilterException $e) {
65-
continue;
66-
}
67-
68-
$this->queryFilterHelper->addContactIdRestriction($filterQueryBuilder, $queryAlias, (int) $event->getContact()->getId());
43+
$event->setIsEvaluated(true);
44+
$event->stopPropagation();
45+
$event->setIsMatched($this->contactFilterMatcher->match(
46+
$event->getFilters(),
47+
$event->getContact()->getProfileFields()
48+
));
49+
}
6950

51+
/**
52+
* @param mixed[] $filters
53+
*/
54+
private function hasCustomObjectFilters(array $filters): bool
55+
{
56+
foreach ($filters as $filter) {
7057
try {
71-
if ($this->executeSelect($filterQueryBuilder)->rowCount()) {
72-
$event->setIsEvaluated(true);
73-
$event->setIsMatched(true);
74-
} else {
75-
$event->setIsEvaluated(true);
76-
}
77-
} catch (\PDOException $e) {
78-
$this->logger->error('Failed to evaluate dynamic content for custom object '.$e->getMessage());
58+
$this->queryFilterFactory->configureQueryBuilderFromSegmentFilter($filter, 'filter');
7959

80-
throw $e;
60+
return true;
61+
} catch (InvalidSegmentFilterException $e) {
8162
}
82-
83-
$event->stopPropagation(); // The filter is ours, we won't allow no more processing
8463
}
64+
65+
return false;
8566
}
8667
}

Helper/ContactFilterMatcher.php

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MauticPlugin\CustomObjectsBundle\Helper;
6+
7+
use Doctrine\DBAL\Connection;
8+
use Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait;
9+
use Mautic\LeadBundle\Entity\CompanyRepository;
10+
use Mautic\LeadBundle\Entity\LeadListRepository;
11+
use MauticPlugin\CustomObjectsBundle\DTO\TableConfig;
12+
use MauticPlugin\CustomObjectsBundle\Entity\CustomItem;
13+
use MauticPlugin\CustomObjectsBundle\Entity\CustomObject;
14+
use MauticPlugin\CustomObjectsBundle\Exception\InvalidCustomObjectFormatListException;
15+
use MauticPlugin\CustomObjectsBundle\Exception\NotFoundException;
16+
use MauticPlugin\CustomObjectsBundle\Model\CustomFieldModel;
17+
use MauticPlugin\CustomObjectsBundle\Model\CustomItemModel;
18+
use MauticPlugin\CustomObjectsBundle\Model\CustomObjectModel;
19+
use MauticPlugin\CustomObjectsBundle\Polyfill\EventListener\MatchFilterForLeadTrait as MatchFilterForLeadTraitPolyfill;
20+
21+
if (method_exists(MatchFilterForLeadTrait::class, 'transformFilterDataForLead')) {
22+
class_alias(MatchFilterForLeadTrait::class, '\MauticPlugin\CustomObjectsBundle\Helper\MatchFilterForLeadTraitAlias');
23+
} else {
24+
class_alias(MatchFilterForLeadTraitPolyfill::class, '\MauticPlugin\CustomObjectsBundle\Helper\MatchFilterForLeadTraitAlias');
25+
}
26+
27+
class ContactFilterMatcher
28+
{
29+
use MatchFilterForLeadTraitAlias {
30+
transformFilterDataForLead as transformFilterDataForLeadAlias;
31+
}
32+
33+
private CustomFieldModel $customFieldModel;
34+
private CustomObjectModel $customObjectModel;
35+
private CustomItemModel $customItemModel;
36+
private CompanyRepository $companyRepository;
37+
private Connection $connection;
38+
private int $leadCustomItemFetchLimit;
39+
40+
public function __construct(
41+
CustomFieldModel $customFieldModel,
42+
CustomObjectModel $customObjectModel,
43+
CustomItemModel $customItemModel,
44+
LeadListRepository $segmentRepository,
45+
CompanyRepository $companyRepository,
46+
Connection $connection,
47+
int $leadCustomItemFetchLimit
48+
) {
49+
$this->customFieldModel = $customFieldModel;
50+
$this->customObjectModel = $customObjectModel;
51+
$this->customItemModel = $customItemModel;
52+
$this->segmentRepository = $segmentRepository;
53+
$this->companyRepository = $companyRepository;
54+
$this->connection = $connection;
55+
$this->leadCustomItemFetchLimit = $leadCustomItemFetchLimit;
56+
}
57+
58+
/**
59+
* @param mixed[] $filters
60+
* @param mixed[] $lead
61+
*/
62+
public function match(array $filters, array $lead, bool &$hasCustomFields = false): bool
63+
{
64+
$leadId = (string) $lead['id'];
65+
$customFieldValues = $this->getCustomFieldDataForLead($filters, $leadId);
66+
67+
if (!$customFieldValues) {
68+
return false;
69+
}
70+
71+
$hasCustomFields = true;
72+
$lead = array_merge($lead, $customFieldValues);
73+
74+
if (!isset($lead['companies']) && $this->doFiltersContainCompanyFilter($filters)) {
75+
$lead['companies'] = $this->companyRepository->getCompaniesByLeadId($leadId);
76+
}
77+
78+
if (!isset($lead['tags']) && $this->doFiltersContainTagsFilter($filters)) {
79+
$lead['tags'] = $this->getTagIdsByLeadId($leadId);
80+
}
81+
82+
return $this->matchFilterForLead($filters, $lead);
83+
}
84+
85+
/**
86+
* @param mixed[] $filters
87+
*
88+
* @return mixed[]
89+
*/
90+
private function getCustomFieldDataForLead(array $filters, string $leadId): array
91+
{
92+
$customFieldValues = $cachedCustomItems = [];
93+
94+
foreach ($filters as $condition) {
95+
try {
96+
if ('custom_object' !== $condition['object']) {
97+
continue;
98+
}
99+
100+
if ('cmf_' === substr($condition['field'], 0, 4)) {
101+
$customField = $this->customFieldModel->fetchEntity(
102+
(int) explode('cmf_', $condition['field'])[1]
103+
);
104+
$customObject = $customField->getCustomObject();
105+
$fieldAlias = $customField->getAlias();
106+
} elseif ('cmo_' === substr($condition['field'], 0, 4)) {
107+
$customObject = $this->customObjectModel->fetchEntity(
108+
(int) explode('cmo_', $condition['field'])[1]
109+
);
110+
$fieldAlias = 'name';
111+
} else {
112+
continue;
113+
}
114+
115+
$key = $customObject->getId().'-'.$leadId;
116+
if (!isset($cachedCustomItems[$key])) {
117+
$cachedCustomItems[$key] = $this->getCustomItems($customObject, $leadId);
118+
}
119+
120+
$result = $this->getCustomFieldValue($customObject, $fieldAlias, $cachedCustomItems[$key]);
121+
122+
$customFieldValues[$condition['field']] = $result;
123+
} catch (NotFoundException|InvalidCustomObjectFormatListException $e) {
124+
continue;
125+
}
126+
}
127+
128+
return $customFieldValues;
129+
}
130+
131+
/**
132+
* @param mixed[] $customItems
133+
*
134+
* @return mixed[]
135+
*/
136+
private function getCustomFieldValue(
137+
CustomObject $customObject,
138+
string $customFieldAlias,
139+
array $customItems
140+
): array {
141+
$fieldValues = [];
142+
143+
foreach ($customItems as $customItemData) {
144+
// Name is known from the CI data array.
145+
if ('name' === $customFieldAlias) {
146+
$fieldValues[] = $customItemData['name'];
147+
148+
continue;
149+
}
150+
151+
// Custom Field values are handled like this.
152+
$customItem = new CustomItem($customObject);
153+
$customItem->populateFromArray($customItemData);
154+
$customItem = $this->customItemModel->populateCustomFields($customItem);
155+
156+
try {
157+
$fieldValue = $customItem->findCustomFieldValueForFieldAlias($customFieldAlias);
158+
// If the CO item doesn't have a value, get the default value
159+
if (empty($fieldValue->getValue())) {
160+
$fieldValue->setValue($fieldValue->getCustomField()->getDefaultValue());
161+
}
162+
163+
if (in_array($fieldValue->getCustomField()->getType(), ['multiselect', 'select'])) {
164+
$fieldValues[] = $fieldValue->getValue();
165+
} else {
166+
$fieldValues[] = $fieldValue->getCustomField()->getTypeObject()->valueToString($fieldValue);
167+
}
168+
} catch (NotFoundException $e) {
169+
// Custom field not found.
170+
}
171+
}
172+
173+
return $fieldValues;
174+
}
175+
176+
/**
177+
* @return array<mixed>
178+
*/
179+
private function getCustomItems(CustomObject $customObject, string $leadId): array
180+
{
181+
$orderBy = CustomItem::TABLE_ALIAS.'.id';
182+
$orderDir = 'DESC';
183+
184+
$tableConfig = new TableConfig($this->leadCustomItemFetchLimit, 1, $orderBy, $orderDir);
185+
$tableConfig->addParameter('customObjectId', $customObject->getId());
186+
$tableConfig->addParameter('filterEntityType', 'contact');
187+
$tableConfig->addParameter('filterEntityId', $leadId);
188+
189+
return $this->customItemModel->getArrayTableData($tableConfig);
190+
}
191+
192+
/**
193+
* @param mixed[] $data
194+
* @param mixed[] $lead
195+
*
196+
* @return ?mixed[]
197+
*/
198+
private function transformFilterDataForLead(array $data, array $lead): ?array
199+
{
200+
if ('custom_object' === $data['object']) {
201+
return $lead[$data['field']];
202+
}
203+
204+
return $this->transformFilterDataForLeadAlias($data, $lead);
205+
}
206+
207+
/**
208+
* @return string[]
209+
*/
210+
public function getTagIdsByLeadId(string $leadId): array
211+
{
212+
return $this->connection->createQueryBuilder()
213+
->select('tag_id')
214+
->from(MAUTIC_TABLE_PREFIX.'lead_tags_xref', 'x')
215+
->where('x.lead_id = :leadId')
216+
->setParameter('leadId', $leadId)
217+
->execute()
218+
->fetchFirstColumn();
219+
}
220+
}

0 commit comments

Comments
 (0)