~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/scripts/utilities/sanitizedb.py

[r=benji][bug=795573,
 796233] On DistroSeries:+localpackagediffs ensure that the comment
 form is hidden after adding a new comment to a DistroSeriesDifference,
 prevent empty comments from being submitted,
 and add some animations and effects to make the UI less jarring and easier to
 follow.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright 2009 Canonical Ltd.  This software is licensed under the
2
 
# GNU Affero General Public License version 3 (see the file LICENSE).
3
 
 
4
 
"""Scrub a Launchpad database of private data."""
5
 
 
6
 
import _pythonpath
7
 
 
8
 
 
9
 
__metaclass__ = type
10
 
__all__ = []
11
 
 
12
 
import re
13
 
import subprocess
14
 
import sys
15
 
 
16
 
from storm.expr import (
17
 
    Join,
18
 
    Or,
19
 
    )
20
 
import transaction
21
 
from zope.component import getUtility
22
 
 
23
 
from canonical.database.constants import UTC_NOW
24
 
from canonical.database.sqlbase import cursor, sqlvalues
25
 
from canonical.database.postgresql import ConnectionString, listReferences
26
 
from canonical.launchpad.scripts.logger import DEBUG2, DEBUG3
27
 
from canonical.launchpad.webapp.interfaces import (
28
 
    IStoreSelector, MAIN_STORE, MASTER_FLAVOR)
29
 
from canonical.lp import initZopeless
30
 
from lp.services.scripts.base import LaunchpadScript
31
 
 
32
 
 
33
 
class SanitizeDb(LaunchpadScript):
34
 
    usage = "%prog [options] pg_connection_string"
35
 
    description = "Destroy private information in a Launchpad database."
36
 
 
37
 
    def add_my_options(self):
38
 
        self.parser.add_option(
39
 
            "-f", "--force", action="store_true", default=False,
40
 
            help="Force running against a possible production database.")
41
 
        self.parser.add_option(
42
 
            "-n", "--dry-run", action="store_true", default=False,
43
 
            help="Don't commit changes.")
44
 
 
45
 
    def _init_db(self, isolation):
46
 
        if len(self.args) == 0:
47
 
            self.parser.error("PostgreSQL connection string required.")
48
 
        elif len(self.args) > 1:
49
 
            self.parser.error("Too many arguments.")
50
 
 
51
 
        self.pg_connection_string = ConnectionString(self.args[0])
52
 
 
53
 
        if ('prod' in str(self.pg_connection_string)
54
 
            and not self.options.force):
55
 
            self.parser.error(
56
 
            "Attempting to sanitize a potential production database '%s'. "
57
 
            "--force required." % self.pg_connection_string.dbname)
58
 
 
59
 
        self.logger.debug("Connect using '%s'." % self.pg_connection_string)
60
 
 
61
 
        self.txn = initZopeless(
62
 
            dbname=self.pg_connection_string.dbname,
63
 
            dbhost=self.pg_connection_string.host,
64
 
            dbuser=self.pg_connection_string.user,
65
 
            isolation=isolation)
66
 
 
67
 
        self.store = getUtility(IStoreSelector).get(MAIN_STORE, MASTER_FLAVOR)
68
 
 
69
 
    def main(self):
70
 
        self.allForeignKeysCascade()
71
 
        triggers_to_disable = [
72
 
            ('bugmessage', 'set_bug_message_count_t'),
73
 
            ('bugmessage', 'set_date_last_message_t'),
74
 
            ]
75
 
        self.disableTriggers(triggers_to_disable)
76
 
 
77
 
        tables_to_empty = [
78
 
            'accountpassword',
79
 
            'archiveauthtoken',
80
 
            'archivesubscriber',
81
 
            'authtoken',
82
 
            'buildqueue',
83
 
            'commercialsubscription',
84
 
            'entitlement',
85
 
            'job',
86
 
            'logintoken',
87
 
            'mailinglistban',
88
 
            'mailinglistsubscription',
89
 
            'oauthaccesstoken',
90
 
            'oauthconsumer',
91
 
            'oauthnonce',
92
 
            'oauthrequesttoken',
93
 
            'openidassociation',
94
 
            'openidconsumerassociation',
95
 
            'openidconsumernonce',
96
 
            'openidrpsummary',
97
 
            'openididentifier',
98
 
            'requestedcds',
99
 
            'scriptactivity',
100
 
            'shipitreport',
101
 
            'shipitsurvey',
102
 
            'shipitsurveyanswer',
103
 
            'shipitsurveyquestion',
104
 
            'shipitsurveyresult',
105
 
            'shipment',
106
 
            'shippingrequest',
107
 
            'shippingrun',
108
 
            'sprintattendance', # Is this private?
109
 
            'standardshipitrequest',
110
 
            'temporaryblobstorage',
111
 
            'usertouseremail',
112
 
            'vote',
113
 
            'votecast',
114
 
            'webserviceban',
115
 
            ]
116
 
        for table in tables_to_empty:
117
 
            self.removeTableRows(table)
118
 
 
119
 
        self.removePrivatePeople()
120
 
        self.removePrivateTeams()
121
 
        self.removePrivateBugs()
122
 
        self.removePrivateBugMessages()
123
 
        self.removePrivateBranches()
124
 
        self.removePrivateHwSubmissions()
125
 
        self.removePrivateSpecifications()
126
 
        self.removePrivateLocations()
127
 
        self.removePrivateArchives()
128
 
        self.removePrivateAnnouncements()
129
 
        self.removePrivateLibrarianFiles()
130
 
        self.removeInactiveProjects()
131
 
        self.removeInactiveProducts()
132
 
        self.removeInvalidEmailAddresses()
133
 
        self.removePPAArchivePermissions()
134
 
        self.scrambleHiddenEmailAddresses()
135
 
 
136
 
        self.removeDeactivatedPeopleAndAccounts()
137
 
 
138
 
        # Remove unlinked records. These might contain private data.
139
 
        self.removeUnlinkedEmailAddresses()
140
 
        self.removeUnlinkedAccounts()
141
 
        self.removeUnlinked('revision', [
142
 
            ('revisioncache', 'revision'),
143
 
            ('revisionparent', 'revision'),
144
 
            ('revisionproperty', 'revision'),
145
 
            ])
146
 
        self.removeUnlinked('libraryfilealias', [
147
 
            ('libraryfiledownloadcount', 'libraryfilealias')])
148
 
        self.removeUnlinked('libraryfilecontent')
149
 
        self.removeUnlinked('message', [('messagechunk', 'message')])
150
 
        self.removeUnlinked('staticdiff')
151
 
        self.removeUnlinked('previewdiff')
152
 
        self.removeUnlinked('diff')
153
 
 
154
 
        # Scrub data after removing all the records we are going to.
155
 
        # No point scrubbing data that is going to get removed later.
156
 
        columns_to_scrub = [
157
 
            ('account', ['status_comment']),
158
 
            ('distribution', ['reviewer_whiteboard']),
159
 
            ('distributionmirror', ['whiteboard']),
160
 
            ('hwsubmission', ['raw_emailaddress']),
161
 
            ('nameblacklist', ['comment']),
162
 
            ('person', [
163
 
                'personal_standing_reason',
164
 
                'mail_resumption_date']),
165
 
            ('product', ['reviewer_whiteboard']),
166
 
            ('project', ['reviewer_whiteboard']),
167
 
            ('revisionauthor', ['email']),
168
 
            ('signedcodeofconduct', ['admincomment']),
169
 
            ]
170
 
        for table, column in columns_to_scrub:
171
 
            self.scrubColumn(table, column)
172
 
 
173
 
        self.enableTriggers(triggers_to_disable)
174
 
        self.repairData()
175
 
 
176
 
        self.resetForeignKeysCascade()
177
 
        if self.options.dry_run:
178
 
            self.logger.info("Dry run - rolling back.")
179
 
            transaction.abort()
180
 
        else:
181
 
            self.logger.info("Committing.")
182
 
            transaction.commit()
183
 
 
184
 
    def removeDeactivatedPeopleAndAccounts(self):
185
 
        """Remove all suspended and deactivated people & their accounts.
186
 
 
187
 
        Launchpad celebrities are ignored.
188
 
        """
189
 
        from canonical.launchpad.database.account import Account
190
 
        from canonical.launchpad.database.emailaddress import EmailAddress
191
 
        from canonical.launchpad.interfaces.account import AccountStatus
192
 
        from lp.app.interfaces.launchpad import ILaunchpadCelebrities
193
 
        from lp.registry.model.person import Person
194
 
        celebrities = getUtility(ILaunchpadCelebrities)
195
 
        # This is a slow operation due to the huge amount of cascading.
196
 
        # We remove one row at a time for better reporting and PostgreSQL
197
 
        # memory use.
198
 
        deactivated_people = self.store.find(
199
 
            Person,
200
 
            Person.account == Account.id,
201
 
            Account.status != AccountStatus.ACTIVE)
202
 
        total_deactivated_count = deactivated_people.count()
203
 
        deactivated_count = 0
204
 
        for person in deactivated_people:
205
 
            # Ignore celebrities
206
 
            if celebrities.isCelebrityPerson(person.name):
207
 
                continue
208
 
            deactivated_count += 1
209
 
            self.logger.debug(
210
 
                "Removing %d of %d deactivated people (%s)",
211
 
                deactivated_count, total_deactivated_count, person.name)
212
 
            # Clean out the EmailAddress and Account for this person
213
 
            # while we are here, making subsequent unbatched steps
214
 
            # faster. These don't cascade due to the lack of a foreign
215
 
            # key constraint between Person and EmailAddress, and the
216
 
            # ON DELETE SET NULL foreign key constraint between
217
 
            # EmailAddress and Account.
218
 
            self.store.find(
219
 
                EmailAddress, EmailAddress.person == person).remove()
220
 
            self.store.find(Account, Account.id == person.accountID).remove()
221
 
            self.store.remove(person)
222
 
            self.store.flush()
223
 
        self.logger.info(
224
 
            "Removed %d suspended or deactivated people + email + accounts",
225
 
            deactivated_count)
226
 
 
227
 
    def removePrivatePeople(self):
228
 
        """Remove all private people."""
229
 
        from lp.registry.interfaces.person import PersonVisibility
230
 
        from lp.registry.model.person import Person
231
 
        count = self.store.find(
232
 
            Person,
233
 
            Person.teamowner == None,
234
 
            Person.visibility != PersonVisibility.PUBLIC).remove()
235
 
        self.store.flush()
236
 
        self.logger.info("Removed %d private people.", count)
237
 
 
238
 
    def removePrivateTeams(self):
239
 
        """Remove all private people."""
240
 
        from lp.registry.interfaces.person import PersonVisibility
241
 
        from lp.registry.model.person import Person
242
 
        count = self.store.find(
243
 
            Person,
244
 
            Person.teamowner != None,
245
 
            Person.visibility != PersonVisibility.PUBLIC).remove()
246
 
        self.store.flush()
247
 
        self.logger.info("Removed %d private teams.", count)
248
 
 
249
 
    def removePrivateBugs(self):
250
 
        """Remove all private bugs."""
251
 
        from lp.bugs.model.bug import Bug
252
 
        count = self.store.find(Bug, Bug.private == True).remove()
253
 
        self.store.flush()
254
 
        self.logger.info("Removed %d private bugs.", count)
255
 
 
256
 
    def removePrivateBugMessages(self):
257
 
        """Remove all hidden bug messages."""
258
 
        from lp.bugs.model.bugmessage import BugMessage
259
 
        from lp.services.messages.model.message import Message
260
 
        message_ids = list(self.store.using(*[
261
 
            BugMessage,
262
 
            Join(Message, BugMessage.messageID == Message.id),
263
 
            ]).find(BugMessage.id, Message.visible == False))
264
 
        self.store.flush()
265
 
        count = self.store.find(
266
 
            BugMessage, BugMessage.id.is_in(message_ids)).remove()
267
 
        self.store.flush()
268
 
        self.logger.info("Removed %d private bug messages.", count)
269
 
 
270
 
    def removePrivateBranches(self):
271
 
        """Remove all private branches."""
272
 
        from lp.code.model.branch import Branch
273
 
        count = self.store.find(Branch, Branch.private == True).remove()
274
 
        self.store.flush()
275
 
        self.logger.info("Removed %d private branches.", count)
276
 
 
277
 
    def removePrivateHwSubmissions(self):
278
 
        """Remove all private hardware submissions."""
279
 
        from lp.hardwaredb.model.hwdb import HWSubmission
280
 
        count = self.store.find(
281
 
            HWSubmission, HWSubmission.private == True).remove()
282
 
        self.store.flush()
283
 
        self.logger.info("Removed %d private hardware submissions.", count)
284
 
 
285
 
    def removePrivateSpecifications(self):
286
 
        """Remove all private specifications."""
287
 
        from lp.blueprints.model.specification import Specification
288
 
        count = self.store.find(
289
 
            Specification, Specification.private == True).remove()
290
 
        self.store.flush()
291
 
        self.logger.info("Removed %d private specifications.", count)
292
 
 
293
 
    def removePrivateLocations(self):
294
 
        """Remove private person locations."""
295
 
        from lp.registry.model.personlocation import PersonLocation
296
 
        count = self.store.find(
297
 
            PersonLocation, PersonLocation.visible == False).remove()
298
 
        self.store.flush()
299
 
        self.logger.info("Removed %d person locations.", count)
300
 
 
301
 
    def removePrivateArchives(self):
302
 
        """Remove private archives.
303
 
 
304
 
        This might over delete, but lets be conservative for now.
305
 
        """
306
 
        from lp.soyuz.model.archive import Archive
307
 
        count = self.store.find(Archive, Archive.private == True).remove()
308
 
        self.store.flush()
309
 
        self.logger.info(
310
 
            "Removed %d private archives.", count)
311
 
 
312
 
    def removePrivateAnnouncements(self):
313
 
        """Remove announcements that have not yet been published."""
314
 
        from lp.registry.model.announcement import Announcement
315
 
        count = self.store.find(
316
 
            Announcement, Or(
317
 
                Announcement.date_announced == None,
318
 
                Announcement.date_announced > UTC_NOW,
319
 
                Announcement.active == False)).remove()
320
 
        self.store.flush()
321
 
        self.logger.info(
322
 
            "Removed %d unpublished announcements.", count)
323
 
 
324
 
    def removePrivateLibrarianFiles(self):
325
 
        """Remove librarian files only available via the restricted librarian.
326
 
        """
327
 
        from canonical.launchpad.database.librarian import LibraryFileAlias
328
 
        count = self.store.find(
329
 
            LibraryFileAlias, LibraryFileAlias.restricted == True).remove()
330
 
        self.store.flush()
331
 
        self.logger.info("Removed %d restricted librarian files.", count)
332
 
 
333
 
    def removeInactiveProjects(self):
334
 
        """Remove inactive projects."""
335
 
        from lp.registry.model.projectgroup import ProjectGroup
336
 
        count = self.store.find(
337
 
            ProjectGroup, ProjectGroup.active == False).remove()
338
 
        self.store.flush()
339
 
        self.logger.info("Removed %d inactive product groups.", count)
340
 
 
341
 
    def removeInactiveProducts(self):
342
 
        """Remove inactive products."""
343
 
        from lp.registry.model.product import Product
344
 
        count = self.store.find(
345
 
            Product, Product.active == False).remove()
346
 
        self.store.flush()
347
 
        self.logger.info("Removed %d inactive products.", count)
348
 
 
349
 
    def removeTableRows(self, table):
350
 
        """Remove all data from a table."""
351
 
        count = self.store.execute("DELETE FROM %s" % table).rowcount
352
 
        self.store.execute("ANALYZE %s" % table)
353
 
        self.logger.info("Removed %d %s rows (all).", count, table)
354
 
 
355
 
    def removeUnlinked(self, table, ignores=()):
356
 
        """Remove all unlinked entries in the table.
357
 
 
358
 
        References from the ignores list are ignored.
359
 
 
360
 
        :param table: table name.
361
 
 
362
 
        :param ignores: list of (table, column) references to ignore.
363
 
        """
364
 
        references = []
365
 
        for result in listReferences(cursor(), table, 'id'):
366
 
            (from_table, from_column, to_table,
367
 
                to_column, update, delete) = result
368
 
            if (to_table == table and to_column == 'id'
369
 
                and (from_table, from_column) not in ignores):
370
 
                references.append(
371
 
                    "EXCEPT SELECT %s FROM %s" % (from_column, from_table))
372
 
        query = (
373
 
            "DELETE FROM %s USING (SELECT id FROM %s %s) AS Unreferenced "
374
 
            "WHERE %s.id = Unreferenced.id"
375
 
            % (table, table, ' '.join(references), table))
376
 
        self.logger.log(DEBUG2, query)
377
 
        count = self.store.execute(query).rowcount
378
 
        self.logger.info("Removed %d unlinked %s rows.", count, table)
379
 
 
380
 
    def removeInvalidEmailAddresses(self):
381
 
        """Remove all invalid and old email addresses."""
382
 
        from canonical.launchpad.database.emailaddress import EmailAddress
383
 
        from canonical.launchpad.interfaces.emailaddress import (
384
 
            EmailAddressStatus)
385
 
        count = self.store.find(
386
 
            EmailAddress, Or(
387
 
                EmailAddress.status == EmailAddressStatus.NEW,
388
 
                EmailAddress.status == EmailAddressStatus.OLD,
389
 
                EmailAddress.email.lower().like(
390
 
                    u'%@example.com', case_sensitive=True))).remove()
391
 
        self.store.flush()
392
 
        self.logger.info(
393
 
            "Removed %d invalid, unvalidated and old email addresses.", count)
394
 
 
395
 
    def removePPAArchivePermissions(self):
396
 
        """Remove ArchivePermission records for PPAs."""
397
 
        from lp.soyuz.enums import ArchivePurpose
398
 
        count = self.store.execute("""
399
 
            DELETE FROM ArchivePermission
400
 
            USING Archive
401
 
            WHERE ArchivePermission.archive = Archive.id
402
 
                AND Archive.purpose = %s
403
 
            """ % sqlvalues(ArchivePurpose.PPA)).rowcount
404
 
        self.logger.info(
405
 
            "Removed %d ArchivePermission records linked to PPAs.", count)
406
 
 
407
 
    def scrambleHiddenEmailAddresses(self):
408
 
        """Hide email addresses users have requested to not be public.
409
 
 
410
 
        Call after removeInvalidEmailAddresses to avoid any possible
411
 
        name clashes.
412
 
 
413
 
        This replaces the email addresses of all people with
414
 
        hide_email_addresses set with an @example.com email address.
415
 
        """
416
 
        # One day there might be Storm documentation telling me how to
417
 
        # do this via the ORM.
418
 
        count = self.store.execute("""
419
 
            UPDATE EmailAddress
420
 
            SET email='e' || text(EmailAddress.id) || '@example.com'
421
 
            FROM Person
422
 
            WHERE EmailAddress.person = Person.id
423
 
                AND Person.hide_email_addresses IS TRUE
424
 
            """).rowcount
425
 
        self.logger.info(
426
 
            "Replaced %d hidden email addresses with @example.com", count)
427
 
 
428
 
    def removeUnlinkedEmailAddresses(self):
429
 
        """Remove EmailAddresses not linked to a Person.
430
 
 
431
 
        We call this before removeUnlinkedAccounts to avoid the
432
 
        ON DELETE SET NULL overhead from the EmailAddress -> Account
433
 
        foreign key constraint.
434
 
        """
435
 
        from canonical.launchpad.database.emailaddress import EmailAddress
436
 
        count = self.store.find(
437
 
            EmailAddress, EmailAddress.person == None).remove()
438
 
        self.store.flush()
439
 
        self.logger.info(
440
 
            "Removed %d email addresses not linked to people.", count)
441
 
 
442
 
    def removeUnlinkedAccounts(self):
443
 
        """Remove Accounts not linked to a Person."""
444
 
        from canonical.launchpad.database.account import Account
445
 
        from lp.registry.model.person import Person
446
 
        all_accounts = self.store.find(Account)
447
 
        linked_accounts = self.store.find(
448
 
            Account, Account.id == Person.accountID)
449
 
        unlinked_accounts = all_accounts.difference(linked_accounts)
450
 
        total_unlinked_accounts = unlinked_accounts.count()
451
 
        count = 0
452
 
        for account in unlinked_accounts:
453
 
            self.store.remove(account)
454
 
            self.store.flush()
455
 
            count += 1
456
 
            self.logger.debug(
457
 
                "Removed %d of %d unlinked accounts."
458
 
                % (count, total_unlinked_accounts))
459
 
        self.logger.info("Removed %d accounts not linked to a person", count)
460
 
 
461
 
    def scrubColumn(self, table, columns):
462
 
        """Remove production admin related notes."""
463
 
        query = ["UPDATE %s SET" % table]
464
 
        for column in columns:
465
 
            query.append("%s = NULL" % column)
466
 
            query.append(",")
467
 
        query.pop()
468
 
        query.append("WHERE")
469
 
        for column in columns:
470
 
            query.append("%s IS NOT NULL" % column)
471
 
            query.append("OR")
472
 
        query.pop()
473
 
        self.logger.log(DEBUG3, ' '.join(query))
474
 
        count = self.store.execute(' '.join(query)).rowcount
475
 
        self.logger.info(
476
 
            "Scrubbed %d %s.{%s} entries."
477
 
            % (count, table, ','.join(columns)))
478
 
 
479
 
    def allForeignKeysCascade(self):
480
 
        """Set all foreign key constraints to ON DELETE CASCADE.
481
 
 
482
 
        The current state is recorded first so resetForeignKeysCascade
483
 
        can repair the changes.
484
 
 
485
 
        Only tables in the public schema are modified.
486
 
        """
487
 
        # Get the SQL needed to create the foreign key constraints.
488
 
        # pg_dump seems the only sane way of getting this. We could
489
 
        # generate the SQL ourselves using the pg_constraints table,
490
 
        # but that can change between PostgreSQL releases.
491
 
        # Ideally we could use ALTER CONSTRAINT, but that doesn't exist.
492
 
        # Or modify pg_constraints, but that doesn't work.
493
 
        cmd = [
494
 
            'pg_dump', '--no-privileges', '--no-owner', '--schema-only',
495
 
            '--schema=public']
496
 
        cmd.extend(
497
 
            self.pg_connection_string.asPGCommandLineArgs().split(' '))
498
 
        self.logger.debug("Running %s", ' '.join(cmd))
499
 
        pg_dump = subprocess.Popen(
500
 
            cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
501
 
            stdin=subprocess.PIPE)
502
 
        (pg_dump_out, pg_dump_err) = pg_dump.communicate()
503
 
        if pg_dump.returncode != 0:
504
 
            self.fail("pg_dump returned %d" % pg_dump.returncode)
505
 
 
506
 
        cascade_sql = []
507
 
        restore_sql = []
508
 
        pattern = r"""
509
 
            (?x) ALTER \s+ TABLE \s+ ONLY \s+ (".*?"|\w+?) \s+
510
 
            ADD \s+ CONSTRAINT \s+ (".*?"|\w+?) \s+ FOREIGN \s+ KEY [^;]+;
511
 
            """
512
 
        for match in re.finditer(pattern, pg_dump_out):
513
 
            table = match.group(1)
514
 
            constraint = match.group(2)
515
 
 
516
 
            sql = match.group(0)
517
 
 
518
 
            # Drop the existing constraint so we can recreate it.
519
 
            drop_sql = 'ALTER TABLE %s DROP CONSTRAINT %s;' % (
520
 
                table, constraint)
521
 
            restore_sql.append(drop_sql)
522
 
            cascade_sql.append(drop_sql)
523
 
 
524
 
            # Store the SQL needed to restore the constraint.
525
 
            restore_sql.append(sql)
526
 
 
527
 
            # Recreate the constraint as ON DELETE CASCADE
528
 
            sql = re.sub(r"""(?xs)^
529
 
                (.*?)
530
 
                (?:ON \s+ DELETE \s+ (?:NO\s+|SET\s+)?\w+)? \s*
531
 
                ((?:NOT\s+)? DEFERRABLE|) \s*
532
 
                (INITIALLY\s+(?:DEFERRED|IMMEDIATE)|) \s*;
533
 
                """, r"\1 ON DELETE CASCADE \2 \3;", sql)
534
 
            cascade_sql.append(sql)
535
 
 
536
 
        # Set all the foreign key constraints to ON DELETE CASCADE, really.
537
 
        self.logger.info(
538
 
            "Setting %d constraints to ON DELETE CASCADE",
539
 
            len(cascade_sql) / 2)
540
 
        for statement in cascade_sql:
541
 
            self.logger.log(DEBUG3, statement)
542
 
            self.store.execute(statement)
543
 
 
544
 
        # Store the recovery SQL.
545
 
        self._reset_foreign_key_sql = restore_sql
546
 
 
547
 
    def resetForeignKeysCascade(self):
548
 
        """Reset the foreign key constraints' ON DELETE mode."""
549
 
        self.logger.info(
550
 
            "Resetting %d foreign key constraints to initial state.",
551
 
            len(self._reset_foreign_key_sql)/2)
552
 
        for statement in self._reset_foreign_key_sql:
553
 
            self.store.execute(statement)
554
 
 
555
 
    def disableTriggers(self, triggers_to_disable):
556
 
        """Disable a set of triggers.
557
 
 
558
 
        :param triggers_to_disable: List of (table_name, trigger_name).
559
 
        """
560
 
        self.logger.debug("Disabling %d triggers." % len(triggers_to_disable))
561
 
        for table_name, trigger_name in triggers_to_disable:
562
 
            self.logger.debug(
563
 
                "Disabling trigger %s.%s." % (table_name, trigger_name))
564
 
            self.store.execute(
565
 
                "ALTER TABLE %s DISABLE TRIGGER %s"
566
 
                % (table_name, trigger_name))
567
 
 
568
 
    def enableTriggers(self, triggers_to_enable):
569
 
        """Renable a set of triggers.
570
 
 
571
 
        :param triggers_to_enable: List of (table_name, trigger_name).
572
 
        """
573
 
        self.logger.debug("Enabling %d triggers." % len(triggers_to_enable))
574
 
        for table_name, trigger_name in triggers_to_enable:
575
 
            self.logger.debug(
576
 
                "Enabling trigger %s.%s." % (table_name, trigger_name))
577
 
            self.store.execute(
578
 
                "ALTER TABLE %s ENABLE TRIGGER %s"
579
 
                % (table_name, trigger_name))
580
 
 
581
 
    def repairData(self):
582
 
        """After scrubbing, repair any data possibly damaged in the process.
583
 
        """
584
 
        # Repair Bug.message_count and Bug.date_last_message.
585
 
        # The triggers where disabled while we where doing the cascading
586
 
        # deletes because they fail (attempting to change a mutating table).
587
 
        # We can repair these caches by forcing the triggers to run for
588
 
        # every row.
589
 
        self.store.execute("""
590
 
            UPDATE Message SET visible=visible
591
 
            FROM BugMessage
592
 
            WHERE BugMessage.message = Message.id
593
 
            """)
594
 
 
595
 
    def _fail(self, error_message):
596
 
        self.logger.fatal(error_message)
597
 
        sys.exit(1)