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