~launchpad-pqm/launchpad/devel

« back to all changes in this revision

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

  • Committer: Danilo Segan
  • Date: 2011-04-22 14:02:29 UTC
  • mto: This revision was merged to the branch mainline in revision 12910.
  • Revision ID: danilo@canonical.com-20110422140229-zhq4d4c2k8jpglhf
Ignore hidden files when building combined JS file.

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 canonical.launchpad.interfaces.launchpad import (
 
193
            ILaunchpadCelebrities)
 
194
        from lp.registry.model.person import Person
 
195
        celebrities = getUtility(ILaunchpadCelebrities)
 
196
        # This is a slow operation due to the huge amount of cascading.
 
197
        # We remove one row at a time for better reporting and PostgreSQL
 
198
        # memory use.
 
199
        deactivated_people = self.store.find(
 
200
            Person,
 
201
            Person.account == Account.id,
 
202
            Account.status != AccountStatus.ACTIVE)
 
203
        total_deactivated_count = deactivated_people.count()
 
204
        deactivated_count = 0
 
205
        for person in deactivated_people:
 
206
            # Ignore celebrities
 
207
            if celebrities.isCelebrityPerson(person.name):
 
208
                continue
 
209
            deactivated_count += 1
 
210
            self.logger.debug(
 
211
                "Removing %d of %d deactivated people (%s)",
 
212
                deactivated_count, total_deactivated_count, person.name)
 
213
            # Clean out the EmailAddress and Account for this person
 
214
            # while we are here, making subsequent unbatched steps
 
215
            # faster. These don't cascade due to the lack of a foreign
 
216
            # key constraint between Person and EmailAddress, and the
 
217
            # ON DELETE SET NULL foreign key constraint between
 
218
            # EmailAddress and Account.
 
219
            self.store.find(
 
220
                EmailAddress, EmailAddress.person == person).remove()
 
221
            self.store.find(Account, Account.id == person.accountID).remove()
 
222
            self.store.remove(person)
 
223
            self.store.flush()
 
224
        self.logger.info(
 
225
            "Removed %d suspended or deactivated people + email + accounts",
 
226
            deactivated_count)
 
227
 
 
228
    def removePrivatePeople(self):
 
229
        """Remove all private people."""
 
230
        from lp.registry.interfaces.person import PersonVisibility
 
231
        from lp.registry.model.person import Person
 
232
        count = self.store.find(
 
233
            Person,
 
234
            Person.teamowner == None,
 
235
            Person.visibility != PersonVisibility.PUBLIC).remove()
 
236
        self.store.flush()
 
237
        self.logger.info("Removed %d private people.", count)
 
238
 
 
239
    def removePrivateTeams(self):
 
240
        """Remove all private people."""
 
241
        from lp.registry.interfaces.person import PersonVisibility
 
242
        from lp.registry.model.person import Person
 
243
        count = self.store.find(
 
244
            Person,
 
245
            Person.teamowner != None,
 
246
            Person.visibility != PersonVisibility.PUBLIC).remove()
 
247
        self.store.flush()
 
248
        self.logger.info("Removed %d private teams.", count)
 
249
 
 
250
    def removePrivateBugs(self):
 
251
        """Remove all private bugs."""
 
252
        from lp.bugs.model.bug import Bug
 
253
        count = self.store.find(Bug, Bug.private == True).remove()
 
254
        self.store.flush()
 
255
        self.logger.info("Removed %d private bugs.", count)
 
256
 
 
257
    def removePrivateBugMessages(self):
 
258
        """Remove all hidden bug messages."""
 
259
        from lp.bugs.model.bugmessage import BugMessage
 
260
        from canonical.launchpad.database.message import Message
 
261
        message_ids = list(self.store.using(*[
 
262
            BugMessage,
 
263
            Join(Message, BugMessage.messageID == Message.id),
 
264
            ]).find(BugMessage.id, Message.visible == False))
 
265
        self.store.flush()
 
266
        count = self.store.find(
 
267
            BugMessage, BugMessage.id.is_in(message_ids)).remove()
 
268
        self.store.flush()
 
269
        self.logger.info("Removed %d private bug messages.", count)
 
270
 
 
271
    def removePrivateBranches(self):
 
272
        """Remove all private branches."""
 
273
        from lp.code.model.branch import Branch
 
274
        count = self.store.find(Branch, Branch.private == True).remove()
 
275
        self.store.flush()
 
276
        self.logger.info("Removed %d private branches.", count)
 
277
 
 
278
    def removePrivateHwSubmissions(self):
 
279
        """Remove all private hardware submissions."""
 
280
        from lp.hardwaredb.model.hwdb import HWSubmission
 
281
        count = self.store.find(
 
282
            HWSubmission, HWSubmission.private == True).remove()
 
283
        self.store.flush()
 
284
        self.logger.info("Removed %d private hardware submissions.", count)
 
285
 
 
286
    def removePrivateSpecifications(self):
 
287
        """Remove all private specifications."""
 
288
        from lp.blueprints.model.specification import Specification
 
289
        count = self.store.find(
 
290
            Specification, Specification.private == True).remove()
 
291
        self.store.flush()
 
292
        self.logger.info("Removed %d private specifications.", count)
 
293
 
 
294
    def removePrivateLocations(self):
 
295
        """Remove private person locations."""
 
296
        from lp.registry.model.personlocation import PersonLocation
 
297
        count = self.store.find(
 
298
            PersonLocation, PersonLocation.visible == False).remove()
 
299
        self.store.flush()
 
300
        self.logger.info("Removed %d person locations.", count)
 
301
 
 
302
    def removePrivateArchives(self):
 
303
        """Remove private archives.
 
304
 
 
305
        This might over delete, but lets be conservative for now.
 
306
        """
 
307
        from lp.soyuz.model.archive import Archive
 
308
        count = self.store.find(Archive, Archive.private == True).remove()
 
309
        self.store.flush()
 
310
        self.logger.info(
 
311
            "Removed %d private archives.", count)
 
312
 
 
313
    def removePrivateAnnouncements(self):
 
314
        """Remove announcements that have not yet been published."""
 
315
        from lp.registry.model.announcement import Announcement
 
316
        count = self.store.find(
 
317
            Announcement, Or(
 
318
                Announcement.date_announced == None,
 
319
                Announcement.date_announced > UTC_NOW,
 
320
                Announcement.active == False)).remove()
 
321
        self.store.flush()
 
322
        self.logger.info(
 
323
            "Removed %d unpublished announcements.", count)
 
324
 
 
325
    def removePrivateLibrarianFiles(self):
 
326
        """Remove librarian files only available via the restricted librarian.
 
327
        """
 
328
        from canonical.launchpad.database.librarian import LibraryFileAlias
 
329
        count = self.store.find(
 
330
            LibraryFileAlias, LibraryFileAlias.restricted == True).remove()
 
331
        self.store.flush()
 
332
        self.logger.info("Removed %d restricted librarian files.", count)
 
333
 
 
334
    def removeInactiveProjects(self):
 
335
        """Remove inactive projects."""
 
336
        from lp.registry.model.projectgroup import ProjectGroup
 
337
        count = self.store.find(
 
338
            ProjectGroup, ProjectGroup.active == False).remove()
 
339
        self.store.flush()
 
340
        self.logger.info("Removed %d inactive product groups.", count)
 
341
 
 
342
    def removeInactiveProducts(self):
 
343
        """Remove inactive products."""
 
344
        from lp.registry.model.product import Product
 
345
        count = self.store.find(
 
346
            Product, Product.active == False).remove()
 
347
        self.store.flush()
 
348
        self.logger.info("Removed %d inactive products.", count)
 
349
 
 
350
    def removeTableRows(self, table):
 
351
        """Remove all data from a table."""
 
352
        count = self.store.execute("DELETE FROM %s" % table).rowcount
 
353
        self.store.execute("ANALYZE %s" % table)
 
354
        self.logger.info("Removed %d %s rows (all).", count, table)
 
355
 
 
356
    def removeUnlinked(self, table, ignores=()):
 
357
        """Remove all unlinked entries in the table.
 
358
 
 
359
        References from the ignores list are ignored.
 
360
 
 
361
        :param table: table name.
 
362
 
 
363
        :param ignores: list of (table, column) references to ignore.
 
364
        """
 
365
        references = []
 
366
        for result in listReferences(cursor(), table, 'id'):
 
367
            (from_table, from_column, to_table,
 
368
                to_column, update, delete) = result
 
369
            if (to_table == table and to_column == 'id'
 
370
                and (from_table, from_column) not in ignores):
 
371
                references.append(
 
372
                    "EXCEPT SELECT %s FROM %s" % (from_column, from_table))
 
373
        query = (
 
374
            "DELETE FROM %s USING (SELECT id FROM %s %s) AS Unreferenced "
 
375
            "WHERE %s.id = Unreferenced.id"
 
376
            % (table, table, ' '.join(references), table))
 
377
        self.logger.log(DEBUG2, query)
 
378
        count = self.store.execute(query).rowcount
 
379
        self.logger.info("Removed %d unlinked %s rows.", count, table)
 
380
 
 
381
    def removeInvalidEmailAddresses(self):
 
382
        """Remove all invalid and old email addresses."""
 
383
        from canonical.launchpad.database.emailaddress import EmailAddress
 
384
        from canonical.launchpad.interfaces.emailaddress import (
 
385
            EmailAddressStatus)
 
386
        count = self.store.find(
 
387
            EmailAddress, Or(
 
388
                EmailAddress.status == EmailAddressStatus.NEW,
 
389
                EmailAddress.status == EmailAddressStatus.OLD,
 
390
                EmailAddress.email.lower().like(
 
391
                    u'%@example.com', case_sensitive=True))).remove()
 
392
        self.store.flush()
 
393
        self.logger.info(
 
394
            "Removed %d invalid, unvalidated and old email addresses.", count)
 
395
 
 
396
    def removePPAArchivePermissions(self):
 
397
        """Remove ArchivePermission records for PPAs."""
 
398
        from lp.soyuz.enums import ArchivePurpose
 
399
        count = self.store.execute("""
 
400
            DELETE FROM ArchivePermission
 
401
            USING Archive
 
402
            WHERE ArchivePermission.archive = Archive.id
 
403
                AND Archive.purpose = %s
 
404
            """ % sqlvalues(ArchivePurpose.PPA)).rowcount
 
405
        self.logger.info(
 
406
            "Removed %d ArchivePermission records linked to PPAs.", count)
 
407
 
 
408
    def scrambleHiddenEmailAddresses(self):
 
409
        """Hide email addresses users have requested to not be public.
 
410
 
 
411
        Call after removeInvalidEmailAddresses to avoid any possible
 
412
        name clashes.
 
413
 
 
414
        This replaces the email addresses of all people with
 
415
        hide_email_addresses set with an @example.com email address.
 
416
        """
 
417
        # One day there might be Storm documentation telling me how to
 
418
        # do this via the ORM.
 
419
        count = self.store.execute("""
 
420
            UPDATE EmailAddress
 
421
            SET email='e' || text(EmailAddress.id) || '@example.com'
 
422
            FROM Person
 
423
            WHERE EmailAddress.person = Person.id
 
424
                AND Person.hide_email_addresses IS TRUE
 
425
            """).rowcount
 
426
        self.logger.info(
 
427
            "Replaced %d hidden email addresses with @example.com", count)
 
428
 
 
429
    def removeUnlinkedEmailAddresses(self):
 
430
        """Remove EmailAddresses not linked to a Person.
 
431
 
 
432
        We call this before removeUnlinkedAccounts to avoid the
 
433
        ON DELETE SET NULL overhead from the EmailAddress -> Account
 
434
        foreign key constraint.
 
435
        """
 
436
        from canonical.launchpad.database.emailaddress import EmailAddress
 
437
        count = self.store.find(
 
438
            EmailAddress, EmailAddress.person == None).remove()
 
439
        self.store.flush()
 
440
        self.logger.info(
 
441
            "Removed %d email addresses not linked to people.", count)
 
442
 
 
443
    def removeUnlinkedAccounts(self):
 
444
        """Remove Accounts not linked to a Person."""
 
445
        from canonical.launchpad.database.account import Account
 
446
        from lp.registry.model.person import Person
 
447
        all_accounts = self.store.find(Account)
 
448
        linked_accounts = self.store.find(
 
449
            Account, Account.id == Person.accountID)
 
450
        unlinked_accounts = all_accounts.difference(linked_accounts)
 
451
        total_unlinked_accounts = unlinked_accounts.count()
 
452
        count = 0
 
453
        for account in unlinked_accounts:
 
454
            self.store.remove(account)
 
455
            self.store.flush()
 
456
            count += 1
 
457
            self.logger.debug(
 
458
                "Removed %d of %d unlinked accounts."
 
459
                % (count, total_unlinked_accounts))
 
460
        self.logger.info("Removed %d accounts not linked to a person", count)
 
461
 
 
462
    def scrubColumn(self, table, columns):
 
463
        """Remove production admin related notes."""
 
464
        query = ["UPDATE %s SET" % table]
 
465
        for column in columns:
 
466
            query.append("%s = NULL" % column)
 
467
            query.append(",")
 
468
        query.pop()
 
469
        query.append("WHERE")
 
470
        for column in columns:
 
471
            query.append("%s IS NOT NULL" % column)
 
472
            query.append("OR")
 
473
        query.pop()
 
474
        self.logger.log(DEBUG3, ' '.join(query))
 
475
        count = self.store.execute(' '.join(query)).rowcount
 
476
        self.logger.info(
 
477
            "Scrubbed %d %s.{%s} entries."
 
478
            % (count, table, ','.join(columns)))
 
479
 
 
480
    def allForeignKeysCascade(self):
 
481
        """Set all foreign key constraints to ON DELETE CASCADE.
 
482
 
 
483
        The current state is recorded first so resetForeignKeysCascade
 
484
        can repair the changes.
 
485
 
 
486
        Only tables in the public schema are modified.
 
487
        """
 
488
        # Get the SQL needed to create the foreign key constraints.
 
489
        # pg_dump seems the only sane way of getting this. We could
 
490
        # generate the SQL ourselves using the pg_constraints table,
 
491
        # but that can change between PostgreSQL releases.
 
492
        # Ideally we could use ALTER CONSTRAINT, but that doesn't exist.
 
493
        # Or modify pg_constraints, but that doesn't work.
 
494
        cmd = [
 
495
            'pg_dump', '--no-privileges', '--no-owner', '--schema-only',
 
496
            '--schema=public']
 
497
        cmd.extend(
 
498
            self.pg_connection_string.asPGCommandLineArgs().split(' '))
 
499
        self.logger.debug("Running %s", ' '.join(cmd))
 
500
        pg_dump = subprocess.Popen(
 
501
            cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
 
502
            stdin=subprocess.PIPE)
 
503
        (pg_dump_out, pg_dump_err) = pg_dump.communicate()
 
504
        if pg_dump.returncode != 0:
 
505
            self.fail("pg_dump returned %d" % pg_dump.returncode)
 
506
 
 
507
        cascade_sql = []
 
508
        restore_sql = []
 
509
        pattern = r"""
 
510
            (?x) ALTER \s+ TABLE \s+ ONLY \s+ (".*?"|\w+?) \s+
 
511
            ADD \s+ CONSTRAINT \s+ (".*?"|\w+?) \s+ FOREIGN \s+ KEY [^;]+;
 
512
            """
 
513
        for match in re.finditer(pattern, pg_dump_out):
 
514
            table = match.group(1)
 
515
            constraint = match.group(2)
 
516
 
 
517
            sql = match.group(0)
 
518
 
 
519
            # Drop the existing constraint so we can recreate it.
 
520
            drop_sql = 'ALTER TABLE %s DROP CONSTRAINT %s;' % (
 
521
                table, constraint)
 
522
            restore_sql.append(drop_sql)
 
523
            cascade_sql.append(drop_sql)
 
524
 
 
525
            # Store the SQL needed to restore the constraint.
 
526
            restore_sql.append(sql)
 
527
 
 
528
            # Recreate the constraint as ON DELETE CASCADE
 
529
            sql = re.sub(r"""(?xs)^
 
530
                (.*?)
 
531
                (?:ON \s+ DELETE \s+ (?:NO\s+|SET\s+)?\w+)? \s*
 
532
                ((?:NOT\s+)? DEFERRABLE|) \s*
 
533
                (INITIALLY\s+(?:DEFERRED|IMMEDIATE)|) \s*;
 
534
                """, r"\1 ON DELETE CASCADE \2 \3;", sql)
 
535
            cascade_sql.append(sql)
 
536
 
 
537
        # Set all the foreign key constraints to ON DELETE CASCADE, really.
 
538
        self.logger.info(
 
539
            "Setting %d constraints to ON DELETE CASCADE",
 
540
            len(cascade_sql) / 2)
 
541
        for statement in cascade_sql:
 
542
            self.logger.log(DEBUG3, statement)
 
543
            self.store.execute(statement)
 
544
 
 
545
        # Store the recovery SQL.
 
546
        self._reset_foreign_key_sql = restore_sql
 
547
 
 
548
    def resetForeignKeysCascade(self):
 
549
        """Reset the foreign key constraints' ON DELETE mode."""
 
550
        self.logger.info(
 
551
            "Resetting %d foreign key constraints to initial state.",
 
552
            len(self._reset_foreign_key_sql)/2)
 
553
        for statement in self._reset_foreign_key_sql:
 
554
            self.store.execute(statement)
 
555
 
 
556
    def disableTriggers(self, triggers_to_disable):
 
557
        """Disable a set of triggers.
 
558
 
 
559
        :param triggers_to_disable: List of (table_name, trigger_name).
 
560
        """
 
561
        self.logger.debug("Disabling %d triggers." % len(triggers_to_disable))
 
562
        for table_name, trigger_name in triggers_to_disable:
 
563
            self.logger.debug(
 
564
                "Disabling trigger %s.%s." % (table_name, trigger_name))
 
565
            self.store.execute(
 
566
                "ALTER TABLE %s DISABLE TRIGGER %s"
 
567
                % (table_name, trigger_name))
 
568
 
 
569
    def enableTriggers(self, triggers_to_enable):
 
570
        """Renable a set of triggers.
 
571
 
 
572
        :param triggers_to_enable: List of (table_name, trigger_name).
 
573
        """
 
574
        self.logger.debug("Enabling %d triggers." % len(triggers_to_enable))
 
575
        for table_name, trigger_name in triggers_to_enable:
 
576
            self.logger.debug(
 
577
                "Enabling trigger %s.%s." % (table_name, trigger_name))
 
578
            self.store.execute(
 
579
                "ALTER TABLE %s ENABLE TRIGGER %s"
 
580
                % (table_name, trigger_name))
 
581
 
 
582
    def repairData(self):
 
583
        """After scrubbing, repair any data possibly damaged in the process.
 
584
        """
 
585
        # Repair Bug.message_count and Bug.date_last_message.
 
586
        # The triggers where disabled while we where doing the cascading
 
587
        # deletes because they fail (attempting to change a mutating table).
 
588
        # We can repair these caches by forcing the triggers to run for
 
589
        # every row.
 
590
        self.store.execute("""
 
591
            UPDATE Message SET visible=visible
 
592
            FROM BugMessage
 
593
            WHERE BugMessage.message = Message.id
 
594
            """)
 
595
 
 
596
    def _fail(self, error_message):
 
597
        self.logger.fatal(error_message)
 
598
        sys.exit(1)