@@ -612,30 +612,30 @@ public function countArchived(string $userId): int {
612612 * @throws Exception
613613 */
614614 public function countAllClicks (string $ userId ): int {
615- $ qb = $ this ->db ->getQueryBuilder ();
616- $ qb ->selectAlias ($ qb ->func ()->sum ('b.clickcount ' ), 'count ' );
617- $ qb
618- ->from ('bookmarks ' , 'b ' )
619- ->innerJoin ('b ' , 'bookmarks_tree ' , 'tr ' , 'b.id = tr.id AND tr.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tr.soft_deleted_at is NULL ' )
620- ->where ($ qb ->expr ()->eq ('b.user_id ' , $ qb ->createPositionalParameter ($ userId )))
621- ->andWhere ($ qb ->expr ()->neq ('b.clickcount ' , $ qb ->createPositionalParameter (0 , IQueryBuilder::PARAM_INT )));
622- $ result = $ qb ->executeQuery ();
623- $ userOwnerClickCount = $ result ->fetch (PDO ::FETCH_COLUMN );
624- $ result ->closeCursor ();
615+ // Sum clicks across the recursive folder_tree CTE so that bookmarks nested in subfolders of
616+ // shared folders are included. Because the CTE yields one row per (bookmark, reachable
617+ // parent_folder), we first deduplicate to one row per bookmark and then sum, so a bookmark
618+ // reachable through several folders doesn't have its clicks counted more than once.
619+ // Hand-rolled queries against the raw bookmarks_tree table missed the nested case entirely.
620+ $ rootFolder = $ this ->folderMapper ->findRootFolder ($ userId );
621+ [$ cte , $ params , $ paramTypes ] = $ this ->generateCTE ($ rootFolder ->getId (), false );
625622
626623 $ qb = $ this ->db ->getQueryBuilder ();
627- $ qb ->selectAlias ($ qb ->func ()->sum ('b.clickcount ' ), 'count ' );
628- $ qb
629- ->from ('bookmarks ' , 'b ' )
630- ->innerJoin ('b ' , 'bookmarks_tree ' , 'tr ' , 'b.id = tr.id AND tr.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tr.soft_deleted_at is NULL ' )
631- ->innerJoin ('tr ' , 'bookmarks_shared_folders ' , 'sf ' , $ qb ->expr ()->eq ('tr.parent_folder ' , 'sf.folder_id ' ))
632- ->where ($ qb ->expr ()->eq ('sf.user_id ' , $ qb ->createPositionalParameter ($ userId )))
633- ->andWhere ($ qb ->expr ()->neq ('b.clickcount ' , $ qb ->createPositionalParameter (0 , IQueryBuilder::PARAM_INT )));
634- $ result = $ qb ->executeQuery ();
635- $ foreignClickCount = $ result ->fetch (PDO ::FETCH_COLUMN );
624+ $ qb ->automaticTablePrefix (false );
625+ $ qb ->selectDistinct (['b.id ' , 'b.clickcount ' ])
626+ ->from ('*PREFIX*bookmarks ' , 'b ' )
627+ ->innerJoin ('b ' , 'folder_tree ' , 'tree ' , 'tree.item_id = b.id AND tree.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tree.soft_deleted_at is NULL ' )
628+ ->where ($ qb ->expr ()->neq ('b.clickcount ' , $ qb ->createPositionalParameter (0 , IQueryBuilder::PARAM_INT )));
629+
630+ $ finalQuery = $ cte . ' SELECT COALESCE(SUM(sub.clickcount), 0) FROM ( ' . $ qb ->getSQL () . ') sub ' ;
631+ $ params = array_merge ($ params , $ qb ->getParameters ());
632+ $ paramTypes = array_merge ($ paramTypes , $ qb ->getParameterTypes ());
633+
634+ $ result = $ this ->db ->executeQuery ($ finalQuery , $ params , $ paramTypes );
635+ $ count = (int )$ result ->fetchOne ();
636636 $ result ->closeCursor ();
637637
638- return $ userOwnerClickCount + $ foreignClickCount ;
638+ return $ count ;
639639 }
640640
641641 /**
@@ -644,30 +644,29 @@ public function countAllClicks(string $userId): int {
644644 * @throws Exception
645645 */
646646 public function countWithClicks (string $ userId ): int {
647- $ qb = $ this ->db ->getQueryBuilder ();
648- $ qb ->selectAlias ($ qb ->func ()->count ('b.id ' ), 'count ' );
649- $ qb
650- ->from ('bookmarks ' , 'b ' )
651- ->innerJoin ('b ' , 'bookmarks_tree ' , 'tr ' , 'b.id = tr.id AND tr.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tr.soft_deleted_at is NULL ' )
652- ->where ($ qb ->expr ()->eq ('b.user_id ' , $ qb ->createPositionalParameter ($ userId )))
653- ->andWhere ($ qb ->expr ()->neq ('b.clickcount ' , $ qb ->createPositionalParameter (0 , IQueryBuilder::PARAM_INT )));
654- $ result = $ qb ->executeQuery ();
655- $ userOwnerWithClicksCount = $ result ->fetch (PDO ::FETCH_COLUMN );
656- $ result ->closeCursor ();
647+ // Count clicked bookmarks against the recursive folder_tree CTE so that bookmarks nested in
648+ // subfolders of shared folders are included, and count each bookmark once (COUNT DISTINCT).
649+ // Hand-rolled queries against the raw bookmarks_tree table miss everything that only becomes
650+ // visible through the recursive expansion, which made this method under-count.
651+ $ rootFolder = $ this ->folderMapper ->findRootFolder ($ userId );
652+ [$ cte , $ params , $ paramTypes ] = $ this ->generateCTE ($ rootFolder ->getId (), false );
657653
658654 $ qb = $ this ->db ->getQueryBuilder ();
659- $ qb ->selectAlias ($ qb ->func ()->count ('b.id ' ), 'count ' );
660- $ qb
661- ->from ('bookmarks ' , 'b ' )
662- ->innerJoin ('b ' , 'bookmarks_tree ' , 'tr ' , 'b.id = tr.id AND tr.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tr.soft_deleted_at is NULL ' )
663- ->innerJoin ('tr ' , 'bookmarks_shared_folders ' , 'sf ' , $ qb ->expr ()->eq ('tr.parent_folder ' , 'sf.folder_id ' ))
664- ->where ($ qb ->expr ()->eq ('sf.user_id ' , $ qb ->createPositionalParameter ($ userId )))
665- ->andWhere ($ qb ->expr ()->neq ('b.clickcount ' , $ qb ->createPositionalParameter (0 , IQueryBuilder::PARAM_INT )));
666- $ result = $ qb ->executeQuery ();
667- $ foreignWithClicksCount = $ result ->fetch (PDO ::FETCH_COLUMN );
655+ $ qb ->automaticTablePrefix (false );
656+ $ qb ->select ($ qb ->createFunction ('COUNT(DISTINCT b.id) ' ))
657+ ->from ('*PREFIX*bookmarks ' , 'b ' )
658+ ->innerJoin ('b ' , 'folder_tree ' , 'tree ' , 'tree.item_id = b.id AND tree.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tree.soft_deleted_at is NULL ' )
659+ ->where ($ qb ->expr ()->neq ('b.clickcount ' , $ qb ->createPositionalParameter (0 , IQueryBuilder::PARAM_INT )));
660+
661+ $ finalQuery = $ cte . ' ' . $ qb ->getSQL ();
662+ $ params = array_merge ($ params , $ qb ->getParameters ());
663+ $ paramTypes = array_merge ($ paramTypes , $ qb ->getParameterTypes ());
664+
665+ $ result = $ this ->db ->executeQuery ($ finalQuery , $ params , $ paramTypes );
666+ $ count = (int )$ result ->fetchOne ();
668667 $ result ->closeCursor ();
669668
670- return $ userOwnerWithClicksCount + $ foreignWithClicksCount ;
669+ return $ count ;
671670 }
672671
673672 /**
@@ -676,30 +675,33 @@ public function countWithClicks(string $userId): int {
676675 * @throws Exception
677676 */
678677 public function countUnavailable (string $ userId ): int {
679- $ qb = $ this ->db ->getQueryBuilder ();
680- $ qb ->selectAlias ($ qb ->func ()->count ('b.id ' ), 'count ' );
681- $ qb
682- ->from ('bookmarks ' , 'b ' )
683- ->innerJoin ('b ' , 'bookmarks_tree ' , 'tr ' , 'b.id = tr.id AND tr.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tr.soft_deleted_at is NULL ' )
684- ->where ($ qb ->expr ()->eq ('b.user_id ' , $ qb ->createPositionalParameter ($ userId )))
685- ->andWhere ($ qb ->expr ()->eq ('b.available ' , $ qb ->createPositionalParameter (false , IQueryBuilder::PARAM_BOOL )));
686- $ result = $ qb ->executeQuery ();
687- $ userOwnerUnavailableCount = $ result ->fetch (PDO ::FETCH_COLUMN );
688- $ result ->closeCursor ();
678+ // Count unavailable bookmarks the exact same way the "Unavailable" list is computed in
679+ // findAll(): against the recursive folder_tree CTE (which covers nested folders and
680+ // bookmarks inside shared folders/subfolders) and using the same _filterUnavailable()
681+ // predicate. Hand-rolled queries against the raw bookmarks_tree table miss everything that
682+ // only becomes visible through the recursive expansion, which made this method under-count.
683+ $ rootFolder = $ this ->folderMapper ->findRootFolder ($ userId );
684+ [$ cte , $ params , $ paramTypes ] = $ this ->generateCTE ($ rootFolder ->getId (), false );
689685
690686 $ qb = $ this ->db ->getQueryBuilder ();
691- $ qb ->selectAlias ($ qb ->func ()->count ('b.id ' ), 'count ' );
692- $ qb
693- ->from ('bookmarks ' , 'b ' )
694- ->innerJoin ('b ' , 'bookmarks_tree ' , 'tr ' , 'b.id = tr.id AND tr.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tr.soft_deleted_at is NULL ' )
695- ->innerJoin ('tr ' , 'bookmarks_shared_folders ' , 'sf ' , $ qb ->expr ()->eq ('tr.parent_folder ' , 'sf.folder_id ' ))
696- ->where ($ qb ->expr ()->eq ('sf.user_id ' , $ qb ->createPositionalParameter ($ userId )))
697- ->andWhere ($ qb ->expr ()->eq ('b.available ' , $ qb ->createPositionalParameter (false , IQueryBuilder::PARAM_BOOL )));
698- $ result = $ qb ->executeQuery ();
699- $ foreignUnavailableCount = $ result ->fetch (PDO ::FETCH_COLUMN );
687+ $ qb ->automaticTablePrefix (false );
688+ $ qb ->select ($ qb ->createFunction ('COUNT(DISTINCT b.id) ' ))
689+ ->from ('*PREFIX*bookmarks ' , 'b ' )
690+ ->innerJoin ('b ' , 'folder_tree ' , 'tree ' , 'tree.item_id = b.id AND tree.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tree.soft_deleted_at is NULL ' );
691+
692+ $ queryParams = new QueryParameters ();
693+ $ queryParams ->setUnavailable (true );
694+ $ this ->_filterUnavailable ($ qb , $ queryParams );
695+
696+ $ finalQuery = $ cte . ' ' . $ qb ->getSQL ();
697+ $ params = array_merge ($ params , $ qb ->getParameters ());
698+ $ paramTypes = array_merge ($ paramTypes , $ qb ->getParameterTypes ());
699+
700+ $ result = $ this ->db ->executeQuery ($ finalQuery , $ params , $ paramTypes );
701+ $ count = (int )$ result ->fetchOne ();
700702 $ result ->closeCursor ();
701703
702- return $ userOwnerUnavailableCount + $ foreignUnavailableCount ;
704+ return $ count ;
703705 }
704706
705707 /**
@@ -708,44 +710,33 @@ public function countUnavailable(string $userId): int {
708710 * @throws Exception
709711 */
710712 public function countDuplicated (string $ userId ): int {
711- $ qb = $ this ->db ->getQueryBuilder ();
712- $ qb ->selectDistinct ($ qb ->func ()->count ('b.id ' ));
713- $ qb
714- ->from ('bookmarks ' , 'b ' )
715- ->innerJoin ('b ' , 'bookmarks_tree ' , 'tr ' , 'b.id = tr.id AND tr.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tr.soft_deleted_at is NULL ' )
716- ->where ($ qb ->expr ()->eq ('b.user_id ' , $ qb ->createPositionalParameter ($ userId )));
717- $ subQuery = $ this ->db ->getQueryBuilder ();
718- $ subQuery ->select ('trdup.parent_folder ' )
719- ->from ('bookmarks_tree ' , 'trdup ' )
720- ->where ($ subQuery ->expr ()->eq ('b.id ' , 'trdup.id ' ))
721- ->andWhere ($ subQuery ->expr ()->neq ('trdup.parent_folder ' , 'tr.parent_folder ' ))
722- ->andWhere ($ subQuery ->expr ()->eq ('trdup.type ' , $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK )))
723- ->andWhere ($ subQuery ->expr ()->isNull ('trdup.soft_deleted_at ' ));
724- $ qb ->andWhere ($ qb ->createFunction ('EXISTS( ' . $ subQuery ->getSQL () . ') ' ));
725- $ result = $ qb ->executeQuery ();
726- $ userOwnerDuplicatesCount = $ result ->fetch (PDO ::FETCH_COLUMN );
727- $ result ->closeCursor ();
713+ // Count duplicates the exact same way the "Duplicated" list is computed in findAll():
714+ // against the recursive folder_tree CTE (which covers nested folders and bookmarks inside
715+ // shared folders/subfolders) and using the same _filterDuplicated() predicate. Hand-rolled
716+ // queries against the raw bookmarks_tree table miss everything that only becomes visible
717+ // through the recursive expansion, which made this method under-count.
718+ $ rootFolder = $ this ->folderMapper ->findRootFolder ($ userId );
719+ [$ cte , $ params , $ paramTypes ] = $ this ->generateCTE ($ rootFolder ->getId (), false );
728720
729721 $ qb = $ this ->db ->getQueryBuilder ();
730- $ qb ->selectDistinct ($ qb ->func ()->count ('b.id ' ));
731- $ qb
732- ->from ('bookmarks ' , 'b ' )
733- ->innerJoin ('b ' , 'bookmarks_tree ' , 'tr ' , 'b.id = tr.id AND tr.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tr.soft_deleted_at is NULL ' )
734- ->innerJoin ('tr ' , 'bookmarks_shared_folders ' , 'sf ' , $ qb ->expr ()->eq ('tr.parent_folder ' , 'sf.folder_id ' ))
735- ->where ($ qb ->expr ()->eq ('sf.user_id ' , $ qb ->createPositionalParameter ($ userId )));
736- $ subQuery = $ this ->db ->getQueryBuilder ();
737- $ subQuery ->select ('trdup.parent_folder ' )
738- ->from ('bookmarks_tree ' , 'trdup ' )
739- ->where ($ subQuery ->expr ()->eq ('b.id ' , 'trdup.id ' ))
740- ->andWhere ($ subQuery ->expr ()->neq ('trdup.parent_folder ' , 'tr.parent_folder ' ))
741- ->andWhere ($ subQuery ->expr ()->eq ('trdup.type ' , $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK )))
742- ->andWhere ($ subQuery ->expr ()->isNull ('trdup.soft_deleted_at ' ));
743- $ qb ->andWhere ($ qb ->createFunction ('EXISTS( ' . $ subQuery ->getSQL () . ') ' ));
744- $ result = $ qb ->executeQuery ();
745- $ foreignDuplicatesCount = $ result ->fetch (PDO ::FETCH_COLUMN );
722+ $ qb ->automaticTablePrefix (false );
723+ $ qb ->select ($ qb ->createFunction ('COUNT(DISTINCT b.id) ' ))
724+ ->from ('*PREFIX*bookmarks ' , 'b ' )
725+ ->innerJoin ('b ' , 'folder_tree ' , 'tree ' , 'tree.item_id = b.id AND tree.type = ' . $ qb ->createPositionalParameter (TreeMapper::TYPE_BOOKMARK ) . ' AND tree.soft_deleted_at is NULL ' );
726+
727+ $ queryParams = new QueryParameters ();
728+ $ queryParams ->setDuplicated (true );
729+ $ this ->_filterDuplicated ($ qb , $ queryParams );
730+
731+ $ finalQuery = $ cte . ' ' . $ qb ->getSQL ();
732+ $ params = array_merge ($ params , $ qb ->getParameters ());
733+ $ paramTypes = array_merge ($ paramTypes , $ qb ->getParameterTypes ());
734+
735+ $ result = $ this ->db ->executeQuery ($ finalQuery , $ params , $ paramTypes );
736+ $ count = (int )$ result ->fetchOne ();
746737 $ result ->closeCursor ();
747738
748- return $ userOwnerDuplicatesCount + $ foreignDuplicatesCount ;
739+ return $ count ;
749740 }
750741
751742 /**
0 commit comments