PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.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 1 month ago RestApi 1 month ago SegmentDependencyValidator.php 3 years ago SegmentListingRepository.php 1 month ago SegmentSaveController.php 2 weeks ago SegmentSubscribersRepository.php 2 weeks ago SegmentsFinder.php 3 years ago SegmentsRepository.php 2 weeks ago SegmentsSimpleListRepository.php 2 months ago SubscribersFinder.php 1 month ago WP.php 2 weeks ago WooCommerce.php 2 weeks ago index.php 3 years ago
SegmentSubscribersRepository.php
696 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 MailPoet\Settings\SettingsController;
19 use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
20 use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
21 use MailPoetVendor\Doctrine\DBAL\Result;
22 use MailPoetVendor\Doctrine\ORM\EntityManager;
23 use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
24 use MailPoetVendor\Doctrine\ORM\QueryBuilder as ORMQueryBuilder;
25 use Throwable;
26
27 class SegmentSubscribersRepository {
28 const BACKFILLED_SETTING_KEY = 'subscribers_segments_count_backfilled';
29
30 /** @var EntityManager */
31 private $entityManager;
32
33 /** @var FilterHandler */
34 private $filterHandler;
35
36 /** @var SegmentsRepository */
37 private $segmentsRepository;
38
39 /** @var SettingsController */
40 private $settings;
41
42 public function __construct(
43 EntityManager $entityManager,
44 FilterHandler $filterHandler,
45 SegmentsRepository $segmentsRepository,
46 SettingsController $settings
47 ) {
48 $this->entityManager = $entityManager;
49 $this->filterHandler = $filterHandler;
50 $this->segmentsRepository = $segmentsRepository;
51 $this->settings = $settings;
52 }
53
54 public function findSubscribersIdsInSegment(int $segmentId, ?array $candidateIds = null): array {
55 return $this->loadSubscriberIdsInSegment($segmentId, $candidateIds);
56 }
57
58 public function getSubscriberIdsInSegment(int $segmentId): array {
59 return $this->loadSubscriberIdsInSegment($segmentId);
60 }
61
62 public function getSubscribersCount(int $segmentId, ?string $status = null): int {
63 $segment = $this->getSegment($segmentId);
64 $result = $this->getSubscribersStatisticsCount($segment);
65 return (int)$result[$status ?: 'all'];
66 }
67
68 public function getSubscribersCountBySegmentIds(array $segmentIds, ?string $status = null, ?int $filterSegmentId = null): int {
69 $segments = $this->segmentsRepository->findByIds($segmentIds);
70 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
71 $queryBuilder = $this->createCountQueryBuilder();
72
73 $subQueries = [];
74 foreach ($segments as $segment) {
75 $segmentQb = $this->createCountQueryBuilder();
76 $segmentQb->select("{$subscribersTable}.id AS inner_id");
77
78 if ($segment->isStatic()) {
79 $segmentQb = $this->filterSubscribersInStaticSegment($segmentQb, $segment, $status);
80 } else {
81 $segmentQb = $this->filterSubscribersInDynamicSegment($segmentQb, $segment, $status);
82 }
83
84 // inner parameters and types have to be merged to outer queryBuilder
85 $queryBuilder->setParameters(array_merge(
86 $segmentQb->getParameters(),
87 $queryBuilder->getParameters()
88 ), array_merge(
89 $segmentQb->getParameterTypes(),
90 $queryBuilder->getParameterTypes()
91 ));
92 $subQueries[] = $segmentQb->getSQL();
93 }
94
95 $queryBuilder->innerJoin(
96 $subscribersTable,
97 sprintf('(%s)', join(' UNION ', $subQueries)),
98 'inner_subscribers',
99 "inner_subscribers.inner_id = {$subscribersTable}.id"
100 );
101
102 try {
103 if (is_int($filterSegmentId)) {
104 $filterSegment = $this->segmentsRepository->verifyDynamicSegmentExists($filterSegmentId);
105 $filterSegmentQb = $this->createCountQueryBuilder();
106 $filterSegmentQb->select("{$subscribersTable}.id AS filter_segment_subscriber_id");
107 $filterSegmentQb = $this->filterSubscribersInDynamicSegment($filterSegmentQb, $filterSegment, $status);
108 $queryBuilder->setParameters(array_merge($filterSegmentQb->getParameters(), $queryBuilder->getParameters()), array_merge($filterSegmentQb->getParameterTypes(), $queryBuilder->getParameterTypes()));
109 $queryBuilder->innerJoin(
110 $subscribersTable,
111 sprintf('(%s)', $filterSegmentQb->getSQL()),
112 'filter_segment',
113 "filter_segment.filter_segment_subscriber_id = {$subscribersTable}.id"
114 );
115 }
116 } catch (InvalidStateException $exception) {
117 return 0;
118 }
119
120 try {
121 $statement = $this->executeQuery($queryBuilder);
122 /** @var string $result */
123 $result = $statement->fetchOne();
124 return (int)$result;
125 } catch (Throwable $e) {
126 $this->logQueryException(null, $e);
127 return 0;
128 }
129 }
130
131 /**
132 * @param DynamicSegmentFilterData[] $filters
133 * @return int
134 * @throws InvalidStateException
135 */
136 public function getDynamicSubscribersCount(array $filters): int {
137 try {
138 $segment = new SegmentEntity('temporary segment', SegmentEntity::TYPE_DYNAMIC, '');
139 foreach ($filters as $filter) {
140 $segment->addDynamicFilter(new DynamicSegmentFilterEntity($segment, $filter));
141 }
142 $queryBuilder = $this->createDynamicStatisticsQueryBuilder();
143 $queryBuilder = $this->filterSubscribersInDynamicSegment($queryBuilder, $segment, null);
144 $statement = $this->executeQuery($queryBuilder);
145 $result = $statement->fetch();
146
147 if (!is_array($result)) {
148 $result = $this->logErrorAndReturnEmptyResult(null, $queryBuilder, $result);
149 }
150
151 return isset($result['all']) && is_numeric($result['all']) ? (int)$result['all'] : 0;
152 } catch (Throwable $e) {
153 $this->logQueryException(null, $e);
154 return 0;
155 }
156 }
157
158 private function createCountQueryBuilder(): QueryBuilder {
159 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
160 return $this->entityManager
161 ->getConnection()
162 ->createQueryBuilder()
163 ->select("count(DISTINCT $subscribersTable.id)")
164 ->from($subscribersTable);
165 }
166
167 private function createDynamicStatisticsQueryBuilder(): QueryBuilder {
168 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
169 return $this->entityManager
170 ->getConnection()
171 ->createQueryBuilder()
172 ->from($subscribersTable)
173 ->addSelect("IFNULL(SUM(
174 CASE WHEN $subscribersTable.deleted_at IS NULL
175 THEN 1 ELSE 0 END
176 ), 0) as `all`")
177 ->addSelect("IFNULL(SUM(
178 CASE WHEN $subscribersTable.deleted_at IS NOT NULL
179 THEN 1 ELSE 0 END
180 ), 0) as trash")
181 ->addSelect("IFNULL(SUM(
182 CASE WHEN $subscribersTable.status = :status_subscribed AND $subscribersTable.deleted_at IS NULL
183 THEN 1 ELSE 0 END
184 ), 0) as :status_subscribed")
185 ->addSelect("IFNULL(SUM(
186 CASE WHEN $subscribersTable.status = :status_unsubscribed AND $subscribersTable.deleted_at IS NULL
187 THEN 1 ELSE 0 END
188 ), 0) as :status_unsubscribed")
189 ->addSelect("IFNULL(SUM(
190 CASE WHEN $subscribersTable.status = :status_inactive AND $subscribersTable.deleted_at IS NULL
191 THEN 1 ELSE 0 END
192 ), 0) as :status_inactive")
193 ->addSelect("IFNULL(SUM(
194 CASE WHEN $subscribersTable.status = :status_unconfirmed AND $subscribersTable.deleted_at IS NULL
195 THEN 1 ELSE 0 END
196 ), 0) as :status_unconfirmed")
197 ->addSelect("IFNULL(SUM(
198 CASE WHEN $subscribersTable.status = :status_bounced AND $subscribersTable.deleted_at IS NULL
199 THEN 1 ELSE 0 END
200 ), 0) as :status_bounced")
201 ->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
202 ->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
203 ->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
204 ->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
205 ->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
206 }
207
208 /**
209 * Per-status counts for a static segment, derived from cheap indexed reads
210 * instead of a single COUNT(DISTINCT) join over the whole membership table.
211 *
212 * On a large list that join scans millions of rows (tens of seconds on a 5M
213 * list). Here the dominant "subscribed" mass is never counted directly: we
214 * read the total membership (index-only), subtract the trashed members, and
215 * subtract the sparse non-subscribed buckets — each a seek driven from the
216 * subscriber status index or the (segment_id, status, subscriber_id) index.
217 *
218 * subscriber_segment.status is only ever subscribed/unsubscribed, so the
219 * buckets partition the non-deleted members exactly and
220 * subscribed = all - unsubscribed - inactive - unconfirmed - bounced is exact,
221 * equivalent to the old query's "s.status = subscribed AND ss.status =
222 * subscribed".
223 *
224 * @return array<string, int>
225 */
226 private function getStaticSegmentStatisticsCount(SegmentEntity $segment): array {
227 $segmentId = (int)$segment->getId();
228 $unsubscribed = SubscriberEntity::STATUS_UNSUBSCRIBED;
229
230 $totalMembership = $this->countStaticSegmentMembers($segmentId);
231 $trash = $this->countStaticSegmentMembers($segmentId, function (QueryBuilder $qb): void {
232 $qb->andWhere('s.deleted_at IS NOT NULL');
233 });
234
235 $inactive = $this->countStaticSegmentMembersWithStatus($segmentId, SubscriberEntity::STATUS_INACTIVE);
236 $unconfirmed = $this->countStaticSegmentMembersWithStatus($segmentId, SubscriberEntity::STATUS_UNCONFIRMED);
237 $bounced = $this->countStaticSegmentMembersWithStatus($segmentId, SubscriberEntity::STATUS_BOUNCED);
238
239 // unsubscribed = members unsubscribed globally OR unsubscribed from this list.
240 // The two halves are disjoint (the second excludes s.status = unsubscribed),
241 // so their counts add up without double counting the overlap.
242 $unsubscribedGlobal = $this->countStaticSegmentMembers($segmentId, function (QueryBuilder $qb) use ($unsubscribed): void {
243 $qb->andWhere('s.deleted_at IS NULL')
244 ->andWhere('s.status = :unsubGlobal')
245 ->setParameter('unsubGlobal', $unsubscribed);
246 });
247 $unsubscribedFromList = $this->countStaticSegmentMembers($segmentId, function (QueryBuilder $qb) use ($unsubscribed): void {
248 $qb->andWhere('s.deleted_at IS NULL')
249 ->andWhere('ss.status = :unsubList')
250 ->andWhere('s.status != :notUnsub')
251 ->setParameter('unsubList', $unsubscribed)
252 ->setParameter('notUnsub', $unsubscribed);
253 });
254 $unsubscribedCount = $unsubscribedGlobal + $unsubscribedFromList;
255
256 $all = max(0, $totalMembership - $trash);
257 $subscribed = max(0, $all - $unsubscribedCount - $inactive - $unconfirmed - $bounced);
258
259 return [
260 'all' => $all,
261 'trash' => $trash,
262 SubscriberEntity::STATUS_SUBSCRIBED => $subscribed,
263 SubscriberEntity::STATUS_UNSUBSCRIBED => $unsubscribedCount,
264 SubscriberEntity::STATUS_INACTIVE => $inactive,
265 SubscriberEntity::STATUS_UNCONFIRMED => $unconfirmed,
266 SubscriberEntity::STATUS_BOUNCED => $bounced,
267 ];
268 }
269
270 /**
271 * A non-subscribed global status bucket: members of the list whose subscriber
272 * status is $status and who are not unsubscribed from the list (a list
273 * unsubscribe wins, placing them in the unsubscribed bucket instead). Driven
274 * from the subscriber status index, so it seeks over a sparse population.
275 *
276 * ss.status is only ever subscribed/unsubscribed, so "not unsubscribed from
277 * the list" is expressed as an equality (ss.status = subscribed) rather than
278 * ss.status != unsubscribed — same rows, but an equality seek the
279 * (segment_id, status, subscriber_id) index can use.
280 */
281 private function countStaticSegmentMembersWithStatus(int $segmentId, string $status): int {
282 return $this->countStaticSegmentMembers($segmentId, function (QueryBuilder $qb) use ($status): void {
283 $qb->andWhere('s.deleted_at IS NULL')
284 ->andWhere('s.status = :memberStatus')
285 ->andWhere('ss.status = :memberSubscribed')
286 ->setParameter('memberStatus', $status)
287 ->setParameter('memberSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
288 });
289 }
290
291 /**
292 * Count members of a static segment. Without $constrain this is an index-only
293 * read of the membership table (no join) — the cheap total the per-status
294 * derivation leans on. Pass $constrain to filter on subscriber columns; the
295 * subscribers table is joined in (alias 's') only then, so the unconstrained
296 * total never pays for the join it doesn't need.
297 *
298 * @param callable(QueryBuilder): void|null $constrain
299 */
300 private function countStaticSegmentMembers(int $segmentId, ?callable $constrain = null): int {
301 $subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
302 $queryBuilder = $this->entityManager->getConnection()->createQueryBuilder()
303 ->select('COUNT(*)')
304 ->from($subscriberSegmentTable, 'ss')
305 ->where('ss.segment_id = :segmentId')
306 ->setParameter('segmentId', $segmentId);
307 if ($constrain !== null) {
308 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
309 $queryBuilder->innerJoin('ss', $subscribersTable, 's', 's.id = ss.subscriber_id');
310 $constrain($queryBuilder);
311 }
312 $count = $this->executeQuery($queryBuilder)->fetchOne();
313 return is_numeric($count) ? (int)$count : 0;
314 }
315
316 private function createStaticGlobalStatusStatisticsQueryBuilder(SegmentEntity $segment): QueryBuilder {
317 $subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
318 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
319 return $this->entityManager
320 ->getConnection()
321 ->createQueryBuilder()
322 ->from($subscriberSegmentTable, 'subscriber_segment')
323 ->where('subscriber_segment.segment_id = :segment_id')
324 ->setParameter('segment_id', $segment->getId())
325 ->join('subscriber_segment', $subscribersTable, 'subscribers', 'subscribers.id = subscriber_segment.subscriber_id')
326 ->addSelect('IFNULL(SUM(
327 CASE WHEN subscribers.deleted_at IS NULL
328 THEN 1 ELSE 0 END
329 ), 0) as `all`')
330 ->addSelect('IFNULL(SUM(
331 CASE WHEN subscribers.deleted_at IS NOT NULL
332 THEN 1 ELSE 0 END
333 ), 0) as trash')
334 ->addSelect('IFNULL(SUM(
335 CASE WHEN subscribers.status = :status_subscribed AND subscribers.deleted_at IS NULL
336 THEN 1 ELSE 0 END
337 ), 0) as :status_subscribed')
338 ->addSelect('IFNULL(SUM(
339 CASE WHEN subscribers.status = :status_unsubscribed AND subscribers.deleted_at IS NULL
340 THEN 1 ELSE 0 END
341 ), 0) as :status_unsubscribed')
342 ->addSelect('IFNULL(SUM(
343 CASE WHEN subscribers.status = :status_inactive AND subscribers.deleted_at IS NULL
344 THEN 1 ELSE 0 END
345 ), 0) as :status_inactive')
346 ->addSelect('IFNULL(SUM(
347 CASE WHEN subscribers.status = :status_unconfirmed AND subscribers.deleted_at IS NULL
348 THEN 1 ELSE 0 END
349 ), 0) as :status_unconfirmed')
350 ->addSelect('IFNULL(SUM(
351 CASE WHEN subscribers.status = :status_bounced AND subscribers.deleted_at IS NULL
352 THEN 1 ELSE 0 END
353 ), 0) as :status_bounced')
354 ->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
355 ->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
356 ->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
357 ->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
358 ->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
359 }
360
361 public function getSubscribersWithoutSegmentCount(): int {
362 if ($this->isSegmentsCountColumnReady()) {
363 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
364 $count = $this->entityManager->getConnection()->executeQuery(
365 "SELECT COUNT(*) FROM {$subscribersTable} WHERE segments_count = 0"
366 )->fetchOne();
367 return is_numeric($count) ? (int)$count : 0;
368 }
369
370 $queryBuilder = $this->entityManager->createQueryBuilder();
371 $queryBuilder
372 ->select('COUNT(DISTINCT s) AS subscribersCount')
373 ->from(SubscriberEntity::class, 's');
374 $this->addConstraintsForSubscribersWithoutSegment($queryBuilder);
375 return (int)$queryBuilder->getQuery()->getSingleScalarResult();
376 }
377
378 /**
379 * segments_count is only trustworthy once the backfill sweep has finished
380 * (see SubscribersSegmentsCountSync). Until then the read paths fall back to
381 * the anti-join so they never report 0 for everyone.
382 */
383 public function isSegmentsCountColumnReady(): bool {
384 return (bool)$this->settings->get(self::BACKFILLED_SETTING_KEY, false);
385 }
386
387 /**
388 * Flip the readiness flag once the backfill sweep has recomputed the whole
389 * table. Called by SubscribersSegmentsCountSync; the setting key lives here
390 * because this is the read path that decides whether to trust the column.
391 */
392 public function markSegmentsCountColumnReady(): void {
393 $this->settings->set(self::BACKFILLED_SETTING_KEY, true);
394 }
395
396 public function getSubscribersWithoutSegmentStatisticsCount(): array {
397 try {
398 $queryBuilder = $this->createWithoutSegmentStatisticsQueryBuilder();
399
400 $this->addConstraintsForSubscribersWithoutSegmentToDBAL($queryBuilder);
401
402 $statement = $this->executeQuery($queryBuilder);
403 $result = $statement->fetch();
404
405 if (is_array($result)) {
406 return $result;
407 }
408
409 return $this->logErrorAndReturnEmptyResult(null, $queryBuilder, $result);
410 } catch (Throwable $e) {
411 $this->logQueryException(null, $e);
412 return $this->emptyStatisticsResult();
413 }
414 }
415
416 private function createWithoutSegmentStatisticsQueryBuilder(): QueryBuilder {
417 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
418 $queryBuilder = $this->entityManager
419 ->getConnection()
420 ->createQueryBuilder();
421 $queryBuilder
422 ->addSelect('IFNULL(SUM(
423 CASE WHEN s.deleted_at IS NULL
424 THEN 1 ELSE 0 END
425 ), 0) as `all`')
426 ->addSelect('IFNULL(SUM(
427 CASE WHEN s.deleted_at IS NOT NULL
428 THEN 1 ELSE 0 END
429 ), 0) as trash')
430 ->addSelect('IFNULL(SUM(
431 CASE WHEN s.status = :status_subscribed AND s.deleted_at IS NULL
432 THEN 1 ELSE 0 END
433 ), 0) as :status_subscribed')
434 ->addSelect('IFNULL(SUM(
435 CASE WHEN s.status = :status_unsubscribed AND s.deleted_at IS NULL
436 THEN 1 ELSE 0 END
437 ), 0) as :status_unsubscribed')
438 ->addSelect('IFNULL(SUM(
439 CASE WHEN s.status = :status_inactive AND s.deleted_at IS NULL
440 THEN 1 ELSE 0 END
441 ), 0) as :status_inactive')
442 ->addSelect('IFNULL(SUM(
443 CASE WHEN s.status = :status_unconfirmed AND s.deleted_at IS NULL
444 THEN 1 ELSE 0 END
445 ), 0) as :status_unconfirmed')
446 ->addSelect('IFNULL(SUM(
447 CASE WHEN s.status = :status_bounced AND s.deleted_at IS NULL
448 THEN 1 ELSE 0 END
449 ), 0) as :status_bounced')
450 ->from($subscribersTable, 's')
451 ->setParameter('status_subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
452 ->setParameter('status_unsubscribed', SubscriberEntity::STATUS_UNSUBSCRIBED)
453 ->setParameter('status_inactive', SubscriberEntity::STATUS_INACTIVE)
454 ->setParameter('status_unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
455 ->setParameter('status_bounced', SubscriberEntity::STATUS_BOUNCED);
456
457 return $queryBuilder;
458 }
459
460 public function addConstraintsForSubscribersWithoutSegment(ORMQueryBuilder $queryBuilder): void {
461 if ($this->isSegmentsCountColumnReady()) {
462 $queryBuilder->andWhere('s.segmentsCount = 0');
463 return;
464 }
465
466 $deletedSegmentsQueryBuilder = $this->entityManager->createQueryBuilder();
467 $deletedSegmentsQueryBuilder->select('sg.id')
468 ->from(SegmentEntity::class, 'sg')
469 ->where($deletedSegmentsQueryBuilder->expr()->isNotNull('sg.deletedAt'));
470
471 $queryBuilder
472 ->leftJoin(
473 's.subscriberSegments',
474 'ssg',
475 Join::WITH,
476 (string)$queryBuilder->expr()->andX(
477 $queryBuilder->expr()->eq('ssg.subscriber', 's.id'),
478 $queryBuilder->expr()->eq('ssg.status', ':statusSubscribed'),
479 $queryBuilder->expr()->notIn('ssg.segment', $deletedSegmentsQueryBuilder->getDQL())
480 )
481 )
482 ->andWhere('ssg.id IS NULL')
483 ->setParameter('statusSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
484 }
485
486 public function addConstraintsForSubscribersWithoutSegmentToDBAL(QueryBuilder $queryBuilder): void {
487 if ($this->isSegmentsCountColumnReady()) {
488 $queryBuilder->andWhere('s.segments_count = 0');
489 return;
490 }
491
492 $deletedSegmentsQueryBuilder = $this->entityManager->createQueryBuilder();
493 $subscribersSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
494 $deletedSegmentsQueryBuilder->select('sg.id')
495 ->from(SegmentEntity::class, 'sg')
496 ->where($deletedSegmentsQueryBuilder->expr()->isNotNull('sg.deletedAt'));
497
498 $queryBuilder
499 ->leftJoin(
500 's',
501 $subscribersSegmentTable,
502 'ssg',
503 (string)$queryBuilder->expr()->and(
504 $queryBuilder->expr()->eq('ssg.subscriber_id', 's.id'),
505 $queryBuilder->expr()->eq('ssg.status', ':statusSubscribed'),
506 $queryBuilder->expr()->notIn('ssg.segment_id', $deletedSegmentsQueryBuilder->getQuery()->getSQL())
507 )
508 )
509 ->andWhere('ssg.id IS NULL')
510 ->setParameter('statusSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
511 }
512
513 private function loadSubscriberIdsInSegment(int $segmentId, ?array $candidateIds = null): array {
514 $segment = $this->getSegment($segmentId);
515 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
516 $queryBuilder = $this->entityManager
517 ->getConnection()
518 ->createQueryBuilder()
519 ->select("DISTINCT $subscribersTable.id")
520 ->from($subscribersTable);
521
522 if ($segment->isStatic()) {
523 $queryBuilder = $this->filterSubscribersInStaticSegment($queryBuilder, $segment, SubscriberEntity::STATUS_SUBSCRIBED);
524 } else {
525 $queryBuilder = $this->filterSubscribersInDynamicSegment($queryBuilder, $segment, SubscriberEntity::STATUS_SUBSCRIBED);
526 }
527
528 if ($candidateIds) {
529 $queryBuilder->andWhere("$subscribersTable.id IN (:candidateIds)")
530 ->setParameter('candidateIds', $candidateIds, ArrayParameterType::STRING);
531 }
532
533 $statement = $this->executeQuery($queryBuilder);
534 $result = $statement->fetchAll();
535 return array_column($result, 'id');
536 }
537
538 private function filterSubscribersInStaticSegment(
539 QueryBuilder $queryBuilder,
540 SegmentEntity $segment,
541 ?string $status = null
542 ): QueryBuilder {
543 $subscribersSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
544 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
545 $parameterName = "segment_{$segment->getId()}"; // When we use this method more times the parameter name has to be unique
546 $queryBuilder = $queryBuilder->join(
547 $subscribersTable,
548 $subscribersSegmentsTable,
549 'subsegment',
550 "subsegment.subscriber_id = $subscribersTable.id AND subsegment.segment_id = :$parameterName"
551 )->andWhere("$subscribersTable.deleted_at IS NULL")
552 ->setParameter($parameterName, $segment->getId());
553 if ($status) {
554 $queryBuilder = $queryBuilder->andWhere("$subscribersTable.status = :status")
555 ->andWhere("subsegment.status = :status")
556 ->setParameter('status', $status);
557 }
558 return $queryBuilder;
559 }
560
561 private function filterSubscribersInDynamicSegment(
562 QueryBuilder $queryBuilder,
563 SegmentEntity $segment,
564 ?string $status = null
565 ): QueryBuilder {
566 $filters = [];
567 $dynamicFilters = $segment->getDynamicFilters();
568 foreach ($dynamicFilters as $dynamicFilter) {
569 $filters[] = $dynamicFilter->getFilterData();
570 }
571
572 // We don't allow dynamic segment without filers since it would return all subscribers
573 // For BC compatibility fetching an empty result
574 if (count($filters) === 0) {
575 return $queryBuilder->andWhere('0 = 1');
576 } elseif ($segment instanceof SegmentEntity) {
577 try {
578 $queryBuilder = $this->filterHandler->apply($queryBuilder, $segment);
579 } catch (InvalidFilterException $e) {
580 // If a segment has an invalid filter, we should simply consider it empty instead of throwing
581 // an unhandled error. Unhandled errors here can break many admin pages.
582 $queryBuilder->andWhere('0 = 1');
583 }
584 }
585 $subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
586 $queryBuilder = $queryBuilder->andWhere("$subscribersTable.deleted_at IS NULL");
587 if ($status) {
588 $queryBuilder = $queryBuilder->andWhere("$subscribersTable.status = :status")
589 ->setParameter('status', $status);
590 }
591 return $queryBuilder;
592 }
593
594 private function getSegment(int $id): SegmentEntity {
595 $segment = $this->entityManager->find(SegmentEntity::class, $id);
596 if (!$segment instanceof SegmentEntity) {
597 throw new NotFoundException('Segment not found');
598 }
599 return $segment;
600 }
601
602 private function executeQuery(QueryBuilder $queryBuilder): Result {
603 try {
604 $this->entityManager->getConnection()->executeStatement('SET SESSION SQL_BIG_SELECTS=1');
605 } catch (Throwable $e) {
606 // Best-effort: some hosts may not allow SET SESSION, continue without it
607 }
608 $result = $queryBuilder->execute();
609 // Execute for select always returns statement but PHP Stan doesn't know that :(
610 if (!$result instanceof Result) {
611 throw new InvalidStateException('Invalid query.');
612 }
613 return $result;
614 }
615
616 public function getSubscribersGlobalStatusStatisticsCount(SegmentEntity $segment): array {
617 try {
618 if ($segment->isStatic()) {
619 $queryBuilder = $this->createStaticGlobalStatusStatisticsQueryBuilder($segment);
620 } else {
621 $queryBuilder = $this->createDynamicStatisticsQueryBuilder();
622 $this->filterSubscribersInDynamicSegment($queryBuilder, $segment);
623 }
624
625 $statement = $this->executeQuery($queryBuilder);
626 $result = $statement->fetch();
627 if (is_array($result)) {
628 return $result;
629 }
630
631 return $this->logErrorAndReturnEmptyResult($segment, $queryBuilder, $result);
632 } catch (Throwable $e) {
633 $this->logQueryException($segment, $e);
634 return $this->emptyStatisticsResult();
635 }
636 }
637
638 public function getSubscribersStatisticsCount(SegmentEntity $segment): array {
639 try {
640 if ($segment->isStatic()) {
641 return $this->getStaticSegmentStatisticsCount($segment);
642 }
643
644 $queryBuilder = $this->createDynamicStatisticsQueryBuilder();
645 $this->filterSubscribersInDynamicSegment($queryBuilder, $segment);
646 $statement = $this->executeQuery($queryBuilder);
647 $result = $statement->fetch();
648 if (is_array($result)) {
649 return $result;
650 }
651
652 return $this->logErrorAndReturnEmptyResult($segment, $queryBuilder, $result);
653 } catch (Throwable $e) {
654 $this->logQueryException($segment, $e);
655 return $this->emptyStatisticsResult();
656 }
657 }
658
659 /**
660 * @param null|SegmentEntity $segment
661 * @param QueryBuilder $queryBuilder
662 * @param mixed $result
663 * @return int[]
664 */
665 private function logErrorAndReturnEmptyResult(?SegmentEntity $segment, QueryBuilder $queryBuilder, $result): array {
666 $logger = LoggerFactory::getInstance()->getLogger(LoggerFactory::TOPIC_SEGMENTS);
667 $logger->error('Invalid result for segment statistics count', [
668 'segment_id' => $segment ? $segment->getId() : null,
669 'result' => $result,
670 'query' => $queryBuilder->getSQL(),
671 ]);
672
673 return $this->emptyStatisticsResult();
674 }
675
676 private function logQueryException(?SegmentEntity $segment, Throwable $e): void {
677 $logger = LoggerFactory::getInstance()->getLogger(LoggerFactory::TOPIC_SEGMENTS);
678 $logger->error('Failed to execute segment subscribers query: ' . $e->getMessage(), [
679 'segment_id' => $segment ? $segment->getId() : null,
680 'error' => $e->getMessage(),
681 ]);
682 }
683
684 private function emptyStatisticsResult(): array {
685 return [
686 'all' => 0,
687 'trash' => 0,
688 'subscribed' => 0,
689 'unsubscribed' => 0,
690 'inactive' => 0,
691 'unconfirmed' => 0,
692 'bounced' => 0,
693 ];
694 }
695 }
696