PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.27.0
MailPoet – Newsletters, Email Marketing, and Automation v5.27.0
5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / Segments / SegmentSubscribersRepository.php
mailpoet / lib / Segments Last commit date
DynamicSegments 2 weeks ago RestApi 4 weeks ago SegmentDependencyValidator.php 3 years ago SegmentListingRepository.php 2 weeks ago SegmentSaveController.php 2 weeks ago SegmentSubscribersRepository.php 4 weeks ago SegmentsFinder.php 3 years ago SegmentsRepository.php 2 weeks ago SegmentsSimpleListRepository.php 4 weeks ago SubscribersFinder.php 4 weeks ago WP.php 3 weeks ago WooCommerce.php 4 weeks ago index.php 3 years ago
SegmentSubscribersRepository.php
583 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\Segments;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Entities\DynamicSegmentFilterData;
9 use MailPoet\Entities\DynamicSegmentFilterEntity;
10 use MailPoet\Entities\SegmentEntity;
11 use MailPoet\Entities\SubscriberEntity;
12 use MailPoet\Entities\SubscriberSegmentEntity;
13 use MailPoet\InvalidStateException;
14 use MailPoet\Logging\LoggerFactory;
15 use MailPoet\NotFoundException;
16 use MailPoet\Segments\DynamicSegments\Exceptions\InvalidFilterException;
17 use MailPoet\Segments\DynamicSegments\FilterHandler;
18 use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
19 use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
20 use MailPoetVendor\Doctrine\DBAL\Result;
21 use MailPoetVendor\Doctrine\ORM\EntityManager;
22 use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
23 use MailPoetVendor\Doctrine\ORM\QueryBuilder as ORMQueryBuilder;
24 use Throwable;
25
26 class SegmentSubscribersRepository {
27 /** @var EntityManager */
28 private $entityManager;
29
30 /** @var FilterHandler */
31 private $filterHandler;
32
33 /** @var SegmentsRepository */
34 private $segmentsRepository;
35
36 public function __construct(
37 EntityManager $entityManager,
38 FilterHandler $filterHandler,
39 SegmentsRepository $segmentsRepository
40 ) {
41 $this->entityManager = $entityManager;
42 $this->filterHandler = $filterHandler;
43 $this->segmentsRepository = $segmentsRepository;
44 }
45
46 public function findSubscribersIdsInSegment(int $segmentId, ?array $candidateIds = null): array {
47 return $this->loadSubscriberIdsInSegment($segmentId, $candidateIds);
48 }
49
50 public function getSubscriberIdsInSegment(int $segmentId): array {
51 return $this->loadSubscriberIdsInSegment($segmentId);
52 }
53
54 public function getSubscribersCount(int $segmentId, ?string $status = null): int {
55 $segment = $this->getSegment($segmentId);
56 $result = $this->getSubscribersStatisticsCount($segment);
57 return (int)$result[$status ?: 'all'];
58 }
59
60 public function getSubscribersCountBySegmentIds(array $segmentIds, ?string $status = null, ?int $filterSegmentId = null): int {
61 $segments = $this->segmentsRepository->findByIds($segmentIds);
62 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
63 $queryBuilder = $this->createCountQueryBuilder();
64
65 $subQueries = [];
66 foreach ($segments as $segment) {
67 $segmentQb = $this->createCountQueryBuilder();
68 $segmentQb->select("{$subscribersTable}.id AS inner_id");
69
70 if ($segment->isStatic()) {
71 $segmentQb = $this->filterSubscribersInStaticSegment($segmentQb, $segment, $status);
72 } else {
73 $segmentQb = $this->filterSubscribersInDynamicSegment($segmentQb, $segment, $status);
74 }
75
76 // inner parameters and types have to be merged to outer queryBuilder
77 $queryBuilder->setParameters(array_merge(
78 $segmentQb->getParameters(),
79 $queryBuilder->getParameters()
80 ), array_merge(
81 $segmentQb->getParameterTypes(),
82 $queryBuilder->getParameterTypes()
83 ));
84 $subQueries[] = $segmentQb->getSQL();
85 }
86
87 $queryBuilder->innerJoin(
88 $subscribersTable,
89 sprintf('(%s)', join(' UNION ', $subQueries)),
90 'inner_subscribers',
91 "inner_subscribers.inner_id = {$subscribersTable}.id"
92 );
93
94 try {
95 if (is_int($filterSegmentId)) {
96 $filterSegment = $this->segmentsRepository->verifyDynamicSegmentExists($filterSegmentId);
97 $filterSegmentQb = $this->createCountQueryBuilder();
98 $filterSegmentQb->select("{$subscribersTable}.id AS filter_segment_subscriber_id");
99 $filterSegmentQb = $this->filterSubscribersInDynamicSegment($filterSegmentQb, $filterSegment, $status);
100 $queryBuilder->setParameters(array_merge($filterSegmentQb->getParameters(), $queryBuilder->getParameters()), array_merge($filterSegmentQb->getParameterTypes(), $queryBuilder->getParameterTypes()));
101 $queryBuilder->innerJoin(
102 $subscribersTable,
103 sprintf('(%s)', $filterSegmentQb->getSQL()),
104 'filter_segment',
105 "filter_segment.filter_segment_subscriber_id = {$subscribersTable}.id"
106 );
107 }
108 } catch (InvalidStateException $exception) {
109 return 0;
110 }
111
112 try {
113 $statement = $this->executeQuery($queryBuilder);
114 /** @var string $result */
115 $result = $statement->fetchOne();
116 return (int)$result;
117 } catch (Throwable $e) {
118 $this->logQueryException(null, $e);
119 return 0;
120 }
121 }
122
123 /**
124 * @param DynamicSegmentFilterData[] $filters
125 * @return int
126 * @throws InvalidStateException
127 */
128 public function getDynamicSubscribersCount(array $filters): int {
129 try {
130 $segment = new SegmentEntity('temporary segment', SegmentEntity::TYPE_DYNAMIC, '');
131 foreach ($filters as $filter) {
132 $segment->addDynamicFilter(new DynamicSegmentFilterEntity($segment, $filter));
133 }
134 $queryBuilder = $this->createDynamicStatisticsQueryBuilder();
135 $queryBuilder = $this->filterSubscribersInDynamicSegment($queryBuilder, $segment, null);
136 $statement = $this->executeQuery($queryBuilder);
137 $result = $statement->fetch();
138
139 if (!is_array($result)) {
140 $result = $this->logErrorAndReturnEmptyResult(null, $queryBuilder, $result);
141 }
142
143 return isset($result['all']) && is_numeric($result['all']) ? (int)$result['all'] : 0;
144 } catch (Throwable $e) {
145 $this->logQueryException(null, $e);
146 return 0;
147 }
148 }
149
150 private function createCountQueryBuilder(): QueryBuilder {
151 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
152 return $this->entityManager
153 ->getConnection()
154 ->createQueryBuilder()
155 ->select("count(DISTINCT $subscribersTable.id)")
156 ->from($subscribersTable);
157 }
158
159 private function createDynamicStatisticsQueryBuilder(): QueryBuilder {
160 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
161 return $this->entityManager
162 ->getConnection()
163 ->createQueryBuilder()
164 ->from($subscribersTable)
165 ->addSelect("IFNULL(SUM(
166 CASE WHEN $subscribersTable.deleted_at IS NULL
167 THEN 1 ELSE 0 END
168 ), 0) as `all`")
169 ->addSelect("IFNULL(SUM(
170 CASE WHEN $subscribersTable.deleted_at IS NOT NULL
171 THEN 1 ELSE 0 END
172 ), 0) as trash")
173 ->addSelect("IFNULL(SUM(
174 CASE WHEN $subscribersTable.status = :status_subscribed AND $subscribersTable.deleted_at IS NULL
175 THEN 1 ELSE 0 END
176 ), 0) as :status_subscribed")
177 ->addSelect("IFNULL(SUM(
178 CASE WHEN $subscribersTable.status = :status_unsubscribed AND $subscribersTable.deleted_at IS NULL
179 THEN 1 ELSE 0 END
180 ), 0) as :status_unsubscribed")
181 ->addSelect("IFNULL(SUM(
182 CASE WHEN $subscribersTable.status = :status_inactive AND $subscribersTable.deleted_at IS NULL
183 THEN 1 ELSE 0 END
184 ), 0) as :status_inactive")
185 ->addSelect("IFNULL(SUM(
186 CASE WHEN $subscribersTable.status = :status_unconfirmed AND $subscribersTable.deleted_at IS NULL
187 THEN 1 ELSE 0 END
188 ), 0) as :status_unconfirmed")
189 ->addSelect("IFNULL(SUM(
190 CASE WHEN $subscribersTable.status = :status_bounced AND $subscribersTable.deleted_at IS NULL
191 THEN 1 ELSE 0 END
192 ), 0) as :status_bounced")
193 ->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
194 ->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
195 ->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
196 ->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
197 ->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
198 }
199
200 private function createStaticStatisticsQueryBuilder(SegmentEntity $segment): QueryBuilder {
201 $subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
202 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
203 return $this->entityManager
204 ->getConnection()
205 ->createQueryBuilder()
206 ->from($subscriberSegmentTable, 'subscriber_segment')
207 ->where('subscriber_segment.segment_id = :segment_id')
208 ->setParameter('segment_id', $segment->getId())
209 ->join('subscriber_segment', $subscribersTable, 'subscribers', 'subscribers.id = subscriber_segment.subscriber_id')
210 ->addSelect('IFNULL(SUM(
211 CASE WHEN subscribers.deleted_at IS NULL
212 THEN 1 ELSE 0 END
213 ), 0) as `all`')
214 ->addSelect('IFNULL(SUM(
215 CASE WHEN subscribers.deleted_at IS NOT NULL
216 THEN 1 ELSE 0 END
217 ), 0) as trash')
218 ->addSelect('IFNULL(SUM(
219 CASE WHEN subscribers.status = :status_subscribed AND subscriber_segment.status = :status_subscribed AND subscribers.deleted_at IS NULL
220 THEN 1 ELSE 0 END
221 ), 0) as :status_subscribed')
222 ->addSelect('IFNULL(SUM(
223 CASE WHEN (subscribers.status = :status_unsubscribed OR subscriber_segment.status = :status_unsubscribed) AND subscribers.deleted_at IS NULL
224 THEN 1 ELSE 0 END
225 ), 0) as :status_unsubscribed')
226 ->addSelect('IFNULL(SUM(
227 CASE WHEN subscribers.status = :status_inactive AND subscriber_segment.status != :status_unsubscribed AND subscribers.deleted_at IS NULL
228 THEN 1 ELSE 0 END
229 ), 0) as :status_inactive')
230 ->addSelect('IFNULL(SUM(
231 CASE WHEN subscribers.status = :status_unconfirmed AND subscriber_segment.status != :status_unsubscribed AND subscribers.deleted_at IS NULL
232 THEN 1 ELSE 0 END
233 ), 0) as :status_unconfirmed')
234 ->addSelect('IFNULL(SUM(
235 CASE WHEN subscribers.status = :status_bounced AND subscriber_segment.status != :status_unsubscribed AND subscribers.deleted_at IS NULL
236 THEN 1 ELSE 0 END
237 ), 0) as :status_bounced')
238 ->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
239 ->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
240 ->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
241 ->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
242 ->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
243 }
244
245 private function createStaticGlobalStatusStatisticsQueryBuilder(SegmentEntity $segment): QueryBuilder {
246 $subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
247 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
248 return $this->entityManager
249 ->getConnection()
250 ->createQueryBuilder()
251 ->from($subscriberSegmentTable, 'subscriber_segment')
252 ->where('subscriber_segment.segment_id = :segment_id')
253 ->setParameter('segment_id', $segment->getId())
254 ->join('subscriber_segment', $subscribersTable, 'subscribers', 'subscribers.id = subscriber_segment.subscriber_id')
255 ->addSelect('IFNULL(SUM(
256 CASE WHEN subscribers.deleted_at IS NULL
257 THEN 1 ELSE 0 END
258 ), 0) as `all`')
259 ->addSelect('IFNULL(SUM(
260 CASE WHEN subscribers.deleted_at IS NOT NULL
261 THEN 1 ELSE 0 END
262 ), 0) as trash')
263 ->addSelect('IFNULL(SUM(
264 CASE WHEN subscribers.status = :status_subscribed AND subscribers.deleted_at IS NULL
265 THEN 1 ELSE 0 END
266 ), 0) as :status_subscribed')
267 ->addSelect('IFNULL(SUM(
268 CASE WHEN subscribers.status = :status_unsubscribed AND subscribers.deleted_at IS NULL
269 THEN 1 ELSE 0 END
270 ), 0) as :status_unsubscribed')
271 ->addSelect('IFNULL(SUM(
272 CASE WHEN subscribers.status = :status_inactive AND subscribers.deleted_at IS NULL
273 THEN 1 ELSE 0 END
274 ), 0) as :status_inactive')
275 ->addSelect('IFNULL(SUM(
276 CASE WHEN subscribers.status = :status_unconfirmed AND subscribers.deleted_at IS NULL
277 THEN 1 ELSE 0 END
278 ), 0) as :status_unconfirmed')
279 ->addSelect('IFNULL(SUM(
280 CASE WHEN subscribers.status = :status_bounced AND subscribers.deleted_at IS NULL
281 THEN 1 ELSE 0 END
282 ), 0) as :status_bounced')
283 ->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
284 ->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
285 ->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
286 ->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
287 ->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
288 }
289
290 public function getSubscribersWithoutSegmentCount(): int {
291 $queryBuilder = $this->entityManager->createQueryBuilder();
292 $queryBuilder
293 ->select('COUNT(DISTINCT s) AS subscribersCount')
294 ->from(SubscriberEntity::class, 's');
295 $this->addConstraintsForSubscribersWithoutSegment($queryBuilder);
296 return (int)$queryBuilder->getQuery()->getSingleScalarResult();
297 }
298
299 public function getSubscribersWithoutSegmentStatisticsCount(): array {
300 try {
301 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
302 $queryBuilder = $this->entityManager
303 ->getConnection()
304 ->createQueryBuilder();
305 $queryBuilder
306 ->addSelect('IFNULL(SUM(
307 CASE WHEN s.deleted_at IS NULL
308 THEN 1 ELSE 0 END
309 ), 0) as `all`')
310 ->addSelect('IFNULL(SUM(
311 CASE WHEN s.deleted_at IS NOT NULL
312 THEN 1 ELSE 0 END
313 ), 0) as trash')
314 ->addSelect('IFNULL(SUM(
315 CASE WHEN s.status = :status_subscribed AND s.deleted_at IS NULL
316 THEN 1 ELSE 0 END
317 ), 0) as :status_subscribed')
318 ->addSelect('IFNULL(SUM(
319 CASE WHEN s.status = :status_unsubscribed AND s.deleted_at IS NULL
320 THEN 1 ELSE 0 END
321 ), 0) as :status_unsubscribed')
322 ->addSelect('IFNULL(SUM(
323 CASE WHEN s.status = :status_inactive AND s.deleted_at IS NULL
324 THEN 1 ELSE 0 END
325 ), 0) as :status_inactive')
326 ->addSelect('IFNULL(SUM(
327 CASE WHEN s.status = :status_unconfirmed AND s.deleted_at IS NULL
328 THEN 1 ELSE 0 END
329 ), 0) as :status_unconfirmed')
330 ->addSelect('IFNULL(SUM(
331 CASE WHEN s.status = :status_bounced AND s.deleted_at IS NULL
332 THEN 1 ELSE 0 END
333 ), 0) as :status_bounced')
334 ->from($subscribersTable, 's')
335 ->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
336 ->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
337 ->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
338 ->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
339 ->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
340
341 $this->addConstraintsForSubscribersWithoutSegmentToDBAL($queryBuilder);
342 $statement = $this->executeQuery($queryBuilder);
343 $result = $statement->fetch();
344
345 if (is_array($result)) {
346 return $result;
347 }
348
349 return $this->logErrorAndReturnEmptyResult(null, $queryBuilder, $result);
350 } catch (Throwable $e) {
351 $this->logQueryException(null, $e);
352 return $this->emptyStatisticsResult();
353 }
354 }
355
356 public function addConstraintsForSubscribersWithoutSegment(ORMQueryBuilder $queryBuilder): void {
357 $deletedSegmentsQueryBuilder = $this->entityManager->createQueryBuilder();
358 $deletedSegmentsQueryBuilder->select('sg.id')
359 ->from(SegmentEntity::class, 'sg')
360 ->where($deletedSegmentsQueryBuilder->expr()->isNotNull('sg.deletedAt'));
361
362 $queryBuilder
363 ->leftJoin(
364 's.subscriberSegments',
365 'ssg',
366 Join::WITH,
367 (string)$queryBuilder->expr()->andX(
368 $queryBuilder->expr()->eq('ssg.subscriber', 's.id'),
369 $queryBuilder->expr()->eq('ssg.status', ':statusSubscribed'),
370 $queryBuilder->expr()->notIn('ssg.segment', $deletedSegmentsQueryBuilder->getDQL())
371 )
372 )
373 ->andWhere('ssg.id IS NULL')
374 ->setParameter('statusSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
375 }
376
377 public function addConstraintsForSubscribersWithoutSegmentToDBAL(QueryBuilder $queryBuilder): void {
378 $deletedSegmentsQueryBuilder = $this->entityManager->createQueryBuilder();
379 $subscribersSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
380 $deletedSegmentsQueryBuilder->select('sg.id')
381 ->from(SegmentEntity::class, 'sg')
382 ->where($deletedSegmentsQueryBuilder->expr()->isNotNull('sg.deletedAt'));
383
384 $queryBuilder
385 ->leftJoin(
386 's',
387 $subscribersSegmentTable,
388 'ssg',
389 (string)$queryBuilder->expr()->and(
390 $queryBuilder->expr()->eq('ssg.subscriber_id', 's.id'),
391 $queryBuilder->expr()->eq('ssg.status', ':statusSubscribed'),
392 $queryBuilder->expr()->notIn('ssg.segment_id', $deletedSegmentsQueryBuilder->getQuery()->getSQL())
393 )
394 )
395 ->andWhere('ssg.id IS NULL')
396 ->setParameter('statusSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
397 }
398
399 private function loadSubscriberIdsInSegment(int $segmentId, ?array $candidateIds = null): array {
400 $segment = $this->getSegment($segmentId);
401 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
402 $queryBuilder = $this->entityManager
403 ->getConnection()
404 ->createQueryBuilder()
405 ->select("DISTINCT $subscribersTable.id")
406 ->from($subscribersTable);
407
408 if ($segment->isStatic()) {
409 $queryBuilder = $this->filterSubscribersInStaticSegment($queryBuilder, $segment, SubscriberEntity::STATUS_SUBSCRIBED);
410 } else {
411 $queryBuilder = $this->filterSubscribersInDynamicSegment($queryBuilder, $segment, SubscriberEntity::STATUS_SUBSCRIBED);
412 }
413
414 if ($candidateIds) {
415 $queryBuilder->andWhere("$subscribersTable.id IN (:candidateIds)")
416 ->setParameter('candidateIds', $candidateIds, ArrayParameterType::STRING);
417 }
418
419 $statement = $this->executeQuery($queryBuilder);
420 $result = $statement->fetchAll();
421 return array_column($result, 'id');
422 }
423
424 private function filterSubscribersInStaticSegment(
425 QueryBuilder $queryBuilder,
426 SegmentEntity $segment,
427 ?string $status = null
428 ): QueryBuilder {
429 $subscribersSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
430 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
431 $parameterName = "segment_{$segment->getId()}"; // When we use this method more times the parameter name has to be unique
432 $queryBuilder = $queryBuilder->join(
433 $subscribersTable,
434 $subscribersSegmentsTable,
435 'subsegment',
436 "subsegment.subscriber_id = $subscribersTable.id AND subsegment.segment_id = :$parameterName"
437 )->andWhere("$subscribersTable.deleted_at IS NULL")
438 ->setParameter($parameterName, $segment->getId());
439 if ($status) {
440 $queryBuilder = $queryBuilder->andWhere("$subscribersTable.status = :status")
441 ->andWhere("subsegment.status = :status")
442 ->setParameter('status', $status);
443 }
444 return $queryBuilder;
445 }
446
447 private function filterSubscribersInDynamicSegment(
448 QueryBuilder $queryBuilder,
449 SegmentEntity $segment,
450 ?string $status = null
451 ): QueryBuilder {
452 $filters = [];
453 $dynamicFilters = $segment->getDynamicFilters();
454 foreach ($dynamicFilters as $dynamicFilter) {
455 $filters[] = $dynamicFilter->getFilterData();
456 }
457
458 // We don't allow dynamic segment without filers since it would return all subscribers
459 // For BC compatibility fetching an empty result
460 if (count($filters) === 0) {
461 return $queryBuilder->andWhere('0 = 1');
462 } elseif ($segment instanceof SegmentEntity) {
463 try {
464 $queryBuilder = $this->filterHandler->apply($queryBuilder, $segment);
465 } catch (InvalidFilterException $e) {
466 // If a segment has an invalid filter, we should simply consider it empty instead of throwing
467 // an unhandled error. Unhandled errors here can break many admin pages.
468 $queryBuilder->andWhere('0 = 1');
469 }
470 }
471 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
472 $queryBuilder = $queryBuilder->andWhere("$subscribersTable.deleted_at IS NULL");
473 if ($status) {
474 $queryBuilder = $queryBuilder->andWhere("$subscribersTable.status = :status")
475 ->setParameter('status', $status);
476 }
477 return $queryBuilder;
478 }
479
480 private function getSegment(int $id): SegmentEntity {
481 $segment = $this->entityManager->find(SegmentEntity::class, $id);
482 if (!$segment instanceof SegmentEntity) {
483 throw new NotFoundException('Segment not found');
484 }
485 return $segment;
486 }
487
488 private function executeQuery(QueryBuilder $queryBuilder): Result {
489 try {
490 $this->entityManager->getConnection()->executeStatement('SET SESSION SQL_BIG_SELECTS=1');
491 } catch (Throwable $e) {
492 // Best-effort: some hosts may not allow SET SESSION, continue without it
493 }
494 $result = $queryBuilder->execute();
495 // Execute for select always returns statement but PHP Stan doesn't know that :(
496 if (!$result instanceof Result) {
497 throw new InvalidStateException('Invalid query.');
498 }
499 return $result;
500 }
501
502 public function getSubscribersGlobalStatusStatisticsCount(SegmentEntity $segment): array {
503 try {
504 if ($segment->isStatic()) {
505 $queryBuilder = $this->createStaticGlobalStatusStatisticsQueryBuilder($segment);
506 } else {
507 $queryBuilder = $this->createDynamicStatisticsQueryBuilder();
508 $this->filterSubscribersInDynamicSegment($queryBuilder, $segment);
509 }
510
511 $statement = $this->executeQuery($queryBuilder);
512 $result = $statement->fetch();
513 if (is_array($result)) {
514 return $result;
515 }
516
517 return $this->logErrorAndReturnEmptyResult($segment, $queryBuilder, $result);
518 } catch (Throwable $e) {
519 $this->logQueryException($segment, $e);
520 return $this->emptyStatisticsResult();
521 }
522 }
523
524 public function getSubscribersStatisticsCount(SegmentEntity $segment): array {
525 try {
526 if ($segment->isStatic()) {
527 $queryBuilder = $this->createStaticStatisticsQueryBuilder($segment);
528 } else {
529 $queryBuilder = $this->createDynamicStatisticsQueryBuilder();
530 $this->filterSubscribersInDynamicSegment($queryBuilder, $segment);
531 }
532
533 $statement = $this->executeQuery($queryBuilder);
534 $result = $statement->fetch();
535 if (is_array($result)) {
536 return $result;
537 }
538
539 return $this->logErrorAndReturnEmptyResult($segment, $queryBuilder, $result);
540 } catch (Throwable $e) {
541 $this->logQueryException($segment, $e);
542 return $this->emptyStatisticsResult();
543 }
544 }
545
546 /**
547 * @param null|SegmentEntity $segment
548 * @param QueryBuilder $queryBuilder
549 * @param mixed $result
550 * @return int[]
551 */
552 private function logErrorAndReturnEmptyResult(?SegmentEntity $segment, QueryBuilder $queryBuilder, $result): array {
553 $logger = LoggerFactory::getInstance()->getLogger(LoggerFactory::TOPIC_SEGMENTS);
554 $logger->error('Invalid result for segment statistics count', [
555 'segment_id' => $segment ? $segment->getId() : null,
556 'result' => $result,
557 'query' => $queryBuilder->getSQL(),
558 ]);
559
560 return $this->emptyStatisticsResult();
561 }
562
563 private function logQueryException(?SegmentEntity $segment, Throwable $e): void {
564 $logger = LoggerFactory::getInstance()->getLogger(LoggerFactory::TOPIC_SEGMENTS);
565 $logger->error('Failed to execute segment subscribers query: ' . $e->getMessage(), [
566 'segment_id' => $segment ? $segment->getId() : null,
567 'error' => $e->getMessage(),
568 ]);
569 }
570
571 private function emptyStatisticsResult(): array {
572 return [
573 'all' => 0,
574 'trash' => 0,
575 'subscribed' => 0,
576 'unsubscribed' => 0,
577 'inactive' => 0,
578 'unconfirmed' => 0,
579 'bounced' => 0,
580 ];
581 }
582 }
583