Putting 4.2.0 on top of 4.0.17
[usit-rt.git] / sbin / rt-setup-fulltext-index
1 #!/usr/bin/perl
2 # BEGIN BPS TAGGED BLOCK {{{
3 #
4 # COPYRIGHT:
5 #
6 # This software is Copyright (c) 1996-2013 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 no warnings 'once';
52
53 # fix lib paths, some may be relative
54 BEGIN { # BEGIN RT CMD BOILERPLATE
55     require File::Spec;
56     require Cwd;
57     my @libs = ("lib", "local/lib");
58     my $bin_path;
59
60     for my $lib (@libs) {
61         unless ( File::Spec->file_name_is_absolute($lib) ) {
62             $bin_path ||= ( File::Spec->splitpath(Cwd::abs_path(__FILE__)) )[1];
63             $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
64         }
65         unshift @INC, $lib;
66     }
67
68 }
69
70 BEGIN {
71     use RT;
72     RT::LoadConfig();
73     RT::Init();
74 };
75 use RT::Interface::CLI ();
76
77 my %DB = (
78     type           => scalar RT->Config->Get('DatabaseType'),
79     user           => scalar RT->Config->Get('DatabaseUser'),
80     admin          => scalar RT->Config->Get('DatabaseAdmin'),
81     admin_password => undef,
82 );
83
84 my %OPT = (
85     help        => 0,
86     ask         => 1,
87     dryrun      => 0,
88     attachments => 1,
89 );
90
91 my %DEFAULT;
92 if ( $DB{'type'} eq 'Pg' ) {
93     %DEFAULT = (
94         table  => 'Attachments',
95         column => 'ContentIndex',
96     );
97 }
98 elsif ( $DB{'type'} eq 'mysql' ) {
99     %DEFAULT = (
100         table => 'AttachmentsIndex',
101     );
102 }
103 elsif ( $DB{'type'} eq 'Oracle' ) {
104     %DEFAULT = (
105         prefix => 'rt_fts_',
106     );
107 }
108
109 use Getopt::Long qw(GetOptions);
110 GetOptions(
111     'h|help!'        => \$OPT{'help'},
112     'ask!'           => \$OPT{'ask'},
113     'dry-run!'       => \$OPT{'dryrun'},
114     'attachments!'   => \$OPT{'attachments'},
115
116     'table=s'        => \$OPT{'table'},
117     'column=s'       => \$OPT{'column'},
118     'url=s'          => \$OPT{'url'},
119     'maxmatches=i'   => \$OPT{'maxmatches'},
120     'index-type=s'   => \$OPT{'index-type'},
121
122     'dba=s'          => \$DB{'admin'},
123     'dba-password=s' => \$DB{'admin_password'},
124 ) or show_help();
125
126 if ( $OPT{'help'} || (!$DB{'admin'} && $DB{'type'} eq 'Oracle' ) ) {
127     show_help( !$OPT{'help'} );
128 }
129
130 my $dbh = $RT::Handle->dbh;
131 $dbh->{'RaiseError'} = 1;
132 $dbh->{'PrintError'} = 1;
133
134 if ( $DB{'type'} eq 'mysql' ) {
135     check_sphinx();
136     my $table = $OPT{'table'} || prompt(
137         message => "Enter name of a new MySQL table that will be used to connect to the\n"
138                  . "Sphinx server:",
139         default => $DEFAULT{'table'},
140         silent  => !$OPT{'ask'},
141     );
142     my $url = $OPT{'url'} || prompt(
143         message => "Enter URL of the sphinx search server; this should be of the form\n"
144                  . "sphinx://<server>:<port>/<index name>",
145         default => 'sphinx://localhost:3312/rt',
146         silent  => !$OPT{'ask'},
147     );
148     my $maxmatches = $OPT{'maxmatches'} || prompt(
149         message => "Maximum number of matches to return; this is the maximum number of\n"
150                  . "attachment records returned by the search, not the maximum number\n"
151                  . "of tickets.  Both your RT_SiteConfig.pm and your sphinx.conf must\n"
152                  . "agree on this value.  Larger values cause your Sphinx server to\n"
153                  . "consume more memory and CPU time per query.",
154         default => 10000,
155         silent  => !$OPT{'ask'},
156     );
157
158     my $schema = <<END;
159 CREATE TABLE $table (
160     id     INTEGER UNSIGNED NOT NULL,
161     weight INTEGER NOT NULL,
162     query  VARCHAR(3072) NOT NULL,
163     INDEX(query)
164 ) ENGINE=SPHINX CONNECTION="$url" CHARACTER SET utf8
165 END
166
167     do_error_is_ok( dba_handle() => "DROP TABLE $table" )
168         unless $OPT{'dryrun'};
169     insert_schema( $schema );
170
171     print_rt_config( Table => $table, MaxMatches => $maxmatches );
172
173     require URI;
174     my $urlo = URI->new( $url );
175     my ($host, $port)  = split /:/, $urlo->authority;
176     my $index = $urlo->path;
177     $index =~ s{^/+}{};
178
179     my $var_path = $RT::VarPath;
180
181     my %sphinx_conf = ();
182     $sphinx_conf{'host'} = RT->Config->Get('DatabaseHost');
183     $sphinx_conf{'db'}   = RT->Config->Get('DatabaseName');
184     $sphinx_conf{'user'} = RT->Config->Get('DatabaseUser');
185     $sphinx_conf{'pass'} = RT->Config->Get('DatabasePassword');
186
187     print <<END
188
189 Below is a simple Sphinx configuration which can be used to index all
190 text/plain attachments in your database.  This configuration is not
191 ideal; you should read the Sphinx documentation to understand how to
192 configure it to better suit your needs.
193
194 source rt {
195     type            = mysql
196
197     sql_host        = $sphinx_conf{'host'}
198     sql_db          = $sphinx_conf{'db'}
199     sql_user        = $sphinx_conf{'user'}
200     sql_pass        = $sphinx_conf{'pass'}
201
202     sql_query_pre   = SET NAMES utf8
203     sql_query       = \\
204         SELECT a.id, a.content FROM Attachments a \\
205         JOIN Transactions txn ON a.TransactionId = txn.id AND txn.ObjectType = 'RT::Ticket' \\
206         JOIN Tickets t ON txn.ObjectId = t.id \\
207         WHERE a.ContentType = 'text/plain' AND t.Status != 'deleted'
208
209     sql_query_info  = SELECT * FROM Attachments WHERE id=\$id
210 }
211
212 index $index {
213     source                  = rt
214     path                    = $var_path/sphinx/index
215     docinfo                 = extern
216     charset_type            = utf-8
217 }
218
219 indexer {
220     mem_limit               = 32M
221 }
222
223 searchd {
224     port                    = $port
225     log                     = $var_path/sphinx/searchd.log
226     query_log               = $var_path/sphinx/query.log
227     read_timeout            = 5
228     max_children            = 30
229     pid_file                = $var_path/sphinx/searchd.pid
230     max_matches             = $maxmatches
231     seamless_rotate         = 1
232     preopen_indexes         = 0
233     unlink_old              = 1
234 }
235
236 END
237
238 }
239 elsif ( $DB{'type'} eq 'Pg' ) {
240     check_tsvalue();
241     my $table = $OPT{'table'} || prompt(
242         message => "Enter the name of a DB table that will be used to store the Pg tsvector.\n"
243                  . "You may either use the existing Attachments table, or create a new\n"
244                  . "table.",
245         default => $DEFAULT{'table'},
246         silent  => !$OPT{'ask'},
247     );
248     my $column = $OPT{'column'} || prompt(
249         message => 'Enter the name of a column that will be used to store the Pg tsvector:',
250         default => $DEFAULT{'column'},
251         silent  => !$OPT{'ask'},
252     );
253
254     my $schema;
255     my $drop;
256     if ( lc($table) eq 'attachments' ) {
257         $drop = "ALTER TABLE $table DROP COLUMN $column";
258         $schema = "ALTER TABLE $table ADD COLUMN $column tsvector";
259     } else {
260         $drop = "DROP TABLE $table";
261         $schema = "CREATE TABLE $table ( "
262             ."id INTEGER NOT NULL,"
263             ."$column tsvector )";
264     }
265
266     my $index_type = lc($OPT{'index-type'} || '');
267     while ( $index_type ne 'gist' and $index_type ne 'gin' ) {
268         $index_type = lc prompt(
269             message => "You may choose between GiST or GIN indexes; the former is several times\n"
270                      . "slower to search, but takes less space on disk and is faster to update.",
271             default => 'GiST',
272             silent  => !$OPT{'ask'},
273         );
274     }
275
276     do_error_is_ok( dba_handle() => $drop )
277         unless $OPT{'dryrun'};
278     insert_schema( $schema );
279     insert_schema("CREATE INDEX ${column}_idx ON $table USING $index_type($column)");
280
281     print_rt_config( Table => $table, Column => $column );
282 }
283 elsif ( $DB{'type'} eq 'Oracle' ) {
284     {
285         my $dbah = dba_handle();
286         do_print_error( $dbah => 'GRANT CTXAPP TO '. $DB{'user'} );
287         do_print_error( $dbah => 'GRANT EXECUTE ON CTXSYS.CTX_DDL TO '. $DB{'user'} );
288     }
289
290     my %PREFERENCES = (
291         datastore => {
292             type => 'DIRECT_DATASTORE',
293         },
294         filter => {
295             type => 'AUTO_FILTER',
296 #        attributes => {
297 #            timeout => 120, # seconds
298 #            timeout_type => 'HEURISTIC', # or 'FIXED'
299 #        },
300         },
301         lexer => {
302             type => 'WORLD_LEXER',
303         },
304         word_list => {
305             type => 'BASIC_WORDLIST',
306             attributes => {
307                 stemmer => 'AUTO',
308                 fuzzy_match => 'AUTO',
309 #            fuzzy_score => undef,
310 #            fuzzy_numresults => undef,
311 #            substring_index => undef,
312 #            prefix_index => undef,
313 #            prefix_length_min => undef,
314 #            prefix_length_max => undef,
315 #            wlidcard_maxterms => undef,
316             },
317         },
318         'section_group' => {
319             type => 'NULL_SECTION_GROUP',
320         },
321
322         storage => {
323             type => 'BASIC_STORAGE',
324             attributes => {
325                 R_TABLE_CLAUSE => 'lob (data) store as (cache)',
326                 I_INDEX_CLAUSE => 'compress 2',
327             },
328         },
329     );
330
331     my @params = ();
332     push @params, ora_create_datastore( %{ $PREFERENCES{'datastore'} } );
333     push @params, ora_create_filter( %{ $PREFERENCES{'filter'} } );
334     push @params, ora_create_lexer( %{ $PREFERENCES{'lexer'} } );
335     push @params, ora_create_word_list( %{ $PREFERENCES{'word_list'} } );
336     push @params, ora_create_stop_list();
337     push @params, ora_create_section_group( %{ $PREFERENCES{'section_group'} } );
338     push @params, ora_create_storage( %{ $PREFERENCES{'storage'} } );
339
340     my $index_params = join "\n", @params;
341     my $index_name = $DEFAULT{prefix} .'index';
342     do_error_is_ok( $dbh => "DROP INDEX $index_name" )
343         unless $OPT{'dryrun'};
344     $dbh->do(
345         "CREATE INDEX $index_name ON Attachments(Content)
346         indextype is ctxsys.context parameters('
347             $index_params
348         ')",
349     ) unless $OPT{'dryrun'};
350
351     print_rt_config( IndexName => $index_name );
352 }
353 else {
354     die "Full-text indexes on $DB{type} are not yet supported";
355 }
356
357 sub check_tsvalue {
358     my $dbh = $RT::Handle->dbh;
359     my $fts = ($dbh->selectrow_array(<<EOQ))[0];
360 SELECT 1 FROM information_schema.routines WHERE routine_name = 'plainto_tsquery'
361 EOQ
362     unless ($fts) {
363         print STDERR <<EOT;
364
365 Your PostgreSQL server does not include full-text support.  You will
366 need to upgrade to PostgreSQL version 8.3 or higher to use full-text
367 indexing.
368
369 EOT
370         exit 1;
371     }
372 }
373
374 sub check_sphinx {
375     return if $RT::Handle->CheckSphinxSE;
376
377     print STDERR <<EOT;
378
379 Your MySQL server has not been compiled with the Sphinx storage engine
380 (sphinxse).  You will need to recompile MySQL according to the
381 instructions in Sphinx's documentation at
382 http://sphinxsearch.com/docs/current.html#sphinxse-installing
383
384 EOT
385     exit 1;
386 }
387
388 sub ora_create_datastore {
389     return sprintf 'datastore %s', ora_create_preference(
390         @_,
391         name => 'datastore',
392     );
393 }
394
395 sub ora_create_filter {
396     my $res = '';
397     $res .= sprintf "format column %s\n", ora_create_format_column();
398     $res .= sprintf 'filter %s', ora_create_preference(
399         @_,
400         name => 'filter',
401     );
402     return $res;
403 }
404
405 sub ora_create_lexer {
406     return sprintf 'lexer %s', ora_create_preference(
407         @_,
408         name => 'lexer',
409     );
410 }
411
412 sub ora_create_word_list {
413     return sprintf 'wordlist %s', ora_create_preference(
414         @_,
415         name => 'word_list',
416     );
417 }
418
419 sub ora_create_stop_list {
420     my $file = shift || 'etc/stopwords/en.txt';
421     return '' unless -e $file;
422
423     my $name = $DEFAULT{'prefix'} .'stop_list';
424     unless ($OPT{'dryrun'}) {
425         do_error_is_ok( $dbh => 'begin ctx_ddl.drop_stoplist(?); end;', $name );
426
427         $dbh->do(
428             'begin ctx_ddl.create_stoplist(?, ?);  end;',
429             undef, $name, 'BASIC_STOPLIST'
430         );
431
432         open( my $fh, '<:utf8', $file )
433             or die "couldn't open file '$file': $!";
434         while ( my $word = <$fh> ) {
435             chomp $word;
436             $dbh->do(
437                 'begin ctx_ddl.add_stopword(?, ?); end;',
438                 undef, $name, $word
439             );
440         }
441         close $fh;
442     }
443     return sprintf 'stoplist %s', $name;
444 }
445
446 sub ora_create_section_group {
447     my %args = @_;
448     my $name = $DEFAULT{'prefix'} .'section_group';
449     unless ($OPT{'dryrun'}) {
450         do_error_is_ok( $dbh => 'begin ctx_ddl.drop_section_group(?); end;', $name );
451         $dbh->do(
452             'begin ctx_ddl.create_section_group(?, ?);  end;',
453             undef, $name, $args{'type'}
454         );
455     }
456     return sprintf 'section group %s', $name;
457 }
458
459 sub ora_create_storage {
460     return sprintf 'storage %s', ora_create_preference(
461         @_,
462         name => 'storage',
463     );
464 }
465
466 sub ora_create_format_column {
467     my $column_name = 'ContentOracleFormat';
468     return $column_name if $OPT{'dryrun'};
469     unless (
470         $dbh->column_info(
471             undef, undef, uc('Attachments'), uc( $column_name )
472         )->fetchrow_array
473     ) {
474         $dbh->do(qq{
475             ALTER TABLE Attachments ADD $column_name VARCHAR2(10)
476         });
477     }
478
479     my $detect_format = qq{
480         CREATE OR REPLACE FUNCTION $DEFAULT{prefix}detect_format_simple(
481             parent IN NUMBER,
482             type IN VARCHAR2,
483             encoding IN VARCHAR2,
484             fname IN VARCHAR2
485         )
486         RETURN VARCHAR2
487         AS
488             format VARCHAR2(10);
489         BEGIN
490             format := CASE
491     };
492     unless ( $OPT{'attachments'} ) {
493         $detect_format .= qq{
494                 WHEN fname IS NOT NULL THEN 'ignore'
495         };
496     }
497     $detect_format .= qq{
498                 WHEN type = 'text' THEN 'text'
499                 WHEN type = 'text/rtf' THEN 'ignore'
500                 WHEN type LIKE 'text/%' THEN 'text'
501                 WHEN type LIKE 'message/%' THEN 'text'
502                 ELSE 'ignore'
503             END;
504             RETURN format;
505         END;
506     };
507     ora_create_procedure( $detect_format );
508
509     $dbh->do(qq{
510         UPDATE Attachments
511         SET $column_name = $DEFAULT{prefix}detect_format_simple(
512             Parent,
513             ContentType, ContentEncoding,
514             Filename
515         )
516         WHERE $column_name IS NULL
517     });
518     $dbh->do(qq{
519         CREATE OR REPLACE TRIGGER $DEFAULT{prefix}set_format
520         BEFORE INSERT
521         ON Attachments
522         FOR EACH ROW
523         BEGIN
524             :new.$column_name := $DEFAULT{prefix}detect_format_simple(
525                 :new.Parent,
526                 :new.ContentType, :new.ContentEncoding,
527                 :new.Filename
528             );
529         END;
530     });
531     return $column_name;
532 }
533
534 sub ora_create_preference {
535     my %info = @_;
536     my $name = $DEFAULT{'prefix'} . $info{'name'};
537     return $name if $OPT{'dryrun'};
538     do_error_is_ok( $dbh => 'begin ctx_ddl.drop_preference(?); end;', $name );
539     $dbh->do(
540         'begin ctx_ddl.create_preference(?, ?);  end;',
541         undef, $name, $info{'type'}
542     );
543     return $name unless $info{'attributes'};
544
545     while ( my ($attr, $value) = each %{ $info{'attributes'} } ) {
546         $dbh->do(
547             'begin ctx_ddl.set_attribute(?, ?, ?);  end;',
548             undef, $name, $attr, $value
549         );
550     }
551
552     return $name;
553 }
554
555 sub ora_create_procedure {
556     my $text = shift;
557
558     return if $OPT{'dryrun'};
559     my $status = $dbh->do($text, { RaiseError => 0 });
560
561     # Statement succeeded
562     return if $status;
563
564     if ( 6550 != $dbh->err ) {
565         # Utter failure
566         die $dbh->errstr;
567     }
568     else {
569         my $msg = $dbh->func( 'plsql_errstr' );
570         die $dbh->errstr if !defined $msg;
571         die $msg if $msg;
572     }
573 }
574
575 sub dba_handle {
576     if ( $DB{'type'} eq 'Oracle' ) {
577         $ENV{'NLS_LANG'} = "AMERICAN_AMERICA.AL32UTF8";
578         $ENV{'NLS_NCHAR'} = "AL32UTF8";
579     }
580     my $dsn = do { my $h = new RT::Handle; $h->BuildDSN; $h->DSN };
581     my $dbh = DBI->connect(
582         $dsn, $DB{admin}, $DB{admin_password},
583         { RaiseError => 1, PrintError => 1 },
584     );
585     unless ( $dbh ) {
586         die "Failed to connect to $dsn as user '$DB{admin}': ". $DBI::errstr;
587     }
588     return $dbh;
589 }
590
591 sub do_error_is_ok {
592     my $dbh = shift;
593     local $dbh->{'RaiseError'} = 0;
594     local $dbh->{'PrintError'} = 0;
595     return $dbh->do(shift, undef, @_);
596 }
597
598 sub do_print_error {
599     my $dbh = shift;
600     local $dbh->{'RaiseError'} = 0;
601     local $dbh->{'PrintError'} = 1;
602     return $dbh->do(shift, undef, @_);
603 }
604
605 sub prompt {
606     my %args = ( @_ );
607     return $args{'default'} if $args{'silent'};
608
609     local $| = 1;
610     print $args{'message'};
611     if ( $args{'default'} ) {
612         print "\n[". $args{'default'} .']: ';
613     } else {
614         print ":\n";
615     }
616
617     my $res = <STDIN>;
618     chomp $res;
619     print "\n";
620     return $args{'default'} if !$res && $args{'default'};
621     return $res;
622 }
623
624 sub verbose  { print @_, "\n" if $OPT{verbose} || $OPT{verbose}; 1 }
625 sub debug    { print @_, "\n" if $OPT{debug}; 1 }
626 sub error    { $RT::Logger->error( @_ ); verbose(@_); 1 }
627 sub warning  { $RT::Logger->warning( @_ ); verbose(@_); 1 }
628
629 sub show_help {
630     my $error = shift;
631     RT::Interface::CLI->ShowHelp(
632         ExitValue => $error,
633         Sections => 'NAME|DESCRIPTION',
634     );
635 }
636
637 sub print_rt_config {
638     my %args = @_;
639     my $config = <<END;
640
641 You can now configure RT to use the newly-created full-text index by
642 adding the following to your RT_SiteConfig.pm:
643
644 Set( %FullTextSearch,
645     Enable     => 1,
646     Indexed    => 1,
647 END
648
649     $config .= sprintf("    %-10s => '$args{$_}',\n",$_)
650         foreach grep defined $args{$_}, keys %args;
651     $config .= ");\n";
652
653     print $config;
654 }
655
656 sub insert_schema {
657     my $dbh = dba_handle();
658     my $message = "Going to run the following in the DB:";
659     my $schema = shift;
660     print "$message\n";
661     my $disp = $schema;
662     $disp =~ s/^/    /mg;
663     print "$disp\n\n";
664     return if $OPT{'dryrun'};
665
666     my $res = $dbh->do( $schema );
667     unless ( $res ) {
668         die "Couldn't run DDL query: ". $dbh->errstr;
669     }
670 }
671
672 =head1 NAME
673
674 rt-setup-fulltext-index - Create indexes for full text search
675
676 =head1 DESCRIPTION
677
678 This script creates the appropriate tables, columns, functions, and / or
679 views necessary for full-text searching for your database type.  It will
680 drop any existing indexes in the process.
681
682 Please read F<docs/full_text_indexing.pod> for complete documentation on
683 full-text indexing for your database type.
684
685 If you have a non-standard database administrator user or password, you
686 may use the C<--dba> and C<--dba-password> parameters to set them
687 explicitly:
688
689     rt-setup-fulltext-index --dba sysdba --dba-password 'secret'
690
691 To test what will happen without running any DDL, pass the C<--dryrun>
692 flag.
693
694 The Oracle index determines which content-types it will index at
695 creation time. By default, textual message bodies and textual uploaded
696 attachments (attachments with filenames) are indexed; to ignore textual
697 attachments, pass the C<--no-attachments> flag when the index is
698 created.
699
700
701 =head1 AUTHOR
702
703 Ruslan Zakirov E<lt>ruz@bestpractical.comE<gt>,
704 Alex Vandiver E<lt>alexmv@bestpractical.comE<gt>
705
706 =cut
707