Issues (3627)

bundles/CampaignBundle/Entity/LeadRepository.php (2 issues)

1
<?php
2
3
/*
4
 * @copyright   2014 Mautic Contributors. All rights reserved
5
 * @author      Mautic
6
 *
7
 * @link        http://mautic.org
8
 *
9
 * @license     GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html
10
 */
11
12
namespace Mautic\CampaignBundle\Entity;
13
14
use Doctrine\DBAL\Connection;
15
use Doctrine\DBAL\Query\QueryBuilder;
16
use Mautic\CampaignBundle\Entity\Result\CountResult;
17
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
18
use Mautic\CoreBundle\Entity\CommonRepository;
19
20
class LeadRepository extends CommonRepository
21
{
22
    use ContactLimiterTrait;
23
    use SlaveConnectionTrait;
24
25
    /**
26
     * Get the details of leads added to a campaign.
27
     *
28
     * @param      $campaignId
29
     * @param null $leads
30
     *
31
     * @return array
32
     */
33
    public function getLeadDetails($campaignId, $leads = null)
34
    {
35
        $q = $this->getEntityManager()->createQueryBuilder()
36
            ->from('MauticCampaignBundle:Lead', 'lc')
37
            ->select('lc')
38
            ->leftJoin('lc.campaign', 'c')
39
            ->leftJoin('lc.lead', 'l');
40
        $q->where(
41
            $q->expr()->eq('c.id', ':campaign')
42
        )->setParameter('campaign', $campaignId);
43
44
        if (!empty($leads)) {
45
            $q->andWhere(
46
                $q->expr()->in('l.id', ':leads')
47
            )->setParameter('leads', $leads);
48
        }
49
50
        $results = $q->getQuery()->getArrayResult();
51
52
        $return = [];
53
        foreach ($results as $r) {
54
            $return[$r['lead_id']][] = $r;
55
        }
56
57
        return $return;
58
    }
59
60
    /**
61
     * Get leads for a specific campaign.
62
     *
63
     * @param      $campaignId
64
     * @param null $eventId
65
     *
66
     * @return array
67
     */
68
    public function getLeads($campaignId, $eventId = null)
69
    {
70
        $q = $this->getEntityManager()->createQueryBuilder()
71
            ->from('MauticCampaignBundle:Lead', 'lc')
72
            ->select('lc, l')
73
            ->leftJoin('lc.campaign', 'c')
74
            ->leftJoin('lc.lead', 'l');
75
        $q->where(
76
            $q->expr()->andX(
77
                $q->expr()->eq('lc.manuallyRemoved', ':false'),
78
                $q->expr()->eq('c.id', ':campaign')
79
            )
80
        )
81
            ->setParameter('false', false, 'boolean')
82
            ->setParameter('campaign', $campaignId);
83
84
        if (null != $eventId) {
85
            $dq = $this->getEntityManager()->createQueryBuilder();
86
            $dq->select('el.id')
87
                ->from('MauticCampaignBundle:LeadEventLog', 'ell')
88
                ->leftJoin('ell.lead', 'el')
89
                ->leftJoin('ell.event', 'ev')
90
                ->where(
91
                    $dq->expr()->eq('ev.id', ':eventId')
92
                );
93
94
            $q->andWhere('l.id NOT IN('.$dq->getDQL().')')
95
                ->setParameter('eventId', $eventId);
96
        }
97
98
        return $q->getQuery()->getResult();
99
    }
100
101
    /**
102
     * Updates lead ID (e.g. after a lead merge).
103
     *
104
     * @param $fromLeadId
105
     * @param $toLeadId
106
     */
107
    public function updateLead($fromLeadId, $toLeadId)
108
    {
109
        // First check to ensure the $toLead doesn't already exist
110
        $results = $this->getEntityManager()->getConnection()->createQueryBuilder()
111
            ->select('cl.campaign_id')
112
            ->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
113
            ->where('cl.lead_id = '.$toLeadId)
114
            ->execute()
115
            ->fetchAll();
116
        $campaigns = [];
117
        foreach ($results as $r) {
118
            $campaigns[] = $r['campaign_id'];
119
        }
120
121
        $q = $this->getEntityManager()->getConnection()->createQueryBuilder();
122
        $q->update(MAUTIC_TABLE_PREFIX.'campaign_leads')
123
            ->set('lead_id', (int) $toLeadId)
124
            ->where('lead_id = '.(int) $fromLeadId);
125
126
        if (!empty($campaigns)) {
127
            $q->andWhere(
128
                $q->expr()->notIn('campaign_id', $campaigns)
129
            )->execute();
130
131
            // Delete remaining leads as the new lead already belongs
132
            $this->getEntityManager()->getConnection()->createQueryBuilder()
133
                ->delete(MAUTIC_TABLE_PREFIX.'campaign_leads')
134
                ->where('lead_id = '.(int) $fromLeadId)
135
                ->execute();
136
        } else {
137
            $q->execute();
138
        }
139
    }
140
141
    /**
142
     * Check Lead in campaign.
143
     *
144
     * @param Lead  $lead
145
     * @param array $options
146
     *
147
     * @return bool
148
     */
149
    public function checkLeadInCampaigns($lead, $options = [])
150
    {
151
        if (empty($options['campaigns'])) {
152
            return false;
153
        }
154
        $q = $this->_em->getConnection()->createQueryBuilder();
155
        $q->select('l.campaign_id')
156
            ->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'l');
157
        $q->where(
158
                $q->expr()->andX(
159
                    $q->expr()->eq('l.lead_id', ':leadId'),
160
                    $q->expr()->in('l.campaign_id', $options['campaigns'], \Doctrine\DBAL\Connection::PARAM_INT_ARRAY)
0 ignored issues
show
The call to Doctrine\DBAL\Query\Expr...ExpressionBuilder::in() has too many arguments starting with Doctrine\DBAL\Connection::PARAM_INT_ARRAY. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

160
                    $q->expr()->/** @scrutinizer ignore-call */ in('l.campaign_id', $options['campaigns'], \Doctrine\DBAL\Connection::PARAM_INT_ARRAY)

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
161
                )
162
            );
163
164
        if (!empty($options['dataAddedLimit'])) {
165
            $q->andWhere($q->expr()
166
                ->{$options['expr']}('l.date_added', ':dateAdded'))
167
                ->setParameter('dateAdded', $options['dateAdded']);
168
        }
169
170
        $q->setParameter('leadId', $lead->getId());
0 ignored issues
show
The method getId() does not exist on Mautic\CampaignBundle\Entity\Lead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

170
        $q->setParameter('leadId', $lead->/** @scrutinizer ignore-call */ getId());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
171
172
        return (bool) $q->execute()->fetchColumn();
173
    }
174
175
    /**
176
     * @param int $campaignId
177
     * @param int $decisionId
178
     * @param int $parentDecisionId
179
     *
180
     * @return array
181
     */
182
    public function getInactiveContacts($campaignId, $decisionId, $parentDecisionId, ContactLimiter $limiter)
183
    {
184
        // Main query
185
        $q = $this->getSlaveConnection($limiter)->createQueryBuilder();
186
        $q->select('l.lead_id, l.date_added')
187
            ->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'l')
188
            ->where(
189
                $q->expr()->andX(
190
                    $q->expr()->eq('l.campaign_id', ':campaignId'),
191
                    $q->expr()->eq('l.manually_removed', 0)
192
                )
193
            )
194
            // Order by ID so we can query by greater than X contact ID when batching
195
            ->orderBy('l.lead_id')
196
            ->setMaxResults($limiter->getBatchLimit())
197
            ->setParameter('campaignId', (int) $campaignId)
198
            ->setParameter('decisionId', (int) $decisionId);
199
200
        // Contact IDs
201
        $this->updateQueryFromContactLimiter('l', $q, $limiter);
202
203
        // Limit to events that have not been executed or scheduled yet
204
        $eventQb = $this->getSlaveConnection($limiter)->createQueryBuilder();
205
        $eventQb->select('null')
206
            ->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'log')
207
            ->where(
208
                $eventQb->expr()->andX(
209
                    $eventQb->expr()->eq('log.event_id', ':decisionId'),
210
                    $eventQb->expr()->eq('log.lead_id', 'l.lead_id'),
211
                    $eventQb->expr()->eq('log.rotation', 'l.rotation')
212
                )
213
            );
214
        $q->andWhere(
215
            sprintf('NOT EXISTS (%s)', $eventQb->getSQL())
216
        );
217
218
        if ($parentDecisionId) {
219
            // Limit to events that have no grandparent or whose grandparent has already been executed
220
            $grandparentQb = $this->getSlaveConnection($limiter)->createQueryBuilder();
221
            $grandparentQb->select('null')
222
                ->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'grandparent_log')
223
                ->where(
224
                    $grandparentQb->expr()->eq('grandparent_log.event_id', ':grandparentId'),
225
                    $grandparentQb->expr()->eq('grandparent_log.lead_id', 'l.lead_id'),
226
                    $grandparentQb->expr()->eq('grandparent_log.rotation', 'l.rotation')
227
                );
228
            $q->setParameter('grandparentId', (int) $parentDecisionId);
229
230
            $q->andWhere(
231
                sprintf('EXISTS (%s)', $grandparentQb->getSQL())
232
            );
233
        }
234
235
        if ($limiter->hasCampaignLimit() && $limiter->getCampaignLimitRemaining() < $limiter->getBatchLimit()) {
236
            $q->setMaxResults($limiter->getCampaignLimitRemaining());
237
        }
238
239
        $results  = $q->execute()->fetchAll();
240
        $contacts = [];
241
        foreach ($results as $result) {
242
            $contacts[$result['lead_id']] = new \DateTime($result['date_added'], new \DateTimeZone('UTC'));
243
        }
244
245
        if ($limiter->hasCampaignLimit()) {
246
            $limiter->reduceCampaignLimitRemaining(count($contacts));
247
        }
248
249
        return $contacts;
250
    }
251
252
    /**
253
     * This is approximate because the query that fetches contacts per decision is based on if the grandparent has been executed or not.
254
     *
255
     * @return int
256
     */
257
    public function getInactiveContactCount($campaignId, array $decisionIds, ContactLimiter $limiter)
258
    {
259
        // We have to loop over each decision to get a count or else any contact that has executed any single one of the decision IDs
260
        // will not be included potentially resulting in not having the inactive path analyzed
261
262
        $totalCount = 0;
263
264
        foreach ($decisionIds as $decisionId) {
265
            // Main query
266
            $q = $this->getSlaveConnection()->createQueryBuilder();
267
            $q->select('count(*)')
268
                ->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'l')
269
                ->where(
270
                    $q->expr()->andX(
271
                        $q->expr()->eq('l.campaign_id', ':campaignId'),
272
                        $q->expr()->eq('l.manually_removed', 0)
273
                    )
274
                )
275
                // Order by ID so we can query by greater than X contact ID when batching
276
                ->orderBy('l.lead_id')
277
                ->setParameter('campaignId', (int) $campaignId);
278
279
            // Contact IDs
280
            $this->updateQueryFromContactLimiter('l', $q, $limiter, true);
281
282
            // Limit to events that have not been executed or scheduled yet
283
            $eventQb = $this->getSlaveConnection($limiter)->createQueryBuilder();
284
            $eventQb->select('null')
285
                ->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'log')
286
                ->where(
287
                    $eventQb->expr()->andX(
288
                        $eventQb->expr()->eq('log.event_id', $decisionId),
289
                        $eventQb->expr()->eq('log.lead_id', 'l.lead_id'),
290
                        $eventQb->expr()->eq('log.rotation', 'l.rotation')
291
                    )
292
                );
293
            $q->andWhere(
294
                sprintf('NOT EXISTS (%s)', $eventQb->getSQL())
295
            );
296
297
            $totalCount += (int) $q->execute()->fetchColumn();
298
        }
299
300
        return $totalCount;
301
    }
302
303
    /**
304
     * @return array
305
     */
306
    public function getCampaignMembers(array $contactIds, Campaign $campaign)
307
    {
308
        $qb = $this->createQueryBuilder('l');
309
310
        $qb->where(
311
            $qb->expr()->andX(
312
                $qb->expr()->eq('l.campaign', ':campaign'),
313
                $qb->expr()->in('IDENTITY(l.lead)', ':contactIds')
314
            )
315
        )
316
            ->setParameter('campaign', $campaign)
317
            ->setParameter('contactIds', $contactIds, Connection::PARAM_INT_ARRAY);
318
319
        $results = $qb->getQuery()->getResult();
320
321
        $campaignMembers = [];
322
323
        /** @var Lead $result */
324
        foreach ($results as $result) {
325
            $campaignMembers[$result->getLead()->getId()] = $result;
326
        }
327
328
        return $campaignMembers;
329
    }
330
331
    /**
332
     * @param $campaignId
333
     *
334
     * @return array
335
     */
336
    public function getContactRotations(array $contactIds, $campaignId)
337
    {
338
        $qb = $this->getEntityManager()->getConnection()->createQueryBuilder();
339
        $qb->select('cl.lead_id, cl.rotation')
340
            ->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
341
            ->where(
342
                $qb->expr()->andX(
343
                    $qb->expr()->eq('cl.campaign_id', ':campaignId'),
344
                    $qb->expr()->in('cl.lead_id', ':contactIds')
345
                )
346
            )
347
            ->setParameter('campaignId', (int) $campaignId)
348
            ->setParameter('contactIds', $contactIds, Connection::PARAM_INT_ARRAY);
349
350
        $results = $qb->execute()->fetchAll();
351
352
        $contactRotations = [];
353
        foreach ($results as $result) {
354
            $contactRotations[$result['lead_id']] = $result['rotation'];
355
        }
356
357
        return $contactRotations;
358
    }
359
360
    /**
361
     * @param      $campaignId
362
     * @param bool $campaignCanBeRestarted
363
     *
364
     * @return CountResult
365
     */
366
    public function getCountsForCampaignContactsBySegment($campaignId, ContactLimiter $limiter, $campaignCanBeRestarted = false)
367
    {
368
        if (!$segments = $this->getCampaignSegments($campaignId)) {
369
            return new CountResult(0, 0, 0);
370
        }
371
372
        $qb = $this->getSlaveConnection($limiter)->createQueryBuilder();
373
        $qb->select('min(ll.lead_id) as min_id, max(ll.lead_id) as max_id, count(distinct(ll.lead_id)) as the_count')
374
            ->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll')
375
            ->where(
376
                $qb->expr()->andX(
377
                    $qb->expr()->eq('ll.manually_removed', 0),
378
                    $qb->expr()->in('ll.leadlist_id', $segments)
379
                )
380
            );
381
382
        $this->updateQueryFromContactLimiter('ll', $qb, $limiter, true);
383
        $this->updateQueryWithExistingMembershipExclusion($campaignId, $qb, $campaignCanBeRestarted);
384
385
        if (!$campaignCanBeRestarted) {
386
            $this->updateQueryWithHistoryExclusion($campaignId, $qb);
387
        }
388
389
        $result = $qb->execute()->fetch();
390
391
        return new CountResult($result['the_count'], $result['min_id'], $result['max_id']);
392
    }
393
394
    /**
395
     * @param      $campaignId
396
     * @param bool $campaignCanBeRestarted
397
     *
398
     * @return array
399
     */
400
    public function getCampaignContactsBySegments($campaignId, ContactLimiter $limiter, $campaignCanBeRestarted = false)
401
    {
402
        if (!$segments = $this->getCampaignSegments($campaignId)) {
403
            return [];
404
        }
405
406
        $qb = $this->getSlaveConnection($limiter)->createQueryBuilder();
407
        $qb->select('distinct(ll.lead_id) as id')
408
            ->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll')
409
            ->where(
410
                $qb->expr()->andX(
411
                    $qb->expr()->eq('ll.manually_removed', 0),
412
                    $qb->expr()->in('ll.leadlist_id', $segments)
413
                )
414
            );
415
416
        $this->updateQueryFromContactLimiter('ll', $qb, $limiter);
417
        $this->updateQueryWithExistingMembershipExclusion($campaignId, $qb, $campaignCanBeRestarted);
418
419
        if (!$campaignCanBeRestarted) {
420
            $this->updateQueryWithHistoryExclusion($campaignId, $qb);
421
        }
422
423
        $results = $qb->execute()->fetchAll();
424
425
        $contacts = [];
426
        foreach ($results as $result) {
427
            $contacts[$result['id']] = $result['id'];
428
        }
429
430
        return $contacts;
431
    }
432
433
    /**
434
     * @param $campaignId
435
     *
436
     * @return int
437
     */
438
    public function getCountsForOrphanedContactsBySegments($campaignId, ContactLimiter $limiter)
439
    {
440
        $segments = $this->getCampaignSegments($campaignId);
441
442
        $qb = $this->getSlaveConnection($limiter)->createQueryBuilder();
443
        $qb->select('min(cl.lead_id) as min_id, max(cl.lead_id) as max_id, count(cl.lead_id) as the_count')
444
            ->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
445
            ->where(
446
                $qb->expr()->andX(
447
                    $qb->expr()->eq('cl.campaign_id', (int) $campaignId),
448
                    $qb->expr()->eq('cl.manually_removed', 0),
449
                    $qb->expr()->eq('cl.manually_added', 0)
450
                )
451
            );
452
453
        $this->updateQueryFromContactLimiter('cl', $qb, $limiter, true);
454
        $this->updateQueryWithSegmentMembershipExclusion($segments, $qb);
455
456
        $result = $qb->execute()->fetch();
457
458
        return new CountResult($result['the_count'], $result['min_id'], $result['max_id']);
459
    }
460
461
    /**
462
     * @param $campaignId
463
     *
464
     * @return array
465
     */
466
    public function getOrphanedContacts($campaignId, ContactLimiter $limiter)
467
    {
468
        $segments = $this->getCampaignSegments($campaignId);
469
470
        $qb = $this->getSlaveConnection($limiter)->createQueryBuilder();
471
        $qb->select('cl.lead_id as id')
472
            ->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
473
            ->where(
474
                $qb->expr()->andX(
475
                    $qb->expr()->eq('cl.campaign_id', (int) $campaignId),
476
                    $qb->expr()->eq('cl.manually_removed', 0),
477
                    $qb->expr()->eq('cl.manually_added', 0)
478
                )
479
            );
480
481
        $this->updateQueryFromContactLimiter('cl', $qb, $limiter, false);
482
        $this->updateQueryWithSegmentMembershipExclusion($segments, $qb);
483
484
        $results = $qb->execute()->fetchAll();
485
486
        $contacts = [];
487
        foreach ($results as $result) {
488
            $contacts[$result['id']] = $result['id'];
489
        }
490
491
        return $contacts;
492
    }
493
494
    /**
495
     * Takes an array of contact ID's and increments
496
     * their current rotation in a campaign by 1.
497
     *
498
     * @param int $campaignId
499
     *
500
     * @return bool
501
     */
502
    public function incrementCampaignRotationForContacts(array $contactIds, $campaignId)
503
    {
504
        $q = $this->getEntityManager()->getConnection()->createQueryBuilder();
505
506
        $q->update(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
507
            ->set('cl.rotation', 'cl.rotation + 1')
508
            ->where(
509
                $q->expr()->andX(
510
                    $q->expr()->in('cl.lead_id', ':contactIds'),
511
                    $q->expr()->eq('cl.campaign_id', ':campaignId')
512
                )
513
            )
514
            ->setParameter('contactIds', $contactIds, Connection::PARAM_INT_ARRAY)
515
            ->setParameter('campaignId', (int) $campaignId)
516
            ->execute();
517
    }
518
519
    /**
520
     * @param $campaignId
521
     *
522
     * @return array
523
     */
524
    private function getCampaignSegments($campaignId)
525
    {
526
        // Get published segments for this campaign
527
        $segmentResults = $this->getEntityManager()->getConnection()->createQueryBuilder()
528
            ->select('cl.leadlist_id')
529
            ->from(MAUTIC_TABLE_PREFIX.'campaign_leadlist_xref', 'cl')
530
            ->join('cl', MAUTIC_TABLE_PREFIX.'lead_lists', 'll', 'll.id = cl.leadlist_id and ll.is_published = 1')
531
            ->where('cl.campaign_id = '.(int) $campaignId)
532
            ->execute()
533
            ->fetchAll();
534
535
        if (empty($segmentResults)) {
536
            // No segments so no contacts
537
            return [];
538
        }
539
540
        $segments = [];
541
        foreach ($segmentResults as $result) {
542
            $segments[] = $result['leadlist_id'];
543
        }
544
545
        return $segments;
546
    }
547
548
    /**
549
     * @param $campaignId
550
     */
551
    private function updateQueryWithExistingMembershipExclusion($campaignId, QueryBuilder $qb, $campaignCanBeRestarted = false)
552
    {
553
        $membershipConditions = [
554
            $qb->expr()->eq('cl.lead_id', 'll.lead_id'),
555
            $qb->expr()->eq('cl.campaign_id', (int) $campaignId),
556
        ];
557
558
        if ($campaignCanBeRestarted) {
559
            $membershipConditions[] = $qb->expr()->eq('cl.manually_removed', 0);
560
        }
561
562
        $subq = $this->getEntityManager()->getConnection()->createQueryBuilder()
563
            ->select('null')
564
            ->from(MAUTIC_TABLE_PREFIX.'campaign_leads', 'cl')
565
            ->where(
566
                $qb->expr()->andX(...$membershipConditions)
567
            );
568
569
        $qb->andWhere(
570
            sprintf('NOT EXISTS (%s)', $subq->getSQL())
571
        );
572
    }
573
574
    private function updateQueryWithSegmentMembershipExclusion(array $segments, QueryBuilder $qb)
575
    {
576
        if (0 === count($segments)) {
577
            // No segments so nothing to exclude
578
            return;
579
        }
580
581
        $subq = $this->getEntityManager()->getConnection()->createQueryBuilder()
582
            ->select('null')
583
            ->from(MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'll')
584
            ->where(
585
                $qb->expr()->andX(
586
                    $qb->expr()->eq('ll.lead_id', 'cl.lead_id'),
587
                    $qb->expr()->eq('ll.manually_removed', 0),
588
                    $qb->expr()->in('ll.leadlist_id', $segments)
589
                )
590
            );
591
592
        $qb->andWhere(
593
            sprintf('NOT EXISTS (%s)', $subq->getSQL())
594
        );
595
    }
596
597
    /**
598
     * Exclude contacts with any previous campaign history; this is mainly BC for pre 2.14.0 where the membership entry was deleted.
599
     *
600
     * @param $campaignId
601
     */
602
    private function updateQueryWithHistoryExclusion($campaignId, QueryBuilder $qb)
603
    {
604
        $subq = $this->getEntityManager()->getConnection()->createQueryBuilder()
605
            ->select('null')
606
            ->from(MAUTIC_TABLE_PREFIX.'campaign_lead_event_log', 'el')
607
            ->where(
608
                $qb->expr()->andX(
609
                    $qb->expr()->eq('el.lead_id', 'll.lead_id'),
610
                    $qb->expr()->eq('el.campaign_id', (int) $campaignId)
611
                )
612
            );
613
614
        $qb->andWhere(
615
            sprintf('NOT EXISTS (%s)', $subq->getSQL())
616
        );
617
    }
618
}
619