2020 */
2121class UserStatusMapper extends QBMapper {
2222
23+ /**
24+ * Oracle rejects an IN list with more than 1000 expressions, so anything
25+ * built from an unbounded set of ids has to be split into chunks.
26+ */
27+ private const MAX_IN_CHUNK = 1000 ;
28+
2329 /**
2430 * @param IDBConnection $db
2531 */
@@ -176,58 +182,132 @@ public function deleteCurrentStatusToRestoreBackup(string $userId, string $messa
176182 * @return int Number of deleted backup rows
177183 */
178184 public function deleteStrandedBackups (array $ automatedMessageIds ): int {
185+ return $ this ->deleteByIds ($ this ->findStrandedBackupIds ($ automatedMessageIds ));
186+ }
187+
188+ /**
189+ * Ids of backup rows that can never be restored. See deleteStrandedBackups().
190+ *
191+ * A backup is reachable exactly when the live row it belongs to still carries
192+ * one of the automated message ids, because that is what revertUserStatus()
193+ * matches on. The live row is the one whose user id is the backup's user id
194+ * without the underscore prefix, so the two are matched with a self join.
195+ *
196+ * @param list<string> $automatedMessageIds
197+ * @return list<int>
198+ */
199+ public function findStrandedBackupIds (array $ automatedMessageIds ): array {
179200 $ qb = $ this ->db ->getQueryBuilder ();
180- $ qb ->select ('id ' , 'user_id ' )
181- ->from ($ this ->tableName )
182- ->where ($ qb ->expr ()->eq ('is_backup ' , $ qb ->createNamedParameter (true , IQueryBuilder::PARAM_BOOL )));
201+ $ qb ->select ('b.id ' )
202+ ->from ($ this ->tableName , 'b ' )
203+ ->where ($ qb ->expr ()->eq ('b.is_backup ' , $ qb ->createNamedParameter (true , IQueryBuilder::PARAM_BOOL )));
204+
205+ if ($ automatedMessageIds === []) {
206+ // No automated status can own a backup, so none of them is reachable.
207+ return $ this ->fetchIds ($ qb );
208+ }
209+
210+ // Not filtering the live side on is_backup is deliberate: a row whose
211+ // is_backup is NULL is still treated as a live row, so unexpected data
212+ // errs towards keeping the backup.
213+ $ qb ->leftJoin ('b ' , $ this ->tableName , 'l ' , $ qb ->expr ()->andX (
214+ $ qb ->expr ()->eq ('l.user_id ' , $ qb ->func ()->substring ('b.user_id ' , $ qb ->createNamedParameter (2 , IQueryBuilder::PARAM_INT ))),
215+ $ qb ->expr ()->in ('l.message_id ' , $ qb ->createNamedParameter ($ automatedMessageIds , IQueryBuilder::PARAM_STR_ARRAY )),
216+ ))
217+ ->andWhere ($ qb ->expr ()->isNull ('l.id ' ));
218+
219+ return $ this ->fetchIds ($ qb );
220+ }
221+
222+ /**
223+ * Ids of live rows that sit on an automated status with no backup row to
224+ * revert into. Those can never be reverted by the automation that set them,
225+ * so the user is stuck on that status until it is cleared.
226+ *
227+ * @param list<string> $automatedMessageIds
228+ * @return list<int>
229+ */
230+ public function findOrphanedAutomatedStatusIds (array $ automatedMessageIds ): array {
231+ if ($ automatedMessageIds === []) {
232+ return [];
233+ }
234+
235+ $ qb = $ this ->db ->getQueryBuilder ();
236+ // The backup of a live row carries the same user id with an underscore
237+ // prefix, so the two are matched with a self join on the concatenation.
238+ $ qb ->select ('l.id ' )
239+ ->from ($ this ->tableName , 'l ' )
240+ ->leftJoin ('l ' , $ this ->tableName , 'b ' , $ qb ->expr ()->eq (
241+ 'b.user_id ' ,
242+ $ qb ->func ()->concat ($ qb ->createNamedParameter ('_ ' ), 'l.user_id ' ),
243+ ))
244+ ->where ($ qb ->expr ()->in ('l.message_id ' , $ qb ->createNamedParameter ($ automatedMessageIds , IQueryBuilder::PARAM_STR_ARRAY )))
245+ ->andWhere ($ qb ->expr ()->isNull ('b.id ' ))
246+ // Skip backup rows on the live side. Testing the prefix rather than
247+ // is_backup keeps this correct for rows where is_backup is NULL, and
248+ // a substring comparison avoids having to escape the underscore for
249+ // a LIKE pattern.
250+ ->andWhere ($ qb ->expr ()->neq (
251+ $ qb ->func ()->substring ('l.user_id ' , $ qb ->createNamedParameter (1 , IQueryBuilder::PARAM_INT ), $ qb ->createNamedParameter (1 , IQueryBuilder::PARAM_INT )),
252+ $ qb ->createNamedParameter ('_ ' ),
253+ ));
254+
255+ return $ this ->fetchIds ($ qb );
256+ }
183257
258+ /**
259+ * @return list<int>
260+ */
261+ private function fetchIds (IQueryBuilder $ qb ): array {
184262 $ result = $ qb ->executeQuery ();
185- /** @var array<string, int> $backups live user id => backup row id */
186- $ backups = [];
263+ $ ids = [];
187264 while ($ row = $ result ->fetch ()) {
188- // Strip the underscore prefix that was added when creating the backup
189- $ backups [substr ((string )$ row ['user_id ' ], 1 )] = (int )$ row ['id ' ];
265+ $ ids [] = (int )$ row ['id ' ];
190266 }
191267 $ result ->closeCursor ();
192268
193- if ($ backups === []) {
194- return 0 ;
195- }
269+ return $ ids ;
270+ }
196271
197- $ reachable = [];
198- if ($ automatedMessageIds !== []) {
199- foreach (array_chunk (array_keys ($ backups ), 1000 ) as $ chunk ) {
200- $ qb = $ this ->db ->getQueryBuilder ();
201- // Matching on the exact user id is enough to exclude backup rows,
202- // since those are always prefixed and user ids cannot start with
203- // an underscore. Not filtering on is_backup also means a row with
204- // a NULL is_backup errs towards keeping the backup.
205- $ qb ->select ('user_id ' )
206- ->from ($ this ->tableName )
207- ->where ($ qb ->expr ()->in ('user_id ' , $ qb ->createNamedParameter ($ chunk , IQueryBuilder::PARAM_STR_ARRAY )))
208- ->andWhere ($ qb ->expr ()->in ('message_id ' , $ qb ->createNamedParameter ($ automatedMessageIds , IQueryBuilder::PARAM_STR_ARRAY )));
209-
210- $ liveResult = $ qb ->executeQuery ();
211- while ($ row = $ liveResult ->fetch ()) {
212- $ reachable [(string )$ row ['user_id ' ]] = true ;
213- }
214- $ liveResult ->closeCursor ();
215- }
216- }
272+ /**
273+ * Ids of rows where is_backup is NULL. Those predate the column default and
274+ * are invisible to every query that compares is_backup against false.
275+ *
276+ * @return list<int>
277+ */
278+ public function findStatusesWithoutBackupFlagIds (): array {
279+ $ qb = $ this ->db ->getQueryBuilder ();
280+ $ qb ->select ('id ' )
281+ ->from ($ this ->tableName )
282+ ->where ($ qb ->expr ()->isNull ('is_backup ' ));
217283
218- $ stranded = [];
219- foreach ($ backups as $ userId => $ id ) {
220- if (!isset ($ reachable [$ userId ])) {
221- $ stranded [] = $ id ;
222- }
223- }
284+ return $ this ->fetchIds ($ qb );
285+ }
224286
225- if ($ stranded === []) {
226- return 0 ;
287+ /**
288+ * @param list<int> $ids
289+ * @return int Number of rows that were given an explicit is_backup value
290+ */
291+ public function normalizeBackupFlagByIds (array $ ids ): int {
292+ $ updated = 0 ;
293+ foreach (array_chunk ($ ids , self ::MAX_IN_CHUNK ) as $ chunk ) {
294+ $ qb = $ this ->db ->getQueryBuilder ();
295+ $ qb ->update ($ this ->tableName )
296+ ->set ('is_backup ' , $ qb ->createNamedParameter (false , IQueryBuilder::PARAM_BOOL ))
297+ ->where ($ qb ->expr ()->in ('id ' , $ qb ->createNamedParameter ($ chunk , IQueryBuilder::PARAM_INT_ARRAY )));
298+ $ updated += $ qb ->executeStatement ();
227299 }
228300
301+ return $ updated ;
302+ }
303+
304+ /**
305+ * @param list<int> $ids
306+ * @return int Number of deleted rows
307+ */
308+ public function deleteByIds (array $ ids ): int {
229309 $ deleted = 0 ;
230- foreach (array_chunk ($ stranded , 1000 ) as $ chunk ) {
310+ foreach (array_chunk ($ ids , self :: MAX_IN_CHUNK ) as $ chunk ) {
231311 $ qb = $ this ->db ->getQueryBuilder ();
232312 $ qb ->delete ($ this ->tableName )
233313 ->where ($ qb ->expr ()->in ('id ' , $ qb ->createNamedParameter ($ chunk , IQueryBuilder::PARAM_INT_ARRAY )));
@@ -237,13 +317,6 @@ public function deleteStrandedBackups(array $automatedMessageIds): int {
237317 return $ deleted ;
238318 }
239319
240- public function deleteByIds (array $ ids ): void {
241- $ qb = $ this ->db ->getQueryBuilder ();
242- $ qb ->delete ($ this ->tableName )
243- ->where ($ qb ->expr ()->in ('id ' , $ qb ->createNamedParameter ($ ids , IQueryBuilder::PARAM_INT_ARRAY )));
244- $ qb ->executeStatement ();
245- }
246-
247320 /**
248321 * @param string $userId
249322 * @return bool
0 commit comments