Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / Record.pm
index 43b9ca1..18198d8 100644 (file)
@@ -3,7 +3,7 @@ use base qw( Exporter );
 
 use strict;
 use vars qw( $AUTOLOAD
-             %virtual_fields_cache %fk_method_cache
+             %virtual_fields_cache %fk_method_cache $fk_table_cache
              $money_char $lat_lower $lon_upper
            );
 use Carp qw(carp cluck croak confess);
@@ -34,7 +34,7 @@ our @EXPORT_OK = qw(
   dbh fields hfields qsearch qsearchs dbdef jsearch
   str2time_sql str2time_sql_closing regexp_sql not_regexp_sql
   concat_sql group_concat_sql
-  midnight_sql
+  midnight_sql fk_methods_init
 );
 
 our $DEBUG = 0;
@@ -45,6 +45,8 @@ our $nowarn_classload = 0;
 our $no_update_diff = 0;
 our $no_history = 0;
 
+our $qsearch_qualify_columns = 1;
+
 our $no_check_foreign = 1; #well, not inefficiently in perl by default anymore
 
 my $rsa_module;
@@ -54,12 +56,18 @@ my $rsa_decrypt;
 
 our $conf = '';
 our $conf_encryption = '';
+our $conf_encryptionmodule = '';
+our $conf_encryptionpublickey = '';
+our $conf_encryptionprivatekey = '';
 FS::UID->install_callback( sub {
 
   eval "use FS::Conf;";
   die $@ if $@;
   $conf = FS::Conf->new; 
-  $conf_encryption = $conf->exists('encryption');
+  $conf_encryption           = $conf->exists('encryption');
+  $conf_encryptionmodule     = $conf->config('encryptionmodule');
+  $conf_encryptionpublickey  = join("\n",$conf->config('encryptionpublickey'));
+  $conf_encryptionprivatekey = join("\n",$conf->config('encryptionprivatekey'));
   $money_char = $conf->config('money_char') || '$';
   my $nw_coords = $conf->exists('geocode-require_nw_coordinates');
   $lat_lower = $nw_coords ? 1 : -90;
@@ -74,9 +82,7 @@ FS::UID->install_callback( sub {
     eval "sub PG_BYTEA { die 'guru meditation #9: calling PG_BYTEA when not running Pg?'; }";
   }
 
-  foreach my $table ( dbdef->tables ) {
-    $fk_method_cache{$table} = fk_methods($table);
-  }
+  #fk_methods_init();
 
 } );
 
@@ -397,10 +403,17 @@ sub qsearch {
     my @real_fields = grep exists($record->{$_}), real_fields($table);
 
     my $statement .= "SELECT $select FROM $stable";
-    $statement .= " $addl_from" if $addl_from;
+    my $alias_main;
+    if ( $addl_from ) {
+      $statement .= " $addl_from";
+      # detect aliasing of the main table
+      if ( $addl_from =~ /^\s*AS\s+(\w+)/i ) {
+        $alias_main = $1;
+      }
+    }
     if ( @real_fields ) {
       $statement .= ' WHERE '. join(' AND ',
-        get_real_fields($table, $record, \@real_fields));
+        get_real_fields($table, $record, \@real_fields, $alias_main));
     }
 
     $statement .= " $extra_sql" if defined($extra_sql);
@@ -487,6 +500,8 @@ sub qsearch {
 
   $sth->finish;
 
+  #below was refactored out to _from_hashref, this should use it at some point
+
   my @return;
   if ( eval 'scalar(@FS::'. $table. '::ISA);' ) {
     if ( eval 'FS::'. $table. '->can(\'new\')' eq \&new ) {
@@ -510,6 +525,7 @@ sub qsearch {
 
     # Check for encrypted fields and decrypt them.
    ## only in the local copy, not the cached object
+    no warnings 'deprecated'; # XXX silence the warning for now
     if ( $conf_encryption 
          && eval '@FS::'. $table . '::encrypted_fields' ) {
       foreach my $record (@return) {
@@ -743,72 +759,76 @@ sub _from_hashref {
   return @return;
 }
 
-## makes this easier to read
-
 sub get_real_fields {
   my $table = shift;
   my $record = shift;
   my $real_fields = shift;
+  my $alias_main = shift; # defaults to undef
+  $alias_main ||= $table;
 
-   ## this huge map was previously inline, just broke it out to help read the qsearch method, should be optimized for readability
-      return ( 
-      map {
+  ## could be optimized more for readability
+  return ( 
+    map {
 
       my $op = '=';
       my $column = $_;
+      my $table_column = $qsearch_qualify_columns ? "$alias_main.$column" : $column;
       my $type = dbdef->table($table)->column($column)->type;
       my $value = $record->{$column};
       $value = $value->{'value'} if ref($value);
-      if ( ref($record->{$_}) ) {
-        $op = $record->{$_}{'op'} if $record->{$_}{'op'};
+
+      if ( ref($record->{$column}) ) {
+        $op = $record->{$column}{'op'} if $record->{$column}{'op'};
         #$op = 'LIKE' if $op =~ /^ILIKE$/i && driver_name ne 'Pg';
         if ( uc($op) eq 'ILIKE' ) {
           $op = 'LIKE';
-          $record->{$_}{'value'} = lc($record->{$_}{'value'});
-          $column = "LOWER($_)";
+          $record->{$column}{'value'} = lc($record->{$column}{'value'});
+          $table_column = "LOWER($table_column)";
         }
-        $record->{$_} = $record->{$_}{'value'}
+        $record->{$column} = $record->{$column}{'value'}
       }
 
-      if ( ! defined( $record->{$_} ) || $record->{$_} eq '' ) {
+      if ( ! defined( $record->{$column} ) || $record->{$column} eq '' ) {
         if ( $op eq '=' ) {
           if ( driver_name eq 'Pg' ) {
             if ( $type =~ /(int|numeric|real|float4|(big)?serial)/i ) {
-              qq-( $column IS NULL )-;
+              qq-( $table_column IS NULL )-;
             } else {
-              qq-( $column IS NULL OR $column = '' )-;
+              qq-( $table_column IS NULL OR $table_column = '' )-;
             }
           } else {
-            qq-( $column IS NULL OR $column = "" )-;
+            qq-( $table_column IS NULL OR $table_column = "" )-;
           }
         } elsif ( $op eq '!=' ) {
           if ( driver_name eq 'Pg' ) {
             if ( $type =~ /(int|numeric|real|float4|(big)?serial)/i ) {
-              qq-( $column IS NOT NULL )-;
+              qq-( $table_column IS NOT NULL )-;
             } else {
-              qq-( $column IS NOT NULL AND $column != '' )-;
+              qq-( $table_column IS NOT NULL AND $table_column != '' )-;
             }
           } else {
-            qq-( $column IS NOT NULL AND $column != "" )-;
+            qq-( $table_column IS NOT NULL AND $table_column != "" )-;
           }
         } else {
           if ( driver_name eq 'Pg' ) {
-            qq-( $column $op '' )-;
+            qq-( $table_column $op '' )-;
           } else {
-            qq-( $column $op "" )-;
+            qq-( $table_column $op "" )-;
           }
         }
       } elsif ( $op eq '!=' ) {
-        qq-( $column IS NULL OR $column != ? )-;
+        qq-( $table_column IS NULL OR $table_column != ? )-;
       #if this needs to be re-enabled, it needs to use a custom op like
       #"APPROX=" or something (better name?, not '=', to avoid affecting other
       # searches
       #} elsif ( $op eq 'APPROX=' && _is_fs_float( $type, $value ) ) {
-      #  ( "$column <= ?", "$column >= ?" );
+      #  ( "$table_column <= ?", "$table_column >= ?" );
       } else {
-        "$column $op ?";
+        "$table_column $op ?";
       }
-    } @{ $real_fields } );  
+
+    } @{ $real_fields }
+  );  
 }
 
 =item by_key PRIMARY_KEY_VALUE
@@ -872,6 +892,7 @@ sub qsearchs { # $result_record = &FS::Record:qsearchs('table',\%hash);
   my $table = $_[0];
   my(@result) = qsearch(@_);
   cluck "warning: Multiple records in scalar search ($table)"
+        #.join(' / ', map "$_=>".$_[1]->{$_}, keys %{ $_[1] } )
     if scalar(@result) > 1;
   #should warn more vehemently if the search was on a primary key?
   scalar(@result) ? ($result[0]) : ();
@@ -965,7 +986,7 @@ sub exists {
   exists($self->{Hash}->{$field});
 }
 
-=item AUTLOADED METHODS
+=item AUTOLOADED METHODS
 
 $record->column is a synonym for $record->get('column');
 
@@ -987,10 +1008,8 @@ sub AUTOLOAD {
   confess "errant AUTOLOAD $field for $self (arg $value)"
     unless blessed($self) && $self->can('setfield');
 
-  #$fk_method_cache{$self->table} ||= fk_methods($self->table);
-  if ( exists($fk_method_cache{$self->table}->{$field}) ) {
+  if ( my $fk_info = get_fk_method($self->table, $field) ) {
 
-    my $fk_info = $fk_method_cache{$self->table}->{$field};
     my $method = $fk_info->{method} || 'qsearchs';
     my $table = $fk_info->{table} || $field;
     my $column = $fk_info->{column};
@@ -1033,6 +1052,37 @@ sub AUTOLOAD {
 #  }    
 #}
 
+# get_fk_method(TABLE, FIELD)
+# Internal subroutine for fetching the foreign key descriptor for TABLE.FIELD
+# if there is one. If not, returns undef.
+# This will initialize fk_method_cache if it hasn't happened yet. It is the
+# _only_ allowed way to access the contents of %fk_method_cache.
+
+# if we wanted to be even more efficient we'd create the fk methods in the
+# symbol table instead of relying on AUTOLOAD every time
+
+sub get_fk_method {
+  my ($table, $field) = @_;
+
+  # maybe should only load one table at a time?
+  fk_methods_init() unless exists($fk_method_cache{$table});
+
+  if ( exists($fk_method_cache{$table}) and
+       exists($fk_method_cache{$table}{$field}) ) {
+    return $fk_method_cache{$table}{$field};
+  } else {
+    return undef;
+  }
+
+}
+
+sub fk_methods_init {
+  warn "[fk_methods_init]\n";
+  foreach my $table ( dbdef->tables ) {
+    $fk_method_cache{$table} = fk_methods($table);
+  }
+}
+
 sub fk_methods {
   my $table = shift;
 
@@ -1070,11 +1120,15 @@ sub fk_methods {
   #  (alas.  why we're cached.  still, might this loop better be done once at
   #   schema load time insetad of every time we AUTOLOAD a method on a new
   #   class?)
-  foreach my $f_table ( dbdef->tables ) {
-    foreach my $fk (dbdef->table($f_table)->foreign_keys) {
-
-      next unless $fk->table eq $table;
-
+  if (! defined $fk_table_cache) {
+    foreach my $f_table ( dbdef->tables ) {
+      foreach my $fk (dbdef->table($f_table)->foreign_keys) {
+        push @{$fk_table_cache->{$fk->table}},[$f_table,$fk];
+      }
+    }
+  }
+  foreach my $fks (@{$fk_table_cache->{$table}}) {
+      my ($f_table,$fk) = @$fks;
       my $method = '';
       if ( scalar( @{$fk->columns} ) == 1 ) {
         if (    ! defined($fk->references)
@@ -1097,9 +1151,6 @@ sub fk_methods {
         }
 
       }
-
-    }
-
   }
 
   \%hash;
@@ -1277,9 +1328,8 @@ sub insert {
   my $table = $self->table;
   
   # Encrypt before the database
-  if (    defined(eval '@FS::'. $table . '::encrypted_fields')
-       && scalar( eval '@FS::'. $table . '::encrypted_fields')
-       && $conf->exists('encryption')
+  if (    scalar( eval '@FS::'. $table . '::encrypted_fields')
+       && $conf_encryption
   ) {
     foreach my $field (eval '@FS::'. $table . '::encrypted_fields') {
       next if $field eq 'payinfo' 
@@ -1520,9 +1570,8 @@ sub replace {
   
   # Encrypt for replace
   my $saved = {};
-  if (    $conf->exists('encryption')
-       && defined(eval '@FS::'. $new->table . '::encrypted_fields')
-       && scalar( eval '@FS::'. $new->table . '::encrypted_fields')
+  if (    scalar( eval '@FS::'. $new->table . '::encrypted_fields')
+       && $conf_encryption
   ) {
     foreach my $field (eval '@FS::'. $new->table . '::encrypted_fields') {
       next if $field eq 'payinfo' 
@@ -1711,7 +1760,7 @@ Table name (required).
 
 =item params
 
-Listref of field names for static fields.  They will be given values from the
+Arrayref of field names for static fields.  They will be given values from the
 PARAMS hashref and passed as a "params" hashref to batch_import.
 
 =item formats
@@ -1760,10 +1809,10 @@ format_types).
 
 =back
 
-PARAMS is a base64-encoded Storable string containing the POSTed data as
-a hash ref.  It normally contains at least one field, "uploaded files",
-generated by /elements/file-upload.html and containing the list of uploaded
-files.  Currently only supports a single file named "file".
+PARAMS is a hashref (or base64-encoded Storable hashref) containing the 
+POSTed data.  It must contain the field "uploaded files", generated by 
+/elements/file-upload.html and containing the list of uploaded files.
+Currently only supports a single file named "file".
 
 =cut
 
@@ -1796,6 +1845,7 @@ sub process_batch_import {
     format_xml_formats         => $opt->{format_xml_formats},
     format_asn_formats         => $opt->{format_asn_formats},
     format_row_callbacks       => $opt->{format_row_callbacks},
+    format_hash_callbacks      => $opt->{format_hash_callbacks},
     #per-import
     job                        => $job,
     file                       => $file,
@@ -1804,7 +1854,9 @@ sub process_batch_import {
     params                     => { map { $_ => $param->{$_} } @pass_params },
     #?
     default_csv                => $opt->{default_csv},
+    preinsert_callback         => $opt->{preinsert_callback},
     postinsert_callback        => $opt->{postinsert_callback},
+    insert_args_callback       => $opt->{insert_args_callback},
   );
 
   if ( $opt->{'batch_namecol'} ) {
@@ -1841,6 +1893,8 @@ Class method for batch imports.  Available params:
 
 =item format_row_callbacks
 
+=item format_hash_callbacks - After parsing, before object creation
+
 =item fields - Alternate way to specify import, specifying import fields directly as a listref
 
 =item preinsert_callback
@@ -1883,7 +1937,7 @@ sub batch_import {
 
   my( $type, $header, $sep_char,
       $fixedlength_format, $xml_format, $asn_format,
-      $parser_opt, $row_callback, @fields );
+      $parser_opt, $row_callback, $hash_callback, @fields );
 
   my $postinsert_callback = '';
   $postinsert_callback = $param->{'postinsert_callback'}
@@ -1891,6 +1945,9 @@ sub batch_import {
   my $preinsert_callback = '';
   $preinsert_callback = $param->{'preinsert_callback'}
          if $param->{'preinsert_callback'};
+  my $insert_args_callback = '';
+  $insert_args_callback = $param->{'insert_args_callback'}
+         if $param->{'insert_args_callback'};
 
   if ( $param->{'format'} ) {
 
@@ -1936,6 +1993,11 @@ sub batch_import {
         ? $param->{'format_row_callbacks'}{ $param->{'format'} }
         : '';
 
+    $hash_callback =
+      $param->{'format_hash_callbacks'}
+        ? $param->{'format_hash_callbacks'}{ $param->{'format'} }
+        : '';
+
     @fields = @{ $formats->{ $format } };
 
   } elsif ( $param->{'fields'} ) {
@@ -1945,6 +2007,7 @@ sub batch_import {
     $sep_char = ',';
     $fixedlength_format = '';
     $row_callback = '';
+    $hash_callback = '';
     @fields = @{ $param->{'fields'} };
 
   } else {
@@ -2170,6 +2233,8 @@ sub batch_import {
       $hash{custnum} = $2;
     }
 
+    %hash = &{$hash_callback}(%hash) if $hash_callback;
+
     #my $table   = $param->{table};
     my $class = "FS::$table";
 
@@ -2200,7 +2265,12 @@ sub batch_import {
       next if exists $param->{skiprow} && $param->{skiprow};
     }
 
-    my $error = $record->insert;
+    my @insert_args = ();
+    if ( $insert_args_callback ) {
+      @insert_args = &{$insert_args_callback}($record, $param);
+    }
+
+    my $error = $record->insert(@insert_args);
 
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -2250,7 +2320,7 @@ sub _h_statement {
   ;
 
   # If we're encrypting then don't store the payinfo in the history
-  if ( $conf && $conf->exists('encryption') && $self->table ne 'banned_pay' ) {
+  if ( $conf_encryption && $self->table ne 'banned_pay' ) {
     @fields = grep { $_ ne 'payinfo' } @fields;
   }
 
@@ -2933,7 +3003,6 @@ May not be null.
 
 sub ut_name {
   my( $self, $field ) = @_;
-#  warn "ut_name allowed alphanumerics: +(sort grep /\w/, map { chr() } 0..255), "\n";
   $self->getfield($field) =~ /^([\p{Word} \,\.\-\']+)$/
     or return gettext('illegal_name'). " $field: ". $self->getfield($field);
   my $name = $1;
@@ -3093,7 +3162,7 @@ on the column first.
 
 sub ut_foreign_key {
   my( $self, $field, $table, $foreign ) = @_;
-  return '' if $no_check_foreign;
+  return $self->ut_number($field) if $no_check_foreign;
   qsearchs($table, { $foreign => $self->getfield($field) })
     or return "Can't find ". $self->table. ".$field ". $self->getfield($field).
               " in $table.$foreign";
@@ -3181,7 +3250,7 @@ sub encrypt {
   my ($self, $value) = @_;
   my $encrypted = $value;
 
-  if ($conf->exists('encryption')) {
+  if ($conf_encryption) {
     if ($self->is_encrypted($value)) {
       # Return the original value if it isn't plaintext.
       $encrypted = $value;
@@ -3224,7 +3293,7 @@ You should generally not have to worry about calling this, as the system handles
 sub decrypt {
   my ($self,$value) = @_;
   my $decrypted = $value; # Will return the original value if it isn't encrypted or can't be decrypted.
-  if ($conf->exists('encryption') && $self->is_encrypted($value)) {
+  if ($conf_encryption && $self->is_encrypted($value)) {
     $self->loadRSA;
     if (ref($rsa_decrypt) =~ /::RSA/) {
       my $encrypted = unpack ("u*", $value);
@@ -3240,8 +3309,8 @@ sub loadRSA {
     #Initialize the Module
     $rsa_module = 'Crypt::OpenSSL::RSA'; # The Default
 
-    if ($conf->exists('encryptionmodule') && $conf->config('encryptionmodule') ne '') {
-      $rsa_module = $conf->config('encryptionmodule');
+    if ($conf_encryptionmodule && $conf_encryptionmodule ne '') {
+      $rsa_module = $conf_encryptionmodule;
     }
 
     if (!$rsa_loaded) {
@@ -3249,15 +3318,13 @@ sub loadRSA {
        $rsa_loaded++;
     }
     # Initialize Encryption
-    if ($conf->exists('encryptionpublickey') && $conf->config('encryptionpublickey') ne '') {
-      my $public_key = join("\n",$conf->config('encryptionpublickey'));
-      $rsa_encrypt = $rsa_module->new_public_key($public_key);
+    if ($conf_encryptionpublickey && $conf_encryptionpublickey ne '') {
+      $rsa_encrypt = $rsa_module->new_public_key($conf_encryptionpublickey);
     }
     
     # Intitalize Decryption
-    if ($conf->exists('encryptionprivatekey') && $conf->config('encryptionprivatekey') ne '') {
-      my $private_key = join("\n",$conf->config('encryptionprivatekey'));
-      $rsa_decrypt = $rsa_module->new_private_key($private_key);
+    if ($conf_encryptionprivatekey && $conf_encryptionprivatekey ne '') {
+      $rsa_decrypt = $rsa_module->new_private_key($conf_encryptionprivatekey);
     }
 }
 
@@ -3336,6 +3403,22 @@ sub count {
   $self->scalar_sql($sql, @_);
 }
 
+=item row_exists [ WHERE [, PLACEHOLDER ...] ]
+
+Convenience method for the common case of "SELECT 1 FROM table ... LIMIT 1"
+with optional (but almost always needed) WHERE.
+
+=cut
+
+sub row_exists {
+  my($self, $where) = (shift, shift);
+  my $table = $self->table or die 'row_exists called on object of class '.ref($self);
+  my $sql = "SELECT 1 FROM $table";
+  $sql .= " WHERE $where" if $where;
+  $sql .= " LIMIT 1";
+  $self->scalar_sql($sql, @_);
+}
+
 =back
 
 =head1 SUBROUTINES