Bug 8092 follow-up: Add optional dependency on CHI
[koha.git] / C4 / SQLHelper.pm
index 7a6db71..e1cb5d0 100644 (file)
@@ -13,20 +13,37 @@ package C4::SQLHelper;
 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License along with
-# Koha; if not, write to the Free Software Foundation, Inc., 59 Temple Place,
-# Suite 330, Boston, MA  02111-1307 USA
+# You should have received a copy of the GNU General Public License along
+# with Koha; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
 
-use List::MoreUtils qw(first_value);
+use strict;
+use warnings;
+use List::MoreUtils qw(first_value any);
 use C4::Context;
 use C4::Dates qw(format_date_in_iso);
 use C4::Debug;
-use strict;
-use warnings;
 require Exporter;
 use vars qw($VERSION @ISA @EXPORT_OK %EXPORT_TAGS);
 
+eval {
+    my $servers = C4::Context->config('memcached_servers');
+    if ($servers) {
+        require Memoize::Memcached;
+        import Memoize::Memcached qw(memoize_memcached);
+
+        my $memcached = {
+            servers     => [$servers],
+            key_prefix  => C4::Context->config('memcached_namespace') || 'koha',
+            expire_time => 600
+        };    # cache for 10 mins
+
+        memoize_memcached( '_get_columns',   memcached => $memcached );
+        memoize_memcached( 'GetPrimaryKeys', memcached => $memcached );
+    }
+};
+
 BEGIN {
        # set the version for version checking
        $VERSION = 0.5;
@@ -34,16 +51,18 @@ BEGIN {
        @ISA    = qw(Exporter);
 @EXPORT_OK=qw(
        InsertInTable
+       DeleteInTable
        SearchInTable
        UpdateInTable
-       GetPrimaryKey
+       GetPrimaryKeys
+        clear_columns_cache
 );
-       %EXPORT_TAGS = ( all =>[qw( InsertInTable SearchInTable UpdateInTable GetPrimaryKey)]
+       %EXPORT_TAGS = ( all =>[qw( InsertInTable DeleteInTable SearchInTable UpdateInTable GetPrimaryKeys)]
                                );
 }
 
 my $tablename;
-my $hash;
+my $hashref;
 
 =head1 NAME
 
@@ -59,142 +78,202 @@ This module contains routines for adding, modifying and Searching Data in MysqlD
 
 =head1 FUNCTIONS
 
-=over 2
-
-=back
-
-
 =head2 SearchInTable
 
-=over 4
+  $hashref = &SearchInTable($tablename,$data, $orderby, $limit, 
+                      $columns_out, $filtercolumns, $searchtype);
 
-  $hashref = &SearchInTable($tablename,$data, $orderby);
 
-=back
+$tablename Name of the table (string)
 
 $data may contain 
        - string
+
        - data_hashref : will be considered as an AND of all the data searched
+
        - data_array_ref on hashrefs : Will be considered as an OR of Datahasref elements
 
-$orderby is a hashref with fieldnames as key and 0 or 1 as values (ASCENDING or DESCENDING order)
+$orderby is an arrayref of hashref with fieldnames as key and 0 or 1 as values (ASCENDING or DESCENDING order)
+
+$limit is an array ref on 2 values in order to limit results to MIN..MAX
+
+$columns_out is an array ref on field names is used to limit results on those fields (* by default)
+
+$filtercolums is an array ref on field names : is used to limit expansion of research for strings
+
+$searchtype is string Can be "start_with" or "exact" 
+
+This query builder is very limited, it should be replaced with DBIx::Class
+or similar  very soon
+Meanwhile adding support for special key '' in case of a data_hashref to
+support filters of type
+
+  ( f1 = a OR f2 = a ) AND fx = b AND fy = c
+
+Call for the query above is:
+
+  SearchInTable($tablename, {'' => a, fx => b, fy => c}, $orderby, $limit,
+                $columns_out, [f1, f2], 'exact');
+
+NOTE: Current implementation may remove parts of the iinput hashrefs. If that is a problem
+a copy needs to be created in _filter_fields() below
 
 =cut
 
 sub SearchInTable{
-    my ($tablename,$filters,$orderby) = @_; 
+    my ($tablename,$filters,$orderby, $limit, $columns_out, $filter_columns,$searchtype) = @_; 
+       $searchtype||="exact";
     my $dbh      = C4::Context->dbh; 
-    my $sql      = "SELECT * from $tablename"; 
+       $columns_out||=["*"];
+    my $sql      = do { local $"=', '; 
+                qq{ SELECT @$columns_out from $tablename} 
+               };
     my $row; 
     my $sth; 
-    my ($keys,$values)=_filter_fields($filters,$tablename, "search"); 
-    if ($filters) { 
-        $sql.= do { local $"=' AND '; 
-                qq{ WHERE @$keys } 
-               }; 
-    } 
+    my ($keys,$values)=_filter_fields($tablename,$filters,$searchtype,$filter_columns); 
+       if ($keys){
+               my @criteria=grep{defined($_) && $_ !~/^\W$/ }@$keys;
+               if (@criteria) { 
+                       $sql.= do { local $"=') OR ('; 
+                                       qq{ WHERE (@criteria) } 
+                                  }; 
+               } 
+       }
     if ($orderby){ 
-        my @orders=map{ "$_".($$orderby{$_}? " DESC" : "") } keys %$orderby; 
-        $sql.= do { local $"=', '; 
-                qq{ ORDER BY @orders} 
-               }; 
+               #Order by desc by default
+               my @orders;
+               foreach my $order ( ref($orderby) ? @$orderby : $orderby ){
+            if (ref $order) {
+                           push @orders,map{ "$_".($order->{$_}? " DESC " : "") } keys %$order; 
+            } else {
+                           push @orders,$order; 
+            }
+               }
+               $sql.= do { local $"=', '; 
+                               qq{ ORDER BY @orders} 
+        }; 
     } 
+       if ($limit){
+               $sql.=qq{ LIMIT }.join(",",@$limit);
+       }
      
-    $debug && warn $sql," ",join(",",@$values); 
-    $sth = $dbh->prepare($sql); 
-    $sth->execute(@$values); 
+    $debug && $values && warn $sql," ",join(",",@$values); 
+    $sth = $dbh->prepare_cached($sql); 
+    eval{$sth->execute(@$values)}; 
+       warn $@ if ($@ && $debug);
     my $results = $sth->fetchall_arrayref( {} ); 
     return $results;
 }
 
 =head2 InsertInTable
 
-=over 4
+  $data_id_in_table = &InsertInTable($tablename,$data_hashref,$withprimarykeys);
 
-  $data_id_in_table = &InsertInTable($tablename,$data_hashref);
+Insert Data in table and returns the id of the row inserted
 
-=back
-
-  Insert Data in table
-  and returns the id of the row inserted
 =cut
 
 sub InsertInTable{
-    my ($tablename,$data) = @_;
+    my ($tablename,$data,$withprimarykeys) = @_;
     my $dbh      = C4::Context->dbh;
-    my ($keys,$values)=_filter_fields($data,$tablename);
-
-    my $query = do { local $"=',';
-    qq{
-            INSERT $tablename
-            SET  @$keys
-        };
-    };
+    my ($keys,$values)=_filter_hash($tablename,$data,($withprimarykeys?"exact":0));
+    my $query = qq{ INSERT INTO $tablename SET  }.join(", ",@$keys);
 
        $debug && warn $query, join(",",@$values);
-    my $sth = $dbh->prepare($query);
-    $sth->execute( @$values);
+    my $sth = $dbh->prepare_cached($query);
+    eval{$sth->execute(@$values)}; 
+       warn $@ if ($@ && $debug);
 
        return $dbh->last_insert_id(undef, undef, $tablename, undef);
 }
 
 =head2 UpdateInTable
 
-=over 4
-
   $status = &UpdateInTable($tablename,$data_hashref);
 
-=back
+Update Data in table and returns the status of the operation
 
-  Update Data in table
-  and returns the status of the operation
 =cut
 
 sub UpdateInTable{
     my ($tablename,$data) = @_;
-       my $field_id=GetPrimaryKey($tablename);
-    my $id=$$data{$field_id};
+       my @field_ids=GetPrimaryKeys($tablename);
+    my @ids=@$data{@field_ids};
     my $dbh      = C4::Context->dbh;
-    my ($keys,$values)=_filter_fields($data,$tablename,0);
+    my ($keys,$values)=_filter_hash($tablename,$data,0);
+    return unless ($keys);
+    my $query = 
+    qq{     UPDATE $tablename
+            SET  }.join(",",@$keys).qq{
+            WHERE }.join (" AND ",map{ "$_=?" }@field_ids);
+       $debug && warn $query, join(",",@$values,@ids);
+
+    my $sth = $dbh->prepare_cached($query);
+       my $result;
+    eval{$result=$sth->execute(@$values,@ids)}; 
+       warn $@ if ($@ && $debug);
+    return $result;
+}
 
-    my $query = do { local $"=',';
-    qq{
-            UPDATE $tablename
-            SET  @$keys
-            WHERE  $field_id=?
-        };
-    };
-       $debug && warn $query, join(",",@$values,$id);
+=head2 DeleteInTable
 
-    my $sth = $dbh->prepare($query);
-    return $sth->execute( @$values,$id);
+  $status = &DeleteInTable($tablename,$data_hashref);
 
-}
+Delete Data in table and returns the status of the operation
 
-=head2 GetPrimaryKey
+=cut
 
-=over 4
+sub DeleteInTable{
+    my ($tablename,$data) = @_;
+    my $dbh      = C4::Context->dbh;
+    my ($keys,$values)=_filter_fields($tablename,$data,1);
+       if ($keys){
+               my $query = do { local $"=') AND (';
+               qq{ DELETE FROM $tablename WHERE (@$keys)};
+               };
+               $debug && warn $query, join(",",@$values);
+               my $sth = $dbh->prepare_cached($query);
+               my $result;
+       eval{$result=$sth->execute(@$values)}; 
+               warn $@ if ($@ && $debug);
+       return $result;
+       }
+}
 
-  $primarykeyname = &GetPrimaryKey($tablename)
+=head2 GetPrimaryKeys
 
-=back
+  @primarykeys = &GetPrimaryKeys($tablename)
+
+Get the Primary Key field names of the table
 
-       Get the Primary Key field name of the table
 =cut
 
-sub GetPrimaryKey($) {
+sub GetPrimaryKeys($) {
        my $tablename=shift;
-       my $hash_columns=_columns($tablename);
-       return  first_value { $$hash_columns{$_}{'Key'} =~/PRI/}  keys %$hash_columns;
+       my $hash_columns=_get_columns($tablename);
+       return  grep { $hash_columns->{$_}->{'Key'} =~/PRI/i}  keys %$hash_columns;
 }
 
-=head2 _get_columns
 
-=over 4
+=head2 clear_columns_cache
 
-_get_columns($tablename)
+  C4::SQLHelper->clear_columns_cache();
 
-=back
+cleans the internal cache of sysprefs. Please call this method if
+you update a tables structure. Otherwise, your new changes
+will not be seen by this process.
+
+=cut
+
+sub clear_columns_cache {
+    %$hashref = ();
+}
+
+
+
+=head2 _get_columns
+
+    _get_columns($tablename)
 
 Given a tablename 
 Returns a hashref of all the fieldnames of the table
@@ -205,27 +284,60 @@ With
 
 =cut
 
-sub _columns($) {
-       my $tablename=shift;
-       $debug && warn $tablename;
-       my $dbh=C4::Context->dbh;
-       my $sth=$dbh->prepare(qq{SHOW COLUMNS FROM $tablename });
-       $sth->execute;
-    return $sth->fetchall_hashref(qw(Field));
+sub _get_columns($) {
+    my ($tablename) = @_;
+    unless ( exists( $hashref->{$tablename} ) ) {
+        my $dbh = C4::Context->dbh;
+        my $sth = $dbh->prepare_cached(qq{SHOW COLUMNS FROM $tablename });
+        $sth->execute;
+        my $columns = $sth->fetchall_hashref(qw(Field));
+        $hashref->{$tablename} = $columns;
+    }
+    return $hashref->{$tablename};
 }
 
-=head2 _filter_fields
+=head2 _filter_columns
 
 =over 4
 
-_filter_fields
+_filter_columns($tablename,$research, $filtercolumns)
 
 =back
 
+Given 
+       - a tablename 
+       - indicator on purpose whether all fields should be returned or only non Primary keys
+       - array_ref to columns to limit to
+
+Returns an array of all the fieldnames of the table
+If it is not for research purpose, filter primary keys
+
+=cut
+
+sub _filter_columns ($$;$) {
+       my ($tablename,$research, $filtercolumns)=@_;
+       if ($filtercolumns){
+               return (@$filtercolumns);
+       }
+       else {
+               my $columns=_get_columns($tablename);
+               if ($research){
+                       return keys %$columns;
+               }
+               else {
+                       return grep {my $column=$_; any {$_ ne $column }GetPrimaryKeys($tablename) } keys %$columns;
+               }
+       }
+}
+=head2 _filter_fields
+
+  _filter_fields
+
 Given 
        - a tablename
-       - a hashref of data 
-       - an indicator on operation
+       - a string or a hashref (containing, fieldnames and datatofilter) or an arrayref to one of those elements
+       - an indicator of operation whether it is a wide research or a narrow one
+       - an array ref to columns to restrict string filter to.
 
 Returns a ref of key array to use in SQL functions
 and a ref to value array
@@ -233,48 +345,156 @@ and a ref to value array
 =cut
 
 sub _filter_fields{
-       my ($data_to_filter,$tablename,$research)=@_;
-       warn "$tablename research $research";
+       my ($tablename,$filter_input,$searchtype,$filtercolumns)=@_;
     my @keys; 
        my @values;
-       my $columns= _columns($tablename);
-       #Filter Primary Keys of table
-    my $elements=join "|",grep {$$columns{$_}{'Key'} ne "PRI"} keys %$columns;
-       if (ref($data_to_filter) eq "HASH"){
-               foreach my $field (grep {/\b($elements)\b/} keys %$data_to_filter){
-                       ## supposed to be a hash of simple values, hashes of arrays could be implemented
-                       $$data_to_filter{$field}=format_date_in_iso($$data_to_filter{$field}) if ($$columns{$field}{Type}=~/date/ && $$data_to_filter{$field} !~C4::Dates->regexp("iso"));
-                       my $strkeys= " $field = ? ";
-                       if ($field=~/code/ && $research){
-                               $strkeys="( $strkeys OR $field='' OR $field IS NULL) ";
-                       }
-                       push @values, $$data_to_filter{$field};
-                       push @keys, $strkeys;
+       if (ref($filter_input) eq "HASH"){
+               my ($keys, $values);
+        if (my $special = delete $filter_input->{''}) { # XXX destroyes '' key
+                   ($keys, $values) = _filter_fields($tablename,$special, $searchtype,$filtercolumns);
+        }
+               my ($hkeys, $hvalues) = _filter_hash($tablename,$filter_input, $searchtype);
+               if ($hkeys){
+            push @$keys, @$hkeys;
+            push @$values, @$hvalues;
+        }
+               if ($keys){
+                   my $stringkey="(".join (") AND (",@$keys).")";
+                   return [$stringkey],$values;
+               }
+               else {
+                   return ();
                }
-       } elsif (ref($data_to_filter) eq "ARRAY"){
-               foreach my $element (@$data_to_filter){
-                       my (@localkeys,@localvalues)=_filter_fields($element);
-                       push @keys, join(' AND ',@localkeys);
-                       push @values, @localvalues;
+       } elsif (ref($filter_input) eq "ARRAY"){
+               foreach my $element_data (@$filter_input){
+                       my ($localkeys,$localvalues)=_filter_fields($tablename,$element_data,$searchtype,$filtercolumns);
+                       if ($localkeys){
+                               @$localkeys=grep{defined($_) && $_ !~/^\W*$/}@$localkeys;
+                               my $string=do{ 
+                                                               local $"=") OR (";
+                                                               qq{(@$localkeys)}
+                                                       };
+                               push @keys, $string;
+                               push @values, @$localvalues;
+                       }
                }
        } 
        else{
-                       my @operands=split / /,$data_to_filter;
-                       foreach my $operand (@operands){
-                               my @localvalues=($operand,"\%$operand\%") ;
-                               foreach my $field (keys %$columns){
-                                       my $strkeys= " ( $field = ? OR $field LIKE ? )";
-                                       if ($field=~/code/){
-                                               $strkeys="( $strkeys OR $field='' OR $field IS NULL) ";
-                                       }
-                                       push @values, @localvalues;
-                                       push @keys, $strkeys;
-                               }
-                       }
+        $debug && warn "filterstring : $filter_input";
+               my ($keys, $values) = _filter_string($tablename,$filter_input, $searchtype,$filtercolumns);
+               if ($keys){
+               my $stringkey="(".join (") AND (",@$keys).")";
+               return [$stringkey],$values;
+               }
+               else {
+               return ();
+               }
        }
 
        return (\@keys,\@values);
 }
 
+sub _filter_hash{
+       my ($tablename,$filter_input, $searchtype)=@_;
+       my (@values, @keys);
+       my $columns= _get_columns($tablename);
+       my @columns_filtered= _filter_columns($tablename,$searchtype);
+       
+       #Filter Primary Keys of table
+    my $elements=join "|",@columns_filtered;
+       foreach my $field (grep {/\b($elements)\b/} keys %$filter_input){
+               ## supposed to be a hash of simple values, hashes of arrays could be implemented
+               $filter_input->{$field}=format_date_in_iso($filter_input->{$field})
+          if $columns->{$field}{Type}=~/date/ &&
+             $filter_input->{$field} !~C4::Dates->regexp("iso");
+               my ($tmpkeys, $localvalues)=_Process_Operands($filter_input->{$field},"$tablename.$field",$searchtype,$columns);
+               if (@$tmpkeys){
+                       push @values, @$localvalues;
+                       push @keys, @$tmpkeys;
+               }
+       }
+       if (@keys){
+               return (\@keys,\@values);
+       }
+       else {
+               return ();
+       }
+}
+
+sub _filter_string{
+       my ($tablename,$filter_input, $searchtype,$filtercolumns)=@_;
+       return () unless($filter_input);
+       my @operands=split /\s+/,$filter_input;
+
+    # An act of desperation
+    $searchtype = 'contain' if @operands > 1 && $searchtype =~ /start_with/o;
+
+       my @columns_filtered= _filter_columns($tablename,$searchtype,$filtercolumns);
+       my $columns= _get_columns($tablename);
+       my (@values,@keys);
+       foreach my $operand (@operands){
+               my @localkeys;
+               foreach my $field (@columns_filtered){
+                       my ($tmpkeys, $localvalues)=_Process_Operands($operand,"$tablename.$field",$searchtype,$columns);
+                       if ($tmpkeys){
+                               push @values,@$localvalues;
+                               push @localkeys,@$tmpkeys;
+                       }
+               }
+               my $sql= join (' OR ', @localkeys);
+               push @keys, $sql;
+       }
+
+       if (@keys){
+               return (\@keys,\@values);
+       }
+       else {
+               return ();
+       }
+}
+sub _Process_Operands{
+       my ($operand, $field, $searchtype,$columns)=@_;
+       my @values;
+       my @tmpkeys;
+       my @localkeys;
+
+    $operand = [$operand] unless ref $operand eq 'ARRAY';
+    foreach (@$operand) {
+           push @tmpkeys, " $field = ? ";
+           push @values, $_;
+    }
+       #By default, exact search
+       if (!$searchtype ||$searchtype eq "exact"){
+               return \@tmpkeys,\@values;
+       }
+       my $col_field=(index($field,".")>0?substr($field, index($field,".")+1):$field);
+       if ($field=~/(?<!zip)code|(?<!card)number/ && $searchtype ne "exact"){
+               push @tmpkeys,(" $field= '' ","$field IS NULL");
+       }
+       if ($columns->{$col_field}->{Type}=~/varchar|text/i){
+               my @localvaluesextended;
+               if ($searchtype eq "contain"){
+            foreach (@$operand) {
+                           push @tmpkeys,(" $field LIKE ? ");
+                           push @localvaluesextended,("\%$_\%") ;
+            }
+               }
+               if ($searchtype eq "field_start_with"){
+            foreach (@$operand) {
+                           push @tmpkeys,("$field LIKE ?");
+                           push @localvaluesextended, ("$_\%") ;
+            }
+               }
+               if ($searchtype eq "start_with"){
+            foreach (@$operand) {
+                           push @tmpkeys,("$field LIKE ?","$field LIKE ?");
+                           push @localvaluesextended, ("$_\%", " $_\%") ;
+            }
+               }
+               push @values,@localvaluesextended;
+       }
+       push @localkeys,qq{ (}.join(" OR ",@tmpkeys).qq{) };
+       return (\@localkeys,\@values);
+}
 1;