package FS::Record;
+use base qw( Exporter );
use strict;
-use vars qw( $AUTOLOAD @ISA @EXPORT_OK $DEBUG
- %virtual_fields_cache
- $conf $conf_encryption $money_char $lat_lower $lon_upper
- $me
- $nowarn_identical $nowarn_classload
- $no_update_diff $no_check_foreign
- @encrypt_payby
+use charnames ':full';
+use vars qw( $AUTOLOAD
+ %virtual_fields_cache %fk_method_cache $fk_table_cache
+ $money_char $lat_lower $lon_upper
);
-use Exporter;
use Carp qw(carp cluck croak confess);
use Scalar::Util qw( blessed );
+use File::Slurp qw( slurp );
use File::CounterFile;
-use Locale::Country;
use Text::CSV_XS;
-use File::Slurp qw( slurp );
use DBI qw(:sql_types);
-use DBIx::DBSchema 0.38;
-use FS::UID qw(dbh getotaker datasrc driver_name);
+use DBIx::DBSchema 0.43; #0.43 for foreign keys
+use Locale::Country;
+use Locale::Currency;
+use NetAddr::IP; # for validation
+use FS::UID qw(dbh datasrc driver_name);
use FS::CurrentUser;
use FS::Schema qw(dbdef);
use FS::SearchCache;
use FS::Msgcat qw(gettext);
-use NetAddr::IP; # for validation
-use Data::Dumper;
#use FS::Conf; #dependency loop bs, in install_callback below instead
use FS::part_virtual_field;
use Tie::IxHash;
-@ISA = qw(Exporter);
-
-@encrypt_payby = qw( CARD DCRD CHEK DCHK );
+our @encrypt_payby = qw( CARD DCRD CHEK DCHK );
#export dbdef for now... everything else expects to find it here
-@EXPORT_OK = qw(
+our @EXPORT_OK = qw(
dbh fields hfields qsearch qsearchs dbdef jsearch
- str2time_sql str2time_sql_closing regexp_sql not_regexp_sql concat_sql
- midnight_sql
+ str2time_sql str2time_sql_closing regexp_sql not_regexp_sql
+ concat_sql group_concat_sql
+ midnight_sql fk_methods_init
);
-$DEBUG = 0;
-$me = '[FS::Record]';
+our $DEBUG = 0;
+our $me = '[FS::Record]';
+
+our $nowarn_identical = 0;
+our $nowarn_classload = 0;
+our $no_update_diff = 0;
+our $no_history = 0;
-$nowarn_identical = 0;
-$nowarn_classload = 0;
-$no_update_diff = 0;
-$no_check_foreign = 0;
+our $qsearch_qualify_columns = 1;
+
+our $no_check_foreign = 1; #well, not inefficiently in perl by default anymore
my $rsa_module;
my $rsa_loaded;
my $rsa_encrypt;
my $rsa_decrypt;
-$conf = '';
-$conf_encryption = '';
+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;
eval "sub PG_BYTEA { die 'guru meditation #9: calling PG_BYTEA when not running Pg?'; }";
}
+ #fk_methods_init();
+
} );
=head1 NAME
$error = $record->ut_floatn('column');
$error = $record->ut_number('column');
$error = $record->ut_numbern('column');
+ $error = $record->ut_decimal('column');
+ $error = $record->ut_decimaln('column');
$error = $record->ut_snumber('column');
$error = $record->ut_snumbern('column');
$error = $record->ut_money('column');
$self->{'modified'} = 0;
+ $self->_simplecache($self->{'Hash'}) if $self->can('_simplecache');
$self->_cache($self->{'Hash'}, shift) if $self->can('_cache') && @_;
$self;
my @bind_type = ();
my $dbh = dbh;
foreach my $stable ( @stable ) {
+
+ carp '->qsearch on cust_main called' if $stable eq 'cust_main' && $DEBUG;
+
#stop altering the caller's hashref
my $record = { %{ shift(@record) || {} } };#and be liberal in receipt
my $select = shift @select;
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);
push @statement, $statement;
warn "[debug]$me $statement\n" if $DEBUG > 1 || $debug;
-
foreach my $field (
grep defined( $record->{$_} ) && $record->{$_} ne '', @real_fields
# grep defined( $record->{$_} ) && $record->{$_} ne '', @fields
# ) or croak "Error executing \"$statement\": ". $sth->errstr;
- $sth->execute or croak "Error executing \"$statement\": ". $sth->errstr;
+ my $ok = $sth->execute;
+ if (!$ok) {
+ my $error = "Error executing \"$statement\"";
+ $error .= ' (' . join(', ', map {"'$_'"} @value) . ')' if @value;
+ $error .= ': '. $sth->errstr;
+ croak $error;
+ }
my $table = $stable[0];
my $pkey = '';
$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 ) {
# 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 'defined(@FS::'. $table . '::encrypted_fields)' ) {
+ && eval '@FS::'. $table . '::encrypted_fields' ) {
foreach my $record (@return) {
foreach my $field (eval '@FS::'. $table . '::encrypted_fields') {
next if $field eq 'payinfo'
return @return;
}
-## makes this easier to read
+=item _query
+
+Construct the SQL statement and parameter-binding list for qsearch. Takes
+the qsearch parameters.
+
+Returns a hash containing:
+'table': The primary table name (if there is one).
+'statement': The SQL statement itself.
+'bind_type': An arrayref of bind types.
+'value': An arrayref of parameter values.
+'cache': The cache object, if one was passed.
+
+=cut
+
+sub _query {
+ my( @stable, @record, @cache );
+ my( @select, @extra_sql, @extra_param, @order_by, @addl_from );
+ my @debug = ();
+ my $cursor = '';
+ my %union_options = ();
+ if ( ref($_[0]) eq 'ARRAY' ) {
+ my $optlist = shift;
+ %union_options = @_;
+ foreach my $href ( @$optlist ) {
+ push @stable, ( $href->{'table'} or die "table name is required" );
+ push @record, ( $href->{'hashref'} || {} );
+ push @select, ( $href->{'select'} || '*' );
+ push @extra_sql, ( $href->{'extra_sql'} || '' );
+ push @extra_param, ( $href->{'extra_param'} || [] );
+ push @order_by, ( $href->{'order_by'} || '' );
+ push @cache, ( $href->{'cache_obj'} || '' );
+ push @addl_from, ( $href->{'addl_from'} || '' );
+ push @debug, ( $href->{'debug'} || '' );
+ }
+ die "at least one hashref is required" unless scalar(@stable);
+ } elsif ( ref($_[0]) eq 'HASH' ) {
+ my $opt = shift;
+ $stable[0] = $opt->{'table'} or die "table name is required";
+ $record[0] = $opt->{'hashref'} || {};
+ $select[0] = $opt->{'select'} || '*';
+ $extra_sql[0] = $opt->{'extra_sql'} || '';
+ $extra_param[0] = $opt->{'extra_param'} || [];
+ $order_by[0] = $opt->{'order_by'} || '';
+ $cache[0] = $opt->{'cache_obj'} || '';
+ $addl_from[0] = $opt->{'addl_from'} || '';
+ $debug[0] = $opt->{'debug'} || '';
+ } else {
+ ( $stable[0],
+ $record[0],
+ $select[0],
+ $extra_sql[0],
+ $cache[0],
+ $addl_from[0]
+ ) = @_;
+ $select[0] ||= '*';
+ }
+ my $cache = $cache[0];
+
+ my @statement = ();
+ my @value = ();
+ my @bind_type = ();
+
+ my $result_table = $stable[0];
+ foreach my $stable ( @stable ) {
+ #stop altering the caller's hashref
+ my $record = { %{ shift(@record) || {} } };#and be liberal in receipt
+ my $select = shift @select;
+ my $extra_sql = shift @extra_sql;
+ my $extra_param = shift @extra_param;
+ my $order_by = shift @order_by;
+ my $cache = shift @cache;
+ my $addl_from = shift @addl_from;
+ my $debug = shift @debug;
+
+ #$stable =~ /^([\w\_]+)$/ or die "Illegal table: $table";
+ #for jsearch
+ $stable =~ /^([\w\s\(\)\.\,\=]+)$/ or die "Illegal table: $stable";
+ $stable = $1;
+
+ $result_table = '' if $result_table ne $stable;
+
+ my $table = $cache ? $cache->table : $stable;
+ my $dbdef_table = dbdef->table($table)
+ or die "No schema for table $table found - ".
+ "do you need to run freeside-upgrade?";
+ my $pkey = $dbdef_table->primary_key;
+
+ my @real_fields = grep exists($record->{$_}), real_fields($table);
+
+ my $statement .= "SELECT $select FROM $stable";
+ $statement .= " $addl_from" if $addl_from;
+ if ( @real_fields ) {
+ $statement .= ' WHERE '. join(' AND ',
+ get_real_fields($table, $record, \@real_fields));
+ }
+
+ $statement .= " $extra_sql" if defined($extra_sql);
+ $statement .= " $order_by" if defined($order_by);
+
+ push @statement, $statement;
+
+ warn "[debug]$me $statement\n" if $DEBUG > 1 || $debug;
+
+
+ foreach my $field (
+ grep defined( $record->{$_} ) && $record->{$_} ne '', @real_fields
+ ) {
+
+ my $value = $record->{$field};
+ my $op = (ref($value) && $value->{op}) ? $value->{op} : '=';
+ $value = $value->{'value'} if ref($value);
+ my $type = dbdef->table($table)->column($field)->type;
+
+ my $bind_type = _bind_type($type, $value);
+
+ #if ( $DEBUG > 2 ) {
+ # no strict 'refs';
+ # %TYPE = map { &{"DBI::$_"}() => $_ } @{ $DBI::EXPORT_TAGS{sql_types} }
+ # unless keys %TYPE;
+ # warn " bind_param $bind (for field $field), $value, TYPE $TYPE{$TYPE}\n";
+ #}
+
+ push @value, $value;
+ push @bind_type, $bind_type;
+
+ }
+
+ foreach my $param ( @$extra_param ) {
+ my $bind_type = { TYPE => SQL_VARCHAR };
+ my $value = $param;
+ if ( ref($param) ) {
+ $value = $param->[0];
+ my $type = $param->[1];
+ $bind_type = _bind_type($type, $value);
+ }
+ push @value, $value;
+ push @bind_type, $bind_type;
+ }
+ }
+
+ my $statement = join( ' ) UNION ( ', @statement );
+ $statement = "( $statement )" if scalar(@statement) > 1;
+ $statement .= " $union_options{order_by}" if $union_options{order_by};
+
+ return {
+ statement => $statement,
+ bind_type => \@bind_type,
+ value => \@value,
+ table => $result_table,
+ cache => $cache,
+ };
+}
+
+# qsearch should eventually use this
+sub _from_hashref {
+ my ($table, $cache, @hashrefs) = @_;
+ my @return;
+ # XXX get rid of these string evals at some point
+ # (when we have time to test it)
+ # my $class = "FS::$table" if $table;
+ # if ( $class and $class->isa('FS::Record') )
+ # if ( $class->can('new') eq \&new )
+ #
+ if ( $table && eval 'scalar(@FS::'. $table. '::ISA);' ) {
+ if ( eval 'FS::'. $table. '->can(\'new\')' eq \&new ) {
+ #derivied class didn't override new method, so this optimization is safe
+ if ( $cache ) {
+ @return = map {
+ new_or_cached( "FS::$table", { %{$_} }, $cache )
+ } @hashrefs;
+ } else {
+ @return = map {
+ new( "FS::$table", { %{$_} } )
+ } @hashrefs;
+ }
+ } else {
+ #okay, its been tested
+ # warn "untested code (class FS::$table uses custom new method)";
+ @return = map {
+ eval 'FS::'. $table. '->new( { %{$_} } )';
+ } @hashrefs;
+ }
+
+ # Check for encrypted fields and decrypt them.
+ ## only in the local copy, not the cached object
+ if ( $conf_encryption
+ && eval '@FS::'. $table . '::encrypted_fields' ) {
+ foreach my $record (@return) {
+ foreach my $field (eval '@FS::'. $table . '::encrypted_fields') {
+ next if $field eq 'payinfo'
+ && ($record->isa('FS::payinfo_transaction_Mixin')
+ || $record->isa('FS::payinfo_Mixin') )
+ && $record->payby
+ && !grep { $record->payby eq $_ } @encrypt_payby;
+ # Set it directly... This may cause a problem in the future...
+ $record->setfield($field, $record->decrypt($record->getfield($field)));
+ }
+ }
+ }
+ } else {
+ cluck "warning: FS::$table not loaded; returning FS::Record objects"
+ unless $nowarn_classload;
+ @return = map {
+ FS::Record->new( $table, { %{$_} } );
+ } @hashrefs;
+ }
+ return @return;
+}
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
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]) : ();
exists($self->{Hash}->{$field});
}
-=item AUTLOADED METHODS
+=item AUTOLOADED METHODS
$record->column is a synonym for $record->get('column');
$record->column('value') is a synonym for $record->set('column','value');
+$record->foreign_table_name calls qsearchs and returns a single
+FS::foreign_table record (for tables referenced by a column of this table) or
+qsearch and returns an array of FS::foreign_table records (for tables
+referenced by a column in the foreign table).
+
=cut
# readable/safe
my($self,$value)=@_;
my($field)=$AUTOLOAD;
$field =~ s/.*://;
+
+ confess "errant AUTOLOAD $field for $self (arg $value)"
+ unless blessed($self) && $self->can('setfield');
+
+ if ( my $fk_info = get_fk_method($self->table, $field) ) {
+
+ my $method = $fk_info->{method} || 'qsearchs';
+ my $table = $fk_info->{table} || $field;
+ my $column = $fk_info->{column};
+ my $foreign_column = $fk_info->{references} || $column;
+
+ eval "use FS::$table";
+ die $@ if $@;
+
+ carp '->cust_main called' if $table eq 'cust_main' && $DEBUG;
+
+ my $pkey_value = $self->$column();
+ my %search = ( $foreign_column => $pkey_value );
+
+ # FS::Record->$method() ? they're actually just subs :/
+ if ( $method eq 'qsearchs' ) {
+ return $pkey_value ? qsearchs( $table, \%search ) : '';
+ } elsif ( $method eq 'qsearch' ) {
+ return $pkey_value ? qsearch( $table, \%search ) : ();
+ } else {
+ die "unknown method $method";
+ }
+
+ }
+
if ( defined($value) ) {
- confess "errant AUTOLOAD $field for $self (arg $value)"
- unless blessed($self) && $self->can('setfield');
$self->setfield($field,$value);
} else {
- confess "errant AUTOLOAD $field for $self (no args)"
- unless blessed($self) && $self->can('getfield');
$self->getfield($field);
}
}
-# efficient
+# efficient (also, old, doesn't support FK stuff)
#sub AUTOLOAD {
# my $field = $AUTOLOAD;
# $field =~ s/.*://;
# }
#}
+# 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" if $DEBUG;
+ foreach my $table ( dbdef->tables ) {
+ $fk_method_cache{$table} = fk_methods($table);
+ }
+}
+
+sub fk_methods {
+ my $table = shift;
+
+ my %hash = ();
+
+ # foreign keys we reference in other tables
+ foreach my $fk (dbdef->table($table)->foreign_keys) {
+
+ my $method = '';
+ if ( scalar( @{$fk->columns} ) == 1 ) {
+ if ( ! defined($fk->references)
+ || ! @{$fk->references}
+ || $fk->columns->[0] eq $fk->references->[0]
+ ) {
+ $method = $fk->table;
+ } else {
+ #some sort of hint in the table.pm or schema for methods not named
+ # after their foreign table (well, not a whole lot different than
+ # just providing a small subroutine...)
+ }
+
+ if ( $method ) {
+ $hash{$method} = { #fk_info
+ 'method' => 'qsearchs',
+ 'column' => $fk->columns->[0],
+ #'references' => $fk->references->[0],
+ };
+ }
+
+ }
+
+ }
+
+ # foreign keys referenced in other tables to us
+ # (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?)
+ 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)
+ || ! @{$fk->references}
+ || $fk->columns->[0] eq $fk->references->[0]
+ ) {
+ $method = $f_table;
+ } else {
+ #some sort of hint in the table.pm or schema for methods not named
+ # after their foreign table (well, not a whole lot different than
+ # just providing a small subroutine...)
+ }
+
+ if ( $method ) {
+ $hash{$method} = { #fk_info
+ 'method' => 'qsearch',
+ 'column' => $fk->columns->[0], #references||column
+ #'references' => $fk->column->[0],
+ };
+ }
+
+ }
+ }
+
+ \%hash;
+}
+
=item hash
Returns a list of the column/value pairs, usually for assigning to a new hash.
$self->{'Hash'};
}
+#fallbacks/generics
+
+sub API_getinfo {
+ my $self = shift;
+ +{ ( map { $_=>$self->$_ } $self->fields ),
+ };
+}
+
+sub API_insert {
+ my( $class, %opt ) = @_;
+ my $table = $class->table;
+ my $self = $class->new( { map { $_ => $opt{$_} } fields($table) } );
+ my $error = $self->insert;
+ return +{ 'error' => $error } if $error;
+ my $pkey = $self->pkey;
+ return +{ 'error' => '',
+ 'primary_key' => $pkey,
+ $pkey => $self->$pkey,
+ };
+}
+
=item modified
Returns true if any of this object's values have been modified with set (or via
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'
}
my $h_sth;
- if ( defined dbdef->table('h_'. $table) ) {
+ if ( defined( dbdef->table('h_'. $table) ) && ! $no_history ) {
my $h_statement = $self->_h_statement('insert');
warn "[debug]$me $h_statement\n" if $DEBUG > 2;
$h_sth = dbh->prepare($h_statement) or do {
# 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'
=item check
Checks custom fields. Subclasses should still provide a check method to validate
-non-custom fields, foreign keys, etc., and call this method via $self->SUPER::check.
+non-custom fields, etc., and call this method via $self->SUPER::check.
=cut
=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
=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
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process_batch_import {
- my($job, $opt) = ( shift, shift );
+ my($job, $opt, $param) = @_;
my $table = $opt->{table};
my @pass_params = $opt->{params} ? @{ $opt->{params} } : ();
my %formats = %{ $opt->{formats} };
- my $param = thaw(decode_base64(shift));
warn Dumper($param) if $DEBUG;
my $files = $param->{'uploaded_files'}
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,
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'} ) {
=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
=cut
+use Data::Dumper;
sub batch_import {
my $param = shift;
my $file = $param->{file};
my $params = $param->{params} || {};
+ my $custnum_prefix = $conf->config('cust_main-custnum-display_prefix');
+ my $custnum_length = $conf->config('cust_main-custnum-display_length') || 8;
+
my( $type, $header, $sep_char,
$fixedlength_format, $xml_format, $asn_format,
- $row_callback, @fields );
+ $parser_opt, $row_callback, $hash_callback, @fields );
my $postinsert_callback = '';
$postinsert_callback = $param->{'postinsert_callback'}
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'} ) {
? $param->{'format_fixedlength_formats'}{ $param->{'format'} }
: '';
+ $parser_opt =
+ $param->{'format_parser_opts'}
+ ? $param->{'format_parser_opts'}{ $param->{'format'} }
+ : {};
+
$xml_format =
$param->{'format_xml_formats'}
? $param->{'format_xml_formats'}{ $param->{'format'} }
? $param->{'format_row_callbacks'}{ $param->{'format'} }
: '';
+ $hash_callback =
+ $param->{'format_hash_callbacks'}
+ ? $param->{'format_hash_callbacks'}{ $param->{'format'} }
+ : '';
+
@fields = @{ $formats->{ $format } };
} elsif ( $param->{'fields'} ) {
$sep_char = ',';
$fixedlength_format = '';
$row_callback = '';
+ $hash_callback = '';
@fields = @{ $param->{'fields'} };
} else {
my $count;
my $parser;
my @buffer = ();
+ my $asn_header_buffer;
if ( $type eq 'csv' || $type eq 'fixedlength' ) {
if ( $type eq 'csv' ) {
- my %attr = ();
- $attr{sep_char} = $sep_char if $sep_char;
- $parser = new Text::CSV_XS \%attr;
+ $parser_opt->{'binary'} = 1;
+ $parser_opt->{'sep_char'} = $sep_char if $sep_char;
+ $parser = Text::CSV_XS->new($parser_opt);
} elsif ( $type eq 'fixedlength' ) {
eval "use Parse::FixedLength;";
die $@ if $@;
- $parser = Parse::FixedLength->new($fixedlength_format);
+ $parser = Parse::FixedLength->new($fixedlength_format, $parser_opt);
- }
- else {
+ } else {
die "Unknown file type $type\n";
}
my $data = slurp($file);
my $asn_output = $parser->decode( $data )
- or die "No ". $asn_format->{'macro'}. " found\n";
+ or return "No ". $asn_format->{'macro'}. " found\n";
+
+ $asn_header_buffer = &{ $asn_format->{'header_buffer'} }( $asn_output );
my $rows = &{ $asn_format->{'arrayref'} }( $asn_output );
$count = @buffer = @$rows;
#my $job = $param->{job};
my $line;
my $imported = 0;
+ my $unique_skip = 0; #lines skipped because they're already in the system
my( $last, $min_sec ) = ( time, 5 ); #progressbar foo
while (1) {
last unless scalar(@buffer);
my $row = shift @buffer;
+ &{ $asn_format->{row_callback} }( $row, $asn_header_buffer )
+ if $asn_format->{row_callback};
foreach my $key ( keys %{ $asn_format->{map} } ) {
- $hash{$key} = &{ $asn_format->{map}{$key} }( $row );
+ $hash{$key} = &{ $asn_format->{map}{$key} }( $row, $asn_header_buffer );
}
} else {
}
+ if ( $custnum_prefix && $hash{custnum} =~ /^$custnum_prefix(0*([1-9]\d*))$/
+ && length($1) == $custnum_length ) {
+ $hash{custnum} = $2;
+ }
+
+ %hash = &{$hash_callback}(%hash) if $hash_callback;
+
#my $table = $param->{table};
my $class = "FS::$table";
}
last if exists( $param->{skiprow} );
}
+ $unique_skip++ if $param->{unique_skip}; #line is already in the system
next if exists( $param->{skiprow} );
if ( $preinsert_callback ) {
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;
unless ( $imported || $param->{empty_ok} ) {
$dbh->rollback if $oldAutoCommit;
- return "Empty file!";
+ # freeside-cdr-conexiant-import is sensitive to the text of this message
+ return $unique_skip ? "All records in file were previously imported" : "Empty file!";
}
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
''; #no error
;
# 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;
}
my @values = map { _quote( $self->getfield($_), $self->table, $_) } @fields;
"INSERT INTO h_". $self->table. " ( ".
- join(', ', qw(history_date history_user history_action), @fields ).
+ join(', ', qw(history_date history_usernum history_action), @fields ).
") VALUES (".
- join(', ', $time, dbh->quote(getotaker()), dbh->quote($action), @values).
+ join(', ', $time,
+ $FS::CurrentUser::CurrentUser->usernum,
+ dbh->quote($action),
+ @values
+ ).
")"
;
}
#warn "field $field is tainted" if is_tainted($field);
my($counter) = new File::CounterFile "$table.$field",0;
-# hack for web demo
-# getotaker() =~ /^([\w\-]{1,16})$/ or die "Illegal CGI REMOTE_USER!";
-# my($user)=$1;
-# my($counter) = new File::CounterFile "$user/$table.$field",0;
-# endhack
my $index = $counter->inc;
$index = $counter->inc while qsearchs($table, { $field=>$index } );
'';
}
+=item ut_decimal COLUMN[, DIGITS]
+
+Check/untaint decimal numbers (up to DIGITS decimal places. If there is an
+error, returns the error, otherwise returns false.
+
+=item ut_decimaln COLUMN[, DIGITS]
+
+Check/untaint decimal numbers. May be null. If there is an error, returns
+the error, otherwise returns false.
+
+=cut
+
+sub ut_decimal {
+ my($self, $field, $digits) = @_;
+ $digits ||= '';
+ $self->getfield($field) =~ /^\s*(\d+(\.\d{0,$digits})?)\s*$/
+ or return "Illegal or empty (decimal) $field: ".$self->getfield($field);
+ $self->setfield($field, $1);
+ '';
+}
+
+sub ut_decimaln {
+ my($self, $field, $digits) = @_;
+ $self->getfield($field) =~ /^\s*(\d*(\.\d{0,$digits})?)\s*$/
+ or return "Illegal (decimal) $field: ".$self->getfield($field);
+ $self->setfield($field, $1);
+ '';
+}
+
=item ut_money COLUMN
Check/untaint monetary numbers. May be negative. Set to 0 if null. If there
sub ut_money {
my($self,$field)=@_;
- $self->setfield($field, 0) if $self->getfield($field) eq '';
- $self->getfield($field) =~ /^\s*(\-)?\s*(\d*)(\.\d{2})?\s*$/
- or return "Illegal (money) $field: ". $self->getfield($field);
- #$self->setfield($field, "$1$2$3" || 0);
- $self->setfield($field, ( ($1||''). ($2||''). ($3||'') ) || 0);
+
+ if ( $self->getfield($field) eq '' ) {
+ $self->setfield($field, 0);
+ } elsif ( $self->getfield($field) =~ /^\s*(\-)?\s*(\d*)(\.\d{1})\s*$/ ) {
+ #handle one decimal place without barfing out
+ $self->setfield($field, ( ($1||''). ($2||''). ($3.'0') ) || 0);
+ } elsif ( $self->getfield($field) =~ /^\s*(\-)?\s*(\d*)(\.\d{2})?\s*$/ ) {
+ $self->setfield($field, ( ($1||''). ($2||''). ($3||'') ) || 0);
+ } else {
+ return "Illegal (money) $field: ". $self->getfield($field);
+ }
+
'';
}
$self->ut_money($field);
}
+=item ut_currencyn COLUMN
+
+Check/untaint currency indicators, such as USD or EUR. May be null. If there
+is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub ut_currencyn {
+ my($self, $field) = @_;
+ if ($self->getfield($field) eq '') { #can be null
+ $self->setfield($field, '');
+ return '';
+ }
+ $self->ut_currency($field);
+}
+
+=item ut_currency COLUMN
+
+Check/untaint currency indicators, such as USD or EUR. May not be null. If
+there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub ut_currency {
+ my($self, $field) = @_;
+ my $value = uc( $self->getfield($field) );
+ if ( code2currency($value) ) {
+ $self->setfield($value);
+ } else {
+ return "Unknown currency $value";
+ }
+
+ '';
+}
+
=item ut_text COLUMN
Check/untaint text. Alphanumerics, spaces, and the following punctuation
#warn "msgcat ". \&msgcat. "\n";
#warn "notexist ". \¬exist. "\n";
#warn "AUTOLOAD ". \&AUTOLOAD. "\n";
+ # \p{Word} = alphanumerics, marks (diacritics), and connectors
+ # see perldoc perluniprops
$self->getfield($field)
- =~ /^([\wô \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>$money_char]+)$/
+ =~ /^([\p{Word} \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>$money_char]+)$/
or return gettext('illegal_or_empty_text'). " $field: ".
$self->getfield($field);
$self->setfield($field,$1);
Check/untaint phone numbers. May be null. If there is an error, returns
the error, otherwise returns false.
-Takes an optional two-letter ISO country code; without it or with unsupported
-countries, ut_phonen simply calls ut_alphan.
+Takes an optional two-letter ISO 3166-1 alpha-2 country code; without
+it or with unsupported countries, ut_phonen simply calls ut_alphan.
=cut
my $coord = $self->getfield($field);
my $neg = $coord =~ s/^(-)//;
+ # ignore degree symbol at the end,
+ # but not otherwise supporting degree/minutes/seconds symbols
+ $coord =~ s/\N{DEGREE SIGN}\s*$//;
+
my ($d, $m, $s) = (0, 0, 0);
if (
sub ut_name {
my( $self, $field ) = @_;
-# warn "ut_name allowed alphanumerics: +(sort grep /\w/, map { chr() } 0..255), "\n";
- $self->getfield($field) =~ /^([\w \,\.\-\']+)$/
+ $self->getfield($field) =~ /^([\p{Word} \,\.\-\']+)$/
or return gettext('illegal_name'). " $field: ". $self->getfield($field);
- $self->setfield($field,$1);
+ my $name = $1;
+ $name =~ s/^\s+//;
+ $name =~ s/\s+$//;
+ $name =~ s/\s+/ /g;
+ $self->setfield($field, $name);
'';
}
+=item ut_namen COLUMN
+
+Check/untaint proper names; allows alphanumerics, spaces and the following
+punctuation: , . - '
+
+May not be null.
+
+=cut
+
+sub ut_namen {
+ my( $self, $field ) = @_;
+ return $self->setfield($field, '') if $self->getfield($field) =~ /^$/;
+ $self->ut_name($field);
+}
+
=item ut_zip COLUMN
Check/untaint zip codes.
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";
if ( $self->$field() ) {
- return "Access denied"
+ return 'Access denied to agent '. $self->$field()
unless $curuser->agentnum($self->$field());
} else {
- return "Access denied"
+ return 'Access denied to global'
unless grep $curuser->access_right($_), @$null_acl;
}
}
+=item trim_whitespace FIELD[, FIELD ... ]
+
+Strip leading and trailing spaces from the value in the named FIELD(s).
+
+=cut
+
+sub trim_whitespace {
+ my $self = shift;
+ foreach my $field (@_) {
+ my $value = $self->get($field);
+ $value =~ s/^\s+//;
+ $value =~ s/\s+$//;
+ $self->set($field, $value);
+ }
+}
+
=item fields [ TABLE ]
This is a wrapper for real_fields. Code that called
$table = $something->table;
} else {
$table = $something;
- $something = "FS::$table";
+ #$something = "FS::$table";
}
return (real_fields($table));
}
sub encrypt {
my ($self, $value) = @_;
- my $encrypted;
+ 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;
sub is_encrypted {
my ($self, $value) = @_;
- # Possible Bug - Some work may be required here....
-
- if ($value =~ /^M/ && length($value) > 80) {
- return 1;
- } else {
- return 0;
- }
+ # could be more precise about it, but this will do for now
+ $value =~ /^M/ && length($value) > 80;
}
=item decrypt($value)
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);
#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) {
$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);
}
}
defined($scalar) ? $scalar : '';
}
-=item count [ WHERE ]
+=item count [ WHERE [, PLACEHOLDER ...] ]
Convenience method for the common case of "SELECT COUNT(*) FROM table",
with optional WHERE. Must be called as method on a class with an
my $table = $self->table or die 'count called on object of class '.ref($self);
my $sql = "SELECT COUNT(*) FROM $table";
$sql .= " WHERE $where" if $where;
- $self->scalar_sql($sql);
+ $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
my $column_type = $column_obj->type;
my $nullable = $column_obj->null;
+ utf8::upgrade($value);
+
warn " $table.$column: $value ($column_type".
( $nullable ? ' NULL' : ' NOT NULL' ).
")\n" if $DEBUG > 2;
}
+=item group_concat_sql COLUMN, DELIMITER
+
+Returns an SQL expression to concatenate an aggregate column, using
+GROUP_CONCAT() for mysql and array_to_string() and array_agg() for Pg.
+
+=cut
+
+sub group_concat_sql {
+ my ($col, $delim) = @_;
+ $delim = dbh->quote($delim);
+ if ( driver_name() =~ /^mysql/i ) {
+ # DISTINCT(foo) is valid as $col
+ return "GROUP_CONCAT($col SEPARATOR $delim)";
+ } else {
+ return "array_to_string(array_agg($col), $delim)";
+ }
+}
+
=item midnight_sql DATE
Returns an SQL expression to convert DATE (a unix timestamp) to midnight