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