fix rt-session-viewer mucking up upgrades
[freeside.git] / rt / sbin / rt-validator.in
1 #!@PERL@
2 # BEGIN BPS TAGGED BLOCK {{{
3
4 # COPYRIGHT:
5
6 # This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC
7 #                                          <jesse@bestpractical.com>
8
9 # (Except where explicitly superseded by other copyright notices)
10
11
12 # LICENSE:
13
14 # This work is made available to you under the terms of Version 2 of
15 # the GNU General Public License. A copy of that license should have
16 # been provided with this software, but in any event can be snarfed
17 # from www.gnu.org.
18
19 # This work is distributed in the hope that it will be useful, but
20 # WITHOUT ANY WARRANTY; without even the implied warranty of
21 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
22 # General Public License for more details.
23
24 # You should have received a copy of the GNU General Public License
25 # along with this program; if not, write to the Free Software
26 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
27 # 02110-1301 or visit their web page on the internet at
28 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
29
30
31 # CONTRIBUTION SUBMISSION POLICY:
32
33 # (The following paragraph is not intended to limit the rights granted
34 # to you to modify and distribute this software under the terms of
35 # the GNU General Public License and is only of importance to you if
36 # you choose to contribute your changes and enhancements to the
37 # community by submitting them to Best Practical Solutions, LLC.)
38
39 # By intentionally submitting any modifications, corrections or
40 # derivatives to this work, or any other work intended for use with
41 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
42 # you are the copyright holder for those contributions and you grant
43 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
44 # royalty-free, perpetual, license to use, copy, create derivative
45 # works based on those contributions, and sublicense and distribute
46 # those contributions and any derivatives thereof.
47
48 # END BPS TAGGED BLOCK }}}
49 use strict;
50 use warnings;
51
52 # fix lib paths, some may be relative
53 BEGIN {
54     require File::Spec;
55     my @libs = ("@RT_LIB_PATH@", "@LOCAL_LIB_PATH@");
56     my $bin_path;
57
58     for my $lib (@libs) {
59         unless ( File::Spec->file_name_is_absolute($lib) ) {
60             unless ($bin_path) {
61                 if ( File::Spec->file_name_is_absolute(__FILE__) ) {
62                     $bin_path = ( File::Spec->splitpath(__FILE__) )[1];
63                 }
64                 else {
65                     require FindBin;
66                     no warnings "once";
67                     $bin_path = $FindBin::Bin;
68                 }
69             }
70             $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
71         }
72         unshift @INC, $lib;
73     }
74
75 }
76
77 use Getopt::Long;
78 my %opt = ();
79 GetOptions(
80     \%opt,
81     'check|c',
82     'resolve',
83     'force',
84     'verbose|v',
85 );
86
87 usage() unless $opt{'check'};
88 usage_warning() if $opt{'resolve'} && !$opt{'force'};
89
90 sub usage {
91     print STDERR <<END;
92 Usage: $0 options
93
94 Options:
95
96     $0 --check
97     $0 --check --verbose
98     $0 --check --verbose --resolve
99     $0 --check --verbose --resolve --force
100
101 --check   - is mandatory argument, you can use -c, as well.
102 --verbose - print additional info to STDOUT
103 --resolve - enable resolver that can delete or create some records
104 --force   - resolve without asking questions
105
106 Description:
107
108 This script checks integrity of records in RT's DB. May delete some invalid
109 records or ressurect accidentally deleted.
110
111 END
112     exit 1;
113 }
114
115 sub usage_warning {
116     print <<END;
117 This utility can fix some issues with DB by creating or updating. In some
118 cases there is no enough data to resurect a missing record, but records which
119 refers to a missing can be deleted. It's up to you to decide what to do.
120
121 In any case it's highly recommended to have a backup before resolving anything.
122
123 Press enter to continue.
124 END
125     <>;
126 }
127
128 use RT;
129 RT::LoadConfig();
130 RT::Init();
131
132 my $dbh = $RT::Handle->dbh;
133 my $db_type = RT->Config->Get('DatabaseType');
134
135 my %TYPE = (
136     'Transactions.Field'    => 'text',
137     'Transactions.OldValue' => 'text',
138     'Transactions.NewValue' => 'text',
139 );
140
141 my @models = qw(
142     ACE
143     Attachment
144     Attribute
145     CachedGroupMember
146     CustomField
147     CustomFieldValue
148     GroupMember
149     Group
150     Link
151     ObjectCustomField
152     ObjectCustomFieldValue
153     Principal
154     Queue
155     ScripAction
156     ScripCondition
157     Scrip
158     Template
159     Ticket
160     Transaction
161     User
162 );
163
164 my %redo_on;
165 $redo_on{'Delete'} = {
166     ACL => [],
167
168     Attributes => [],
169
170     Links => [],
171
172     CustomFields => [],
173     CustomFieldValues => [],
174     ObjectCustomFields => [],
175     ObjectCustomFieldValues => [],
176
177     Queues => [],
178
179     Scrips => [],
180     ScripActions => [],
181     ScripConditions => [],
182     Templates => [],
183
184     Tickets => [ 'Tickets -> other', 'Tickets <-> Role Groups' ],
185     Transactions => [ 'Attachments -> other' ],
186
187     Principals => ['User <-> ACL equivalence group', 'GMs -> Groups, Members' ],
188     Users => ['User <-> ACL equivalence group', 'GMs -> Groups, Members', 'Principals -> Users' ],
189     Groups => ['User <-> ACL equivalence group', 'GMs -> Groups, Members', 'CGM vs. GM', 'Principals -> Groups' ],
190
191     GroupMembers => [ 'CGM vs. GM' ],
192     CachedGroupMembers => [ 'CGM vs. GM' ],
193 };
194 $redo_on{'Create'} = {
195     Principals => ['User <-> ACL equivalence group', 'GMs -> Groups, Members' ],
196     Groups => ['User <-> ACL equivalence group', 'GMs -> Groups, Members', 'CGM vs. GM' ],
197     GroupMembers => [ 'CGM vs. GM' ],
198     CachedGroupMembers => [ 'CGM vs. GM' ],
199 };
200
201 my %describe_cb;
202 %describe_cb = (
203     Attachments => sub {
204         my $row = shift;
205         my $txn_id = $row->{transactionid};
206         my $res = 'Attachment #'. $row->{id} .' -> Txn #'. $txn_id;
207         return $res .', '. describe( 'Transactions', $txn_id );
208     },
209     Transactions => sub {
210         my $row = shift;
211         return 'Transaction #'. $row->{id} .' -> object '. $row->{objecttype} .' #'. $row->{objectid};
212     },
213 );
214
215 { my %cache = ();
216 sub m2t($) {
217     my $model = shift;
218     return $cache{$model} if $cache{$model};
219     my $class = "RT::$model";
220     my $object = $class->new( $RT::SystemUser );
221     return $cache{$model} = $object->Table;
222 } }
223
224 my (@do_check, %redo_check);
225
226 my @CHECKS;
227 foreach my $table ( qw(Users Groups) ) {
228     push @CHECKS, "$table -> Principals" => sub {
229         my $msg = "A record in $table refers not existing record in Principals."
230             ." The script can either create missing record in Principals"
231             ." or delete record in $table.";
232         my ($type) = ($table =~ /^(.*)s$/);
233         check_integrity(
234             $table, 'id' => 'Principals', 'id',
235             join_condition => 't.PrincipalType = ?',
236             bind_values => [ $type ],
237             action => sub {
238                 my $id = shift;
239                 return unless my $a = prompt_action( ['Delete', 'create'], $msg );
240
241                 if ( $a eq 'd' ) {
242                     delete_record( $table, $id );
243                 }
244                 elsif ( $a eq 'c' ) {
245                     my $principal_id = create_record( 'Principals',
246                         id => $id, PrincipalType => $type, ObjectId => $id, Disabled => 0
247                     );
248                 }
249                 else {
250                     die "Unknown action '$a'";
251                 }
252             },
253         );
254     };
255
256     push @CHECKS, "Principals -> $table" => sub {
257         my $msg = "A record in Principals refers not existing record in $table."
258             ." In some cases it's possible to resurrect manually such records,"
259             ." but this utility can only delete";
260
261         check_integrity(
262             'Principals', 'id' => $table, 'id',
263             condition   => 's.PrincipalType = ?',
264             bind_values => [ $table =~ /^(.*)s$/ ],
265             action => sub {
266                 my $id = shift;
267                 return unless prompt( 'Delete', $msg );
268
269                 delete_record( 'Principals', $id );
270             },
271         );
272     };
273 }
274
275 push @CHECKS, 'User <-> ACL equivalence group' => sub {
276     # from user to group
277     check_integrity(
278         'Users', 'id' => 'Groups', 'Instance',
279         join_condition   => 't.Domain = ? AND t.Type = ?',
280         bind_values => [ 'ACLEquivalence',  'UserEquiv' ],
281         action => sub {
282             my $id = shift;
283             return unless prompt(
284                 'Create', "Found an user that has no ACL equivalence group."
285             );
286
287             my $gid = create_record( 'Groups',
288                 Domain => 'ACLEquivalence', Type => 'UserEquiv', Instance => $id,
289             );
290         },
291     );
292     # from group to user
293     check_integrity(
294         'Groups', 'Instance' => 'Users', 'id',
295         condition   => 's.Domain = ? AND s.Type = ?',
296         bind_values => [ 'ACLEquivalence',  'UserEquiv' ],
297         action => sub {
298             my $id = shift;
299             return unless prompt(
300                 'Delete', "Found an user ACL equivalence group, but there is no user."
301             );
302
303             delete_record( 'Groups', $id );
304         },
305     );
306     # one ACL equiv group for each user
307     check_uniqueness(
308         'Groups',
309         columns     => ['Instance'],
310         condition   => '.Domain = ? AND .Type = ?',
311         bind_values => [ 'ACLEquivalence',  'UserEquiv' ],
312     );
313 };
314
315 # check integrity of Queue role groups
316 push @CHECKS, 'Queues <-> Role Groups' => sub {
317     # XXX: we check only that there is at least one group for a queue
318     # from queue to group
319     check_integrity(
320         'Queues', 'id' => 'Groups', 'Instance',
321         join_condition   => 't.Domain = ?',
322         bind_values => [ 'RT::Queue-Role' ],
323     );
324     # from group to queue
325     check_integrity(
326         'Groups', 'Instance' => 'Queues', 'id',
327         condition   => 's.Domain = ?',
328         bind_values => [ 'RT::Queue-Role' ],
329         action => sub {
330             my $id = shift;
331             return unless prompt(
332                 'Delete', "Found role group of not existant queue."
333             );
334
335             delete_record( 'Groups', $id );
336         },
337     );
338 };
339
340 # check integrity of Ticket role groups
341 push @CHECKS, 'Tickets <-> Role Groups' => sub {
342     # XXX: we check only that there is at least one group for a queue
343     # from queue to group
344     check_integrity(
345         'Tickets', 'id' => 'Groups', 'Instance',
346         join_condition   => 't.Domain = ?',
347         bind_values => [ 'RT::Ticket-Role' ],
348     );
349     # from group to ticket
350     check_integrity(
351         'Groups', 'Instance' => 'Tickets', 'id',
352         condition   => 's.Domain = ?',
353         bind_values => [ 'RT::Ticket-Role' ],
354         action => sub {
355             my $id = shift;
356             return unless prompt(
357                 'Delete', "Found a role group of not existant ticket."
358             );
359
360             delete_record( 'Groups', $id );
361         },
362     );
363 };
364
365 # additional CHECKS on groups
366 push @CHECKS, 'Role Groups (Instance, Type) uniqueness' => sub {
367     # Check that Domain, Instance and Type are unique
368     check_uniqueness(
369         'Groups',
370         columns     => ['Domain', 'Instance', 'Type'],
371         condition   => '.Domain LIKE ?',
372         bind_values => [ '%-Role' ],
373     );
374 };
375
376
377 push @CHECKS, 'GMs -> Groups, Members' => sub {
378     my $msg = "A record in GroupMembers references an object that doesn't exist."
379         ." May be you deleted a group or principal directly from DB?"
380         ." Usually it's ok to delete such records.";
381     check_integrity(
382         'GroupMembers', 'GroupId' => 'Groups', 'id',
383         action => sub {
384             my $id = shift;
385             return unless prompt( 'Delete', $msg );
386
387             delete_record( 'GroupMembers', $id );
388         },
389     );
390     check_integrity(
391         'GroupMembers', 'MemberId' => 'Principals', 'id',
392         action => sub {
393             my $id = shift;
394             return unless prompt( 'Delete', $msg );
395
396             delete_record( 'GroupMembers', $id );
397         },
398     );
399 };
400
401 # CGM and GM
402 push @CHECKS, 'CGM vs. GM' => sub {
403     # all GM record should be duplicated in CGM
404     check_integrity(
405         GroupMembers       => ['GroupId', 'MemberId'],
406         CachedGroupMembers => ['GroupId', 'MemberId'],
407         join_condition     => 't.ImmediateParentId = t.GroupId AND t.Via = t.id',
408         action => sub {
409             my $id = shift;
410             return unless prompt(
411                 'Create',
412                 "Found a record in GroupMembers that has no direct duplicate in CachedGroupMembers table."
413             );
414
415             my $gm = RT::GroupMember->new( $RT::SystemUser );
416             $gm->Load( $id );
417             die "Couldn't load GM record #$id" unless $gm->id;
418             my $cgm = create_record( 'CachedGroupMembers',
419                 GroupId => $gm->GroupId, MemberId => $gm->MemberId,
420                 ImmediateParentId => $gm->GroupId, Via => undef,
421                 Disabled => 0, # XXX: we should check integrity of Disabled field
422             );
423             update_records( "CachedGroupMembers", { id => $cgm }, { Via => $cgm } );
424         },
425     );
426     # all first level CGM records should have a GM record
427     check_integrity(
428         CachedGroupMembers => ['GroupId', 'MemberId'],
429         GroupMembers       => ['GroupId', 'MemberId'],
430         condition     => 's.ImmediateParentId = s.GroupId AND s.Via = s.id AND s.GroupId != s.MemberId',
431         action => sub {
432             my $id = shift;
433             return unless prompt(
434                 'Delete',
435                 "Found a record in CachedGroupMembers for a (Group, Member) pair"
436                 ." that doesn't exist in GroupMembers table."
437             );
438
439             delete_record( 'CachedGroupMembers', $id );
440         },
441     );
442     # each group should have a CGM record where MemberId == GroupId
443     check_integrity(
444         Groups => ['id', 'id'],
445         CachedGroupMembers => ['GroupId', 'MemberId'],
446         join_condition     => 't.ImmediateParentId = t.GroupId AND t.Via = t.id',
447         action => sub {
448             my $id = shift;
449             return unless prompt(
450                 'Create',
451                 "Found a record in Groups that has no direct"
452                 ." duplicate in CachedGroupMembers table."
453             );
454
455             my $g = RT::Group->new( $RT::SystemUser );
456             $g->Load( $id );
457             die "Couldn't load group #$id" unless $g->id;
458             die "Loaded group by $id has id ". $g->id  unless $g->id == $id;
459             my $cgm = create_record( 'CachedGroupMembers',
460                 GroupId => $id, MemberId => $id,
461                 ImmediateParentId => $id, Via => undef,
462                 Disabled => $g->Disabled,
463             );
464             update_records( "CachedGroupMembers", { id => $cgm }, { Via => $cgm } );
465         },
466     );
467
468     # and back, each record in CGM with MemberId == GroupId without exceptions
469     # should reference a group
470     check_integrity(
471         CachedGroupMembers => ['GroupId', 'MemberId'],
472         Groups => ['id', 'id'],
473         condition => "s.GroupId = s.MemberId",
474         action => sub {
475             my $id = shift;
476             return unless prompt(
477                 'Delete',
478                 "Found a record in CachedGroupMembers for a group that doesn't exist."
479             );
480
481             delete_record( 'CachedGroupMembers', $id );
482         },
483     );
484     # Via
485     check_integrity(
486         CachedGroupMembers => 'Via',
487         CachedGroupMembers => 'id',
488         action => sub {
489             my $id = shift;
490             return unless prompt(
491                 'Delete',
492                 "Found a record in CachedGroupMembers with Via referencing not existing record."
493             );
494
495             delete_record( 'CachedGroupMembers', $id );
496         },
497     );
498
499     # for every CGM where ImmediateParentId != GroupId there should be
500     # matching parent record (first level) 
501     check_integrity(
502         CachedGroupMembers => ['ImmediateParentId', 'MemberId'],
503         CachedGroupMembers => ['GroupId', 'MemberId'],
504         join_condition => 't.Via = t.id',
505         condition => 's.ImmediateParentId != s.GroupId',
506         action => sub {
507             my $id = shift;
508             return unless prompt(
509                 'Delete',
510                 "Found a record in CachedGroupMembers that referencing not existant record in CachedGroupMembers table."
511             );
512
513             delete_record( 'CachedGroupMembers', $id );
514         },
515     );
516
517     # for every CGM where ImmediateParentId != GroupId there should be
518     # matching "grand" parent record
519     check_integrity(
520         CachedGroupMembers => ['GroupId', 'ImmediateParentId', 'Via'],
521         CachedGroupMembers => ['GroupId', 'MemberId', 'id'],
522         condition => 's.ImmediateParentId != s.GroupId',
523         action => sub {
524             my $id = shift;
525             return unless prompt(
526                 'Delete',
527                 "Found a record in CachedGroupMembers that referencing not existant record in CachedGroupMembers table."
528             );
529
530             delete_record( 'CachedGroupMembers', $id );
531         },
532     );
533
534     # CHECK recursive records:
535     # if we have CGM1 (G1,M1,V1,IP1) then for every GM2(G2, M2), where G2 == M1,
536     # we should have CGM3 where G3 = G1, M3 = M2, V3 = ID1, IP3 = M1
537     {
538         my $query = <<END;
539 SELECT cgm1.GroupId, gm2.MemberId, cgm1.id AS Via,
540     cgm1.MemberId AS ImmediateParentId, cgm1.Disabled
541 FROM
542     CachedGroupMembers cgm1
543     CROSS JOIN GroupMembers gm2
544     LEFT JOIN CachedGroupMembers cgm3 ON (
545             cgm3.GroupId           = cgm1.GroupId
546         AND cgm3.MemberId          = gm2.MemberId
547         AND cgm3.Via               = cgm1.id
548         AND cgm3.ImmediateParentId = cgm1.MemberId )
549 WHERE cgm1.GroupId != cgm1.MemberId
550 AND gm2.GroupId = cgm1.MemberId
551 AND cgm3.id IS NULL
552 END
553
554         my $action = sub {
555             my %props = @_;
556             return unless prompt(
557                 'Create',
558                 "Found records in CachedGroupMembers table without recursive duplicates."
559             );
560             my $cgm = create_record( 'CachedGroupMembers', %props );
561         };
562
563         my $sth = execute_query( $query );
564         while ( my ($g, $m, $via, $ip, $dis) = $sth->fetchrow_array ) {
565             print STDERR "Principal #$m is member of #$ip when #$ip is member of #$g,";
566             print STDERR " but there is no cached GM record that $m is member of #$g.\n";
567             $action->(
568                 GroupId => $g, MemberId => $m, Via => $via,
569                 ImmediateParentId => $ip, Disabled => $dis,
570             );
571         }
572     }
573 };
574
575 # Tickets
576 push @CHECKS, 'Tickets -> other' => sub {
577     check_integrity(
578         'Tickets', 'EffectiveId' => 'Tickets', 'id',
579         action => sub {
580             my $id = shift;
581             return unless prompt(
582                 'Delete',
583                 "Found a ticket that's been merged into a ticket that don't exist anymore."
584             );
585
586             delete_record( 'Tickets', $id );
587         },
588     );
589     check_integrity(
590         'Tickets', 'Queue' => 'Queues', 'id',
591     );
592     check_integrity(
593         'Tickets', 'Owner' => 'Users', 'id',
594     );
595     # XXX: check that owner is only member of owner role group
596 };
597
598
599 push @CHECKS, 'Transactions -> other' => sub {
600     foreach my $model ( @models ) {
601         check_integrity(
602             'Transactions', 'ObjectId' => m2t($model), 'id',
603             condition   => 's.ObjectType = ?',
604             bind_values => [ "RT::$model" ],
605             action => sub {
606                 my $id = shift;
607                 return unless prompt(
608                     'Delete', "Found a transaction without object."
609                 );
610
611                 delete_record( 'Transactions', $id );
612             },
613         );
614     }
615     # type = CustomField
616     check_integrity(
617         'Transactions', 'Field' => 'CustomFields', 'id',
618         condition   => 's.Type = ?',
619         bind_values => [ 'CustomField' ],
620     );
621     # type = Take, Untake, Force, Steal or Give
622     check_integrity(
623         'Transactions', 'OldValue' => 'Users', 'id',
624         condition   => 's.Type IN (?, ?, ?, ?, ?)',
625         bind_values => [ qw(Take Untake Force Steal Give) ],
626         action => sub {
627             my $id = shift;
628             return unless prompt(
629                 'Delete', "Found a transaction regarding changes of Owner,"
630                 ." but User with id stored in OldValue column doesn't exist anymore."
631             );
632
633             delete_record( 'Transactions', $id );
634         },
635     );
636     check_integrity(
637         'Transactions', 'NewValue' => 'Users', 'id',
638         condition   => 's.Type IN (?, ?, ?, ?, ?)',
639         bind_values => [ qw(Take Untake Force Steal Give) ],
640         action => sub {
641             my $id = shift;
642             return unless prompt(
643                 'Delete', "Found a transaction regarding changes of Owner,"
644                 ." but User with id stored in NewValue column doesn't exist anymore."
645             );
646
647             delete_record( 'Transactions', $id );
648         },
649     );
650     # type = DelWatcher
651     check_integrity(
652         'Transactions', 'OldValue' => 'Principals', 'id',
653         condition   => 's.Type = ?',
654         bind_values => [ 'DelWatcher' ],
655         action => sub {
656             my $id = shift;
657             return unless prompt(
658                 'Delete', "Found a transaction describing watchers change,"
659                 ." but User with id stored in OldValue column doesn't exist anymore."
660             );
661
662             delete_record( 'Transactions', $id );
663         },
664     );
665     # type = AddWatcher
666     check_integrity(
667         'Transactions', 'NewValue' => 'Principals', 'id',
668         condition   => 's.Type = ?',
669         bind_values => [ 'AddWatcher' ],
670         action => sub {
671             my $id = shift;
672             return unless prompt(
673                 'Delete', "Found a transaction describing watchers change,"
674                 ." but User with id stored in NewValue column doesn't exist anymore."
675             );
676
677             delete_record( 'Transactions', $id );
678         },
679     );
680
681 # XXX: Links need more love, uri is stored instead of id
682 #    # type = DeleteLink
683 #    check_integrity(
684 #        'Transactions', 'OldValue' => 'Links', 'id',
685 #        condition   => 's.Type = ?',
686 #        bind_values => [ 'DeleteLink' ],
687 #    );
688 #    # type = AddLink
689 #    check_integrity(
690 #        'Transactions', 'NewValue' => 'Links', 'id',
691 #        condition   => 's.Type = ?',
692 #        bind_values => [ 'AddLink' ],
693 #    );
694
695     # type = Set, Field = Queue
696     check_integrity(
697         'Transactions', 'NewValue' => 'Queues', 'id',
698         condition   => 's.Type = ? AND s.Field = ?',
699         bind_values => [ 'Set', 'Queue' ],
700         action => sub {
701             my $id = shift;
702             return unless prompt(
703                 'Delete', "Found a transaction describing queue change,"
704                 ." but Queue with id stored in NewValue column doesn't exist anymore."
705             );
706
707             delete_record( 'Transactions', $id );
708         },
709     );
710     check_integrity(
711         'Transactions', 'OldValue' => 'Queues', 'id',
712         condition   => 's.Type = ? AND s.Field = ?',
713         bind_values => [ 'Set', 'Queue' ],
714         action => sub {
715             my $id = shift;
716             return unless prompt(
717                 'Delete', "Found a transaction describing queue change,"
718                 ." but Queue with id stored in OldValue column doesn't exist anymore."
719             );
720
721             delete_record( 'Transactions', $id );
722         },
723     );
724     # Reminders
725     check_integrity(
726         'Transactions', 'NewValue' => 'Tickets', 'id',
727         join_condition => 't.Type = ?',
728         condition      => 's.Type IN (?, ?, ?)',
729         bind_values    => [ 'reminder', 'AddReminder', 'OpenReminder', 'ResolveReminder' ],
730     );
731 };
732
733 # Attachments
734 push @CHECKS, 'Attachments -> other' => sub {
735     check_integrity(
736         Attachments  => 'TransactionId', Transactions => 'id',
737         action => sub {
738             my $id = shift;
739             return unless prompt(
740                 'Delete', "Found an attachment without a transaction."
741             );
742             delete_record( 'Attachments', $id );
743         },
744     );
745     check_integrity(
746         Attachments => 'Parent', Attachments => 'id',
747         action => sub {
748             my $id = shift;
749             return unless prompt(
750                 'Delete', "Found an sub-attachment without its parent attachment."
751             );
752             delete_record( 'Attachments', $id );
753         },
754     );
755     check_integrity(
756         Attachments => 'Parent',
757         Attachments => 'id',
758         join_condition => 's.TransactionId = t.TransactionId',
759     );
760 };
761
762 push @CHECKS, 'CustomFields and friends' => sub {
763     #XXX: ObjectCustomFields needs more love
764     check_integrity(
765         'CustomFieldValues', 'CustomField' => 'CustomFields', 'id',
766     );
767     check_integrity(
768         'ObjectCustomFieldValues', 'CustomField' => 'CustomFields', 'id',
769     );
770     foreach my $model ( @models ) {
771         check_integrity(
772             'ObjectCustomFieldValues', 'ObjectId' => m2t($model), 'id',
773             condition   => 's.ObjectType = ?',
774             bind_values => [ "RT::$model" ],
775         );
776     }
777 };
778
779 push @CHECKS, Templates => sub {
780     check_integrity(
781         'Templates', 'Queue' => 'Queues', 'id',
782     );
783 };
784
785 push @CHECKS, Scrips => sub {
786     check_integrity(
787         'Scrips', 'Queue' => 'Queues', 'id',
788     );
789     check_integrity(
790         'Scrips', 'ScripCondition' => 'ScripConditions', 'id',
791     );
792     check_integrity(
793         'Scrips', 'ScripAction' => 'ScripActions', 'id',
794     );
795     check_integrity(
796         'Scrips', 'Template' => 'Templates', 'id',
797     );
798 };
799
800 push @CHECKS, Attributes => sub {
801     foreach my $model ( @models ) {
802         check_integrity(
803             'Attributes', 'ObjectId' => m2t($model), 'id',
804             condition   => 's.ObjectType = ?',
805             bind_values => [ "RT::$model" ],
806         );
807     }
808 };
809
810 # Fix situations when Creator or LastUpdatedBy references ACL equivalence
811 # group of a user instead of user
812 push @CHECKS, 'FIX: LastUpdatedBy and Creator' => sub {
813     my %fix = ();
814     foreach my $model ( @models ) {
815         my $class = "RT::$model";
816         my $object = $class->new( $RT::SystemUser );
817         foreach my $column ( qw(LastUpdatedBy Creator) ) {
818             next unless $object->_Accessible( $column, 'auto' );
819
820             my $table = m2t($model);
821             my $query = <<END;
822 SELECT m.id, g.id, g.Instance
823 FROM
824     Groups g JOIN $table m ON g.id = m.$column
825 WHERE
826     g.Domain = ?
827     AND g.Type = ?
828 END
829             my $action = sub {
830                 my ($gid, $uid) = @_;
831                 return unless prompt(
832                     'Update',
833                     "Looks like there were a bug in old versions of RT back in 2006\n"
834                     ."that has been fixed. If other checks are ok then it's ok to update\n"
835                     ."these records to point them to users instead of groups"
836                 );
837                 $fix{ $table }{ $column }{ $gid } = $uid;
838             };
839
840             my $sth = execute_query( $query, 'ACLEquivalence', 'UserEquiv' );
841             while ( my ($rid, $gid, $uid) = $sth->fetchrow_array ) {
842                 print STDERR "Record #$rid in $table refers to ACL equivalence group #$gid of user #$uid";
843                 print STDERR " when must reference user.\n";
844                 $action->( $gid, $uid );
845                 if ( keys( %fix ) > 1000 ) {
846                     $sth->finish;
847                     last;
848                 }
849             }
850         }
851     }
852
853     if ( keys %fix ) {
854         foreach my $table ( keys %fix ) {
855             foreach my $column ( keys %{ $fix{ $table } } ) {
856                 my $query = "UPDATE $table SET $column = ? WHERE $column = ?";
857                 while ( my ($gid, $uid) = each %{ $fix{ $table }{ $column } } ) {
858                     update_records( $table, { $column => $gid }, { $column => $uid } );
859                 }
860             }
861         }
862         $redo_check{'FIX: LastUpdatedBy and Creator'} = 1;
863     }
864 };
865
866 push @CHECKS, 'LastUpdatedBy and Creator' => sub {
867     foreach my $model ( @models ) {
868         my $class = "RT::$model";
869         my $object = $class->new( $RT::SystemUser );
870         my $table = $object->Table;
871         foreach my $column ( qw(LastUpdatedBy Creator) ) {
872             next unless $object->_Accessible( $column, 'auto' );
873             check_integrity(
874                 $table, $column => 'Users', 'id',
875                 action => sub {
876                     my ($id, %prop) = @_;
877                     return unless my $replace_with = prompt_integer(
878                         'Replace',
879                         "Column $column should point to a user, but there is record #$id in table $table\n"
880                         ."where it's not true. It's ok to replace these wrong references with id of any user.\n"
881                         ."Note that id you enter is not checked. You can peak any user from your DB, but it's\n"
882                         ."may be better to create a special user for this, for example 'user_that_has_been_deleted'\n"
883                         ."or something like that.",
884                         "$table.$column -> user #$prop{$column}"
885                     );
886                     update_records( $table, { id => $id, $column => $prop{$column} }, { $column => $replace_with } );
887                 },
888             );
889         }
890     }
891 };
892 my %CHECKS = @CHECKS;
893
894 @do_check = do { my $i = 1; grep $i++%2, @CHECKS };
895
896 while ( my $check = shift @do_check ) {
897     $CHECKS{ $check }->();
898
899     foreach my $redo ( keys %redo_check ) {
900         die "check $redo doesn't exist" unless $CHECKS{ $redo };
901         delete $redo_check{ $redo };
902         next if grep $_ eq $redo, @do_check; # don't do twice
903         push @do_check, $redo;
904     }
905 }
906
907 sub check_integrity {
908     my ($stable, @scols) = (shift, shift);
909     my ($ttable, @tcols) = (shift, shift);
910     my %args = @_;
911
912     @scols = @{ $scols[0] } if ref $scols[0];
913     @tcols = @{ $tcols[0] } if ref $tcols[0];
914
915     print "Checking integrity of $stable.{". join(', ', @scols) ."} => $ttable.{". join(', ', @tcols) ."}\n"
916         if $opt{'verbose'};
917
918     my $query = "SELECT s.id, ". join(', ', map "s.$_", @scols)
919         ." FROM $stable s LEFT JOIN $ttable t"
920         ." ON (". join(
921             ' AND ', map columns_eq_cond('s', $stable, $scols[$_] => 't', $ttable, $tcols[$_]), (0..(@scols-1))
922         ) .")"
923         . ($args{'join_condition'}? " AND ( $args{'join_condition'} )": "")
924         ." WHERE t.id IS NULL"
925         ." AND ". join(' AND ', map "s.$_ IS NOT NULL", @scols);
926
927     $query .= " AND ( $args{'condition'} )" if $args{'condition'};
928
929     my @binds = @{ $args{'bind_values'} || [] };
930     if ( $tcols[0] eq 'id' && @tcols == 1 ) {
931         my $type = $TYPE{"$stable.$scols[0]"} || 'number';
932         if ( $type eq 'number' ) {
933             $query .= " AND s.$scols[0] != ?"
934         }
935         elsif ( $type eq 'text' ) {
936             $query .= " AND s.$scols[0] NOT LIKE ?"
937         }
938         push @binds, 0;
939     }
940
941     my $sth = execute_query( $query, @binds );
942     while ( my ($sid, @set) = $sth->fetchrow_array ) {
943         print STDERR "Record #$sid in $stable references not existent record in $ttable\n";
944         for ( my $i = 0; $i < @scols; $i++ ) {
945             print STDERR "\t$scols[$i] => '$set[$i]' => $tcols[$i]\n";
946         }
947         print STDERR "\t". describe( $stable, $sid ) ."\n";
948         $args{'action'}->( $sid, map { $scols[$_] => $set[$_] } (0 .. (@scols-1)) ) if $args{'action'};
949     }
950 }
951
952 sub describe {
953     my ($table, $id) = @_;
954     return '' unless my $cb = $describe_cb{ $table };
955
956     my $row = load_record( $table, $id );
957     unless ( $row->{id} ) {
958         $table =~ s/s$//;
959         return "$table doesn't exist";
960     }
961     return $cb->( $row );
962 }
963
964 sub columns_eq_cond {
965     my ($la, $lt, $lc, $ra, $rt, $rc) = @_;
966     my $ltype = $TYPE{"$lt.$lc"} || 'number';
967     my $rtype = $TYPE{"$rt.$rc"} || 'number';
968     return "$la.$lc = $ra.$rc" if $db_type ne 'Pg' || $ltype eq $rtype;
969
970     if ( $rtype eq 'text' ) {
971         return "$ra.$rc LIKE CAST($la.$lc AS text)";
972     }
973     elsif ( $ltype eq 'text' ) {
974         return "$la.$lc LIKE CAST($ra.$rc AS text)";
975     }
976     else { die "don't know how to cast" }
977 }
978
979 sub check_uniqueness {
980     my $on = shift;
981     my %args = @_;
982
983     my @columns = @{ $args{'columns'} };
984
985     print "Checking uniqueness of ( ", join(', ', map "'$_'", @columns )," ) in table '$on'\n"
986         if $opt{'versbose'};
987
988     my ($scond, $tcond);
989     if ( $scond = $tcond = $args{'condition'} ) {
990         $scond =~ s/(\s|^)\./$1s./g;
991         $tcond =~ s/(\s|^)\./$1t./g;
992     }
993
994     my $query = "SELECT s.id, t.id, ". join(', ', map "s.$_", @columns)
995         ." FROM $on s LEFT JOIN $on t "
996         ." ON s.id != t.id AND ". join(' AND ', map "s.$_ = t.$_", @columns)
997         . ($tcond? " AND ( $tcond )": "")
998         ." WHERE t.id IS NOT NULL "
999         ." AND ". join(' AND ', map "s.$_ IS NOT NULL", @columns);
1000     $query .= " AND ( $scond )" if $scond;
1001
1002     my $sth = execute_query(
1003         $query,
1004         $args{'bind_values'}? (@{ $args{'bind_values'} }, @{ $args{'bind_values'} }): ()
1005     );
1006     while ( my ($sid, $tid, @set) = $sth->fetchrow_array ) {
1007         print STDERR "Record #$tid in $on has the same set of values as $sid\n";
1008         for ( my $i = 0; $i < @columns; $i++ ) {
1009             print STDERR "\t$columns[$i] => '$set[$i]'\n";
1010         }
1011     }
1012 }
1013
1014 sub load_record {
1015     my ($table, $id) = @_;
1016     my $sth = execute_query( "SELECT * FROM $table WHERE id = ?", $id );
1017     return $sth->fetchrow_hashref('NAME_lc');
1018 }
1019
1020 sub delete_record {
1021     my ($table, $id) = (@_);
1022     print "Deleting record #$id in $table\n" if $opt{'verbose'};
1023     my $query = "DELETE FROM $table WHERE id = ?";
1024     $redo_check{ $_ } = 1 foreach @{ $redo_on{'Delete'}{ $table } || [] };
1025     return execute_query( $query, $id );
1026 }
1027
1028 sub create_record {
1029     print "Creating a record in $_[0]\n" if $opt{'verbose'};
1030     $redo_check{ $_ } = 1 foreach @{ $redo_on{'Create'}{ $_[0] } || [] };
1031     return $RT::Handle->Insert( @_ );
1032 }
1033
1034 sub update_records {
1035     my $table = shift;
1036     my $where = shift;
1037     my $what = shift;
1038
1039     my (@where_cols, @where_binds);
1040     while ( my ($k, $v) = each %$where ) { push @where_cols, $k; push @where_binds, $v; }
1041
1042     my (@what_cols, @what_binds);
1043     while ( my ($k, $v) = each %$what ) { push @what_cols, $k; push @what_binds, $v; }
1044
1045     print "Updating record(s) in $table\n" if $opt{'verbose'};
1046     my $query = "UPDATE $table SET ". join(', ', map "$_ = ?", @what_cols)
1047         ." WHERE ". join(' AND ', map "$_ = ?", @where_cols);
1048     $redo_check{ $_ } = 1 foreach @{ $redo_on{'Update'}{ $table } || [] };
1049     return execute_query( $query, @what_binds, @where_binds );
1050 }
1051
1052 sub execute_query {
1053     my ($query, @binds) = @_;
1054
1055     print "Executing query: $query\n\n" if $opt{'verbose'};
1056
1057     my $sth = $dbh->prepare( $query ) or die "couldn't prepare $query\n\tError: ". $dbh->errstr;
1058     $sth->execute( @binds ) or die "couldn't execute $query\n\tError: ". $sth->errstr;
1059     return $sth;
1060 }
1061
1062 { my %cached_answer;
1063 sub prompt {
1064     my $action = shift;
1065     my $msg = shift;
1066     my $token = shift || join ':', caller;
1067
1068     return 0 unless $opt{'resolve'};
1069     return 1 if $opt{'force'};
1070
1071     return $cached_answer{ $token } if exists $cached_answer{ $token };
1072
1073     print $msg, "\n";
1074     print "$action ALL records with the same defect? [N]: ";
1075     my $a = <STDIN>;
1076     return $cached_answer{ $token } = 1 if $a =~ /^(y|yes)$/i;
1077     return $cached_answer{ $token } = 0;
1078 } }
1079
1080 { my %cached_answer;
1081 sub prompt_action {
1082     my $actions = shift;
1083     my $msg = shift;
1084     my $token = shift || join ':', caller;
1085
1086     return '' unless $opt{'resolve'};
1087     return '' if $opt{'force'};
1088     return $cached_answer{ $token } if exists $cached_answer{ $token };
1089
1090     print $msg, "\n";
1091     print join( ' or ', @$actions ) ." ALL records with the same defect? [do nothing]: ";
1092     my $a = <STDIN>;
1093     chomp $a;
1094     return $cached_answer{ $token } = '' unless $a;
1095     foreach ( grep rindex(lc $_, lc $a, 0) == 0, @$actions ) {
1096         return $cached_answer{ $token } = lc substr $a, 0, 1;
1097     }
1098     return $cached_answer{ $token } = '';
1099 } }
1100
1101 { my %cached_answer;
1102 sub prompt_integer {
1103     my $action = shift;
1104     my $msg = shift;
1105     my $token = shift || join ':', caller;
1106
1107     return 0 unless $opt{'resolve'};
1108     return 0 if $opt{'force'};
1109
1110     return $cached_answer{ $token } if exists $cached_answer{ $token };
1111
1112     print $msg, "\n";
1113     print "$action ALL records with the same defect? [0]: ";
1114     my $a = <STDIN>; chomp $a; $a = int($a);
1115     return $cached_answer{ $token } = $a;
1116 } }
1117
1118 1;