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