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