Bug 28516: Prevent failures if borrower_attribute_types.mandatory=1
[koha.git] / t / lib / TestBuilder.pm
1 package t::lib::TestBuilder;
2
3 use Modern::Perl;
4
5 use Koha::Database;
6 use C4::Biblio;
7 use C4::Items;
8 use Koha::Biblios;
9 use Koha::Items;
10 use Koha::DateUtils qw( dt_from_string );
11
12 use Bytes::Random::Secure;
13 use Carp;
14 use Module::Load;
15 use String::Random;
16
17 use constant {
18     SIZE_BARCODE => 20, # Not perfect but avoid to fetch the value when creating a new item
19 };
20
21 sub new {
22     my ($class) = @_;
23     my $self = {};
24     bless( $self, $class );
25
26     $self->schema( Koha::Database->new()->schema );
27     $self->schema->storage->sql_maker->quote_char('`');
28
29     $self->{gen_type} = _gen_type();
30     $self->{default_values} = _gen_default_values();
31     return $self;
32 }
33
34 sub schema {
35     my ($self, $schema) = @_;
36
37     if( defined( $schema ) ) {
38         $self->{schema} = $schema;
39     }
40     return $self->{schema};
41 }
42
43 # sub clear has been obsoleted; use delete_all from the schema resultset
44
45 sub delete {
46     my ( $self, $params ) = @_;
47     my $source = $params->{source} || return;
48     my @recs = ref( $params->{records} ) eq 'ARRAY'?
49         @{$params->{records}}: ( $params->{records} // () );
50     # tables without PK are not supported
51     my @pk = $self->schema->source( $source )->primary_columns;
52     return if !@pk;
53     my $rv = 0;
54     foreach my $rec ( @recs ) {
55     # delete only works when you supply full primary key values
56     # $cond does not include searches for undef (not allowed in PK)
57         my $cond = { map { defined $rec->{$_}? ($_, $rec->{$_}): (); } @pk };
58         next if keys %$cond < @pk;
59         $self->schema->resultset( $source )->search( $cond )->delete;
60         # we clear the pk columns in the supplied hash
61         # this indirectly signals at least an attempt to delete
62         map { delete $rec->{$_}; } @pk;
63         $rv++;
64     }
65     return $rv;
66 }
67
68 sub build_object {
69     my ( $self, $params ) = @_;
70
71     my $class = $params->{class};
72     my $value = $params->{value};
73
74     if ( not defined $class ) {
75         carp "Missing class param";
76         return;
77     }
78
79     my @unknowns = grep( !/^(class|value)$/, keys %{ $params });
80     carp "Unknown parameter(s): ", join( ', ', @unknowns ) if scalar @unknowns;
81
82     load $class;
83     my $source = $class->_type;
84
85     my $hashref = $self->build({ source => $source, value => $value });
86     my $object;
87     if ( $class eq 'Koha::Old::Patrons' ) {
88         $object = $class->search({ borrowernumber => $hashref->{borrowernumber} })->next;
89     } elsif ( $class eq 'Koha::Statistics' ) {
90         $object = $class->search({ datetime => $hashref->{datetime} })->next;
91     } else {
92         my @ids;
93         my @pks = $self->schema->source( $class->_type )->primary_columns;
94         foreach my $pk ( @pks ) {
95             push @ids, $hashref->{ $pk };
96         }
97
98         $object = $class->find( @ids );
99     }
100
101     return $object;
102 }
103
104 sub build {
105 # build returns a hash of column values for a created record, or undef
106 # build does NOT update a record, or pass back values of an existing record
107     my ($self, $params) = @_;
108     my $source  = $params->{source};
109     if( !$source ) {
110         carp "Source parameter not specified!";
111         return;
112     }
113     my $value   = $params->{value};
114
115     my @unknowns = grep( !/^(source|value)$/, keys %{ $params });
116     carp "Unknown parameter(s): ", join( ', ', @unknowns ) if scalar @unknowns;
117
118     my $col_values = $self->_buildColumnValues({
119         source  => $source,
120         value   => $value,
121     });
122     return if !$col_values; # did not meet unique constraints?
123
124     # loop thru all fk and create linked records if needed
125     # fills remaining entries in $col_values
126     my $foreign_keys = $self->_getForeignKeys( { source => $source } );
127     for my $fk ( @$foreign_keys ) {
128         # skip when FK points to itself: e.g. borrowers:guarantorid
129         next if $fk->{source} eq $source;
130         my $keys = $fk->{keys};
131         my $tbl = $fk->{source};
132         my $res = $self->_create_links( $tbl, $keys, $col_values, $value );
133         return if !$res; # failed: no need to go further
134         foreach( keys %$res ) { # save new values
135             $col_values->{$_} = $res->{$_};
136         }
137     }
138
139     # store this record and return hashref
140     return $self->_storeColumnValues({
141         source => $source,
142         values => $col_values,
143     });
144 }
145
146 sub build_sample_biblio {
147     my ( $self, $args ) = @_;
148
149     my $title  = $args->{title}  || 'Some boring read';
150     my $author = $args->{author} || 'Some boring author';
151     my $frameworkcode = $args->{frameworkcode} || '';
152     my $itemtype = $args->{itemtype}
153       || $self->build_object( { class => 'Koha::ItemTypes' } )->itemtype;
154
155     my $marcflavour = C4::Context->preference('marcflavour');
156
157     my $record = MARC::Record->new();
158     $record->encoding( 'UTF-8' );
159
160     my ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 200, 'a' ) : ( 245, 'a' );
161     $record->append_fields(
162         MARC::Field->new( $tag, ' ', ' ', $subfield => $title ),
163     );
164
165     ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 200, 'f' ) : ( 100, 'a' );
166     $record->append_fields(
167         MARC::Field->new( $tag, ' ', ' ', $subfield => $author ),
168     );
169
170     ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 995, 'r' ) : ( 942, 'c' );
171     $record->append_fields(
172         MARC::Field->new( $tag, ' ', ' ', $subfield => $itemtype )
173     );
174
175     my ($biblio_id) = C4::Biblio::AddBiblio( $record, $frameworkcode );
176     return Koha::Biblios->find($biblio_id);
177 }
178
179 sub build_sample_item {
180     my ( $self, $args ) = @_;
181
182     my $biblionumber =
183       delete $args->{biblionumber} || $self->build_sample_biblio->biblionumber;
184     my $library = delete $args->{library}
185       || $self->build_object( { class => 'Koha::Libraries' } )->branchcode;
186
187     # If itype is not passed it will be picked from the biblio (see Koha::Item->store)
188
189     my $barcode = delete $args->{barcode}
190       || $self->_gen_text( { info => { size => SIZE_BARCODE } } );
191
192     return Koha::Item->new(
193         {
194             biblionumber  => $biblionumber,
195             homebranch    => $library,
196             holdingbranch => $library,
197             barcode       => $barcode,
198             %$args,
199         }
200     )->store->get_from_storage;
201 }
202
203 # ------------------------------------------------------------------------------
204 # Internal helper routines
205
206 sub _create_links {
207 # returns undef for failure to create linked records
208 # otherwise returns hashref containing new column values for parent record
209     my ( $self, $linked_tbl, $keys, $col_values, $value ) = @_;
210
211     my $fk_value = {};
212     my ( $cnt_scalar, $cnt_null ) = ( 0, 0 );
213
214     # First, collect all values for creating a linked record (if needed)
215     foreach my $fk ( @$keys ) {
216         my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
217         if( ref( $value->{$col} ) eq 'HASH' ) {
218             # add all keys from the FK hash
219             $fk_value = { %{ $value->{$col} }, %$fk_value };
220         }
221         if( exists $col_values->{$col} ) {
222             # add specific value (this does not necessarily exclude some
223             # values from the hash in the preceding if)
224             $fk_value->{ $destcol } = $col_values->{ $col };
225             $cnt_scalar++;
226             $cnt_null++ if !defined( $col_values->{$col} );
227         }
228     }
229
230     # If we saw all FK columns, first run the following checks
231     if( $cnt_scalar == @$keys ) {
232         # if one or more fk cols are null, the FK constraint will not be forced
233         return {} if $cnt_null > 0;
234
235         # does the record exist already?
236         my @pks = $self->schema->source( $linked_tbl )->primary_columns;
237         my %fk_pk_value;
238         for (@pks) {
239             $fk_pk_value{$_} = $fk_value->{$_} if defined $fk_value->{$_};
240         }
241         return {} if !(keys %fk_pk_value);
242         return {} if $self->schema->resultset($linked_tbl)->find( \%fk_pk_value );
243     }
244     # create record with a recursive build call
245     my $row = $self->build({ source => $linked_tbl, value => $fk_value });
246     return if !$row; # failure
247
248     # Finally, only return the new values
249     my $rv = {};
250     foreach my $fk ( @$keys ) {
251         my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
252         next if exists $col_values->{ $col };
253         $rv->{ $col } = $row->{ $destcol };
254     }
255     return $rv; # success
256 }
257
258 sub _formatSource {
259     my ($params) = @_;
260     my $source = $params->{source} || return;
261     $source =~ s|(\w+)$|$1|;
262     return $source;
263 }
264
265 sub _buildColumnValues {
266     my ($self, $params) = @_;
267     my $source = _formatSource( $params ) || return;
268     my $original_value = $params->{value};
269
270     my $col_values = {};
271     my @columns = $self->schema->source($source)->columns;
272     my %unique_constraints = $self->schema->source($source)->unique_constraints();
273
274     my $build_value = 5;
275     # we try max $build_value times if there are unique constraints
276     BUILD_VALUE: while ( $build_value ) {
277         # generate random values for all columns
278         for my $col_name( @columns ) {
279             my $valref = $self->_buildColumnValue({
280                 source      => $source,
281                 column_name => $col_name,
282                 value       => $original_value,
283             });
284             return if !$valref; # failure
285             if( @$valref ) { # could be empty
286                 # there will be only one value, but it could be undef
287                 $col_values->{$col_name} = $valref->[0];
288             }
289         }
290
291         # verify the data would respect each unique constraint
292         # note that this is INCOMPLETE since not all col_values are filled
293         CONSTRAINTS: foreach my $constraint (keys %unique_constraints) {
294
295                 my $condition;
296                 my $constraint_columns = $unique_constraints{$constraint};
297                 # loop through all constraint columns and build the condition
298                 foreach my $constraint_column ( @$constraint_columns ) {
299                     # build the filter
300                     # if one column does not exist or is undef, skip it
301                     # an insert with a null will not trigger the constraint
302                     next CONSTRAINTS
303                         if !exists $col_values->{ $constraint_column } ||
304                         !defined $col_values->{ $constraint_column };
305                     $condition->{ $constraint_column } =
306                             $col_values->{ $constraint_column };
307                 }
308                 my $count = $self->schema
309                                  ->resultset( $source )
310                                  ->search( $condition )
311                                  ->count();
312                 if ( $count > 0 ) {
313                     # no point checking more stuff, exit the loop
314                     $build_value--;
315                     next BUILD_VALUE;
316                 }
317         }
318         last; # you passed all tests
319     }
320     return $col_values if $build_value > 0;
321
322     # if you get here, we have a problem
323     warn "Violation of unique constraint in $source";
324     return;
325 }
326
327 sub _getForeignKeys {
328
329 # Returns the following arrayref
330 #   [ [ source => name, keys => [ col_name => A, col_fk_name => B ] ], ... ]
331 # The array gives source name and keys for each FK constraint
332
333     my ($self, $params) = @_;
334     my $source = $self->schema->source( $params->{source} );
335
336     my ( @foreign_keys, $check_dupl );
337     my @relationships = $source->relationships;
338     for my $rel_name( @relationships ) {
339         my $rel_info = $source->relationship_info($rel_name);
340         if( $rel_info->{attrs}->{is_foreign_key_constraint} ) {
341             $rel_info->{source} =~ s/^.*:://g;
342             my $rel = { source => $rel_info->{source} };
343
344             my @keys;
345             while( my ($col_fk_name, $col_name) = each(%{$rel_info->{cond}}) ) {
346                 $col_name    =~ s|self.(\w+)|$1|;
347                 $col_fk_name =~ s|foreign.(\w+)|$1|;
348                 push @keys, {
349                     col_name    => $col_name,
350                     col_fk_name => $col_fk_name,
351                 };
352             }
353             # check if the combination table and keys is unique
354             # so skip double belongs_to relations (as in Biblioitem)
355             my $tag = $rel->{source}. ':'.
356                 join ',', sort map { $_->{col_name} } @keys;
357             next if $check_dupl->{$tag};
358             $check_dupl->{$tag} = 1;
359             $rel->{keys} = \@keys;
360             push @foreign_keys, $rel;
361         }
362     }
363     return \@foreign_keys;
364 }
365
366 sub _storeColumnValues {
367     my ($self, $params) = @_;
368     my $source      = $params->{source};
369     my $col_values  = $params->{values};
370     my $new_row = $self->schema->resultset( $source )->create( $col_values );
371     return $new_row? { $new_row->get_columns }: {};
372 }
373
374 sub _buildColumnValue {
375 # returns an arrayref if all goes well
376 # an empty arrayref typically means: auto_incr column or fk column
377 # undef means failure
378     my ($self, $params) = @_;
379     my $source    = $params->{source};
380     my $value     = $params->{value};
381     my $col_name  = $params->{column_name};
382
383     my $col_info  = $self->schema->source($source)->column_info($col_name);
384
385     my $retvalue = [];
386     if( $col_info->{is_auto_increment} ) {
387         if( exists $value->{$col_name} ) {
388             warn "Value not allowed for auto_incr $col_name in $source";
389             return;
390         }
391         # otherwise: no need to assign a value
392     } elsif( $col_info->{is_foreign_key} || _should_be_fk($source,$col_name) ) {
393         if( exists $value->{$col_name} ) {
394             if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
395                 # This explicit undef is not allowed
396                 warn "Null value for $col_name in $source not allowed";
397                 return;
398             }
399             if( ref( $value->{$col_name} ) ne 'HASH' ) {
400                 push @$retvalue, $value->{$col_name};
401             }
402             # sub build will handle a passed hash value later on
403         }
404     } elsif( ref( $value->{$col_name} ) eq 'HASH' ) {
405         # this is not allowed for a column that is not a FK
406         warn "Hash not allowed for $col_name in $source";
407         return;
408     } elsif( exists $value->{$col_name} ) {
409         if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
410             # This explicit undef is not allowed
411             warn "Null value for $col_name in $source not allowed";
412             return;
413         }
414         push @$retvalue, $value->{$col_name};
415     } elsif( exists $self->{default_values}{$source}{$col_name} ) {
416         my $v = $self->{default_values}{$source}{$col_name};
417         $v = &$v() if ref($v) eq 'CODE';
418         push @$retvalue, $v;
419     } else {
420         my $data_type = $col_info->{data_type};
421         $data_type =~ s| |_|;
422         if( my $hdlr = $self->{gen_type}->{$data_type} ) {
423             push @$retvalue, &$hdlr( $self, { info => $col_info } );
424         } else {
425             warn "Unknown type $data_type for $col_name in $source";
426             return;
427         }
428     }
429     return $retvalue;
430 }
431
432 sub _should_be_fk {
433 # This sub is only needed for inconsistencies in the schema
434 # A column is not marked as FK, but a belongs_to relation is defined
435     my ( $source, $column ) = @_;
436     my $inconsistencies = {
437         'Item.biblionumber' => 1, #FIXME: Please remove me when I become FK
438     };
439     return $inconsistencies->{ "$source.$column" };
440 }
441
442 sub _gen_type {
443     return {
444         tinyint   => \&_gen_int,
445         smallint  => \&_gen_int,
446         mediumint => \&_gen_int,
447         integer   => \&_gen_int,
448         bigint    => \&_gen_int,
449
450         float            => \&_gen_real,
451         decimal          => \&_gen_real,
452         double_precision => \&_gen_real,
453
454         timestamp => \&_gen_datetime,
455         datetime  => \&_gen_datetime,
456         date      => \&_gen_date,
457
458         char       => \&_gen_text,
459         varchar    => \&_gen_text,
460         tinytext   => \&_gen_text,
461         text       => \&_gen_text,
462         mediumtext => \&_gen_text,
463         longtext   => \&_gen_text,
464
465         set  => \&_gen_set_enum,
466         enum => \&_gen_set_enum,
467
468         tinyblob   => \&_gen_blob,
469         mediumblob => \&_gen_blob,
470         blob       => \&_gen_blob,
471         longblob   => \&_gen_blob,
472     };
473 };
474
475 sub _gen_int {
476     my ($self, $params) = @_;
477     my $data_type = $params->{info}->{data_type};
478
479     my $max = 1;
480     if( $data_type eq 'tinyint' ) {
481         $max = 127;
482     }
483     elsif( $data_type eq 'smallint' ) {
484         $max = 32767;
485     }
486     elsif( $data_type eq 'mediumint' ) {
487         $max = 8388607;
488     }
489     elsif( $data_type eq 'integer' ) {
490         $max = 2147483647;
491     }
492     elsif( $data_type eq 'bigint' ) {
493         $max = 9223372036854775807;
494     }
495     return int( rand($max+1) );
496 }
497
498 sub _gen_real {
499     my ($self, $params) = @_;
500     my $max = 10 ** 38;
501     if( defined( $params->{info}->{size} ) ) {
502         $max = 10 ** ($params->{info}->{size}->[0] - $params->{info}->{size}->[1]);
503     }
504     $max = 10 ** 5 if $max > 10 ** 5;
505     return sprintf("%.2f", rand($max-0.1));
506 }
507
508 sub _gen_date {
509     my ($self, $params) = @_;
510     return $self->schema->storage->datetime_parser->format_date(dt_from_string)
511 }
512
513 sub _gen_datetime {
514     my ($self, $params) = @_;
515     return $self->schema->storage->datetime_parser->format_datetime(dt_from_string);
516 }
517
518 sub _gen_text {
519     my ($self, $params) = @_;
520     # From perldoc String::Random
521     my $size = $params->{info}{size} // 10;
522     $size -= alt_rand(0.5 * $size);
523     my $regex = $size > 1
524         ? '[A-Za-z][A-Za-z0-9_]{'.($size-1).'}'
525         : '[A-Za-z]';
526     my $random = String::Random->new( rand_gen => \&alt_rand );
527     # rand_gen is only supported from 0.27 onward
528     return $random->randregex($regex);
529 }
530
531 sub alt_rand { #Alternative randomizer
532     my ($max) = @_;
533     my $random = Bytes::Random::Secure->new( NonBlocking => 1 );
534     my $r = $random->irand / 2**32;
535     return int( $r * $max );
536 }
537
538 sub _gen_set_enum {
539     my ($self, $params) = @_;
540     return $params->{info}->{extra}->{list}->[0];
541 }
542
543 sub _gen_blob {
544     my ($self, $params) = @_;;
545     return 'b';
546 }
547
548 sub _gen_default_values {
549     my ($self) = @_;
550     return {
551         Borrower => {
552             login_attempts => 0,
553             gonenoaddress  => undef,
554             lost           => undef,
555             debarred       => undef,
556             borrowernotes  => '',
557         },
558         Item => {
559             notforloan         => 0,
560             itemlost           => 0,
561             withdrawn          => 0,
562             restricted         => 0,
563             damaged            => 0,
564             materials          => undef,
565             more_subfields_xml => undef,
566         },
567         Category => {
568             enrolmentfee => 0,
569             reservefee   => 0,
570             # Not X, used for statistics
571             category_type => sub { return [ qw( A C S I P ) ]->[int(rand(5))] },
572             min_password_length => undef,
573             require_strong_password => undef,
574         },
575         Branch => {
576             pickup_location => 0,
577         },
578         Reserve => {
579             non_priority => 0,
580         },
581         Itemtype => {
582             rentalcharge => 0,
583             rentalcharge_daily => 0,
584             rentalcharge_hourly => 0,
585             defaultreplacecost => 0,
586             processfee => 0,
587             notforloan => 0,
588         },
589         Aqbookseller => {
590             tax_rate => 0,
591             discount => 0,
592         },
593         AuthHeader => {
594             marcxml => '',
595         },
596         BorrowerAttributeType => {
597             mandatory => 0,
598         },
599     };
600 }
601
602 =head1 NAME
603
604 t::lib::TestBuilder.pm - Koha module to create test records
605
606 =head1 SYNOPSIS
607
608     use t::lib::TestBuilder;
609     my $builder = t::lib::TestBuilder->new;
610
611     # The following call creates a patron, linked to branch CPL.
612     # Surname is provided, other columns are randomly generated.
613     # Branch CPL is created if it does not exist.
614     my $patron = $builder->build({
615         source => 'Borrower',
616         value  => { surname => 'Jansen', branchcode => 'CPL' },
617     });
618
619 =head1 DESCRIPTION
620
621 This module automatically creates database records for you.
622 If needed, records for foreign keys are created too.
623 Values will be randomly generated if not passed to TestBuilder.
624 Note that you should wrap these actions in a transaction yourself.
625
626 =head1 METHODS
627
628 =head2 new
629
630     my $builder = t::lib::TestBuilder->new;
631
632     Constructor - Returns the object TestBuilder
633
634 =head2 schema
635
636     my $schema = $builder->schema;
637
638     Getter - Returns the schema of DBIx::Class
639
640 =head2 delete
641
642     $builder->delete({
643         source => $source,
644         records => $patron, # OR: records => [ $patron, ... ],
645     });
646
647     Delete individual records, created by builder.
648     Returns the number of delete attempts, or undef.
649
650 =head2 build
651
652     $builder->build({ source  => $source_name, value => $value });
653
654     Create a test record in the table, represented by $source_name.
655     The name is required and must conform to the DBIx::Class schema.
656     Values may be specified by the optional $value hashref. Will be
657     randomized otherwise.
658     If needed, TestBuilder creates linked records for foreign keys.
659     Returns the values of the new record as a hashref, or undef if
660     the record could not be created.
661
662     Note that build also supports recursive hash references inside the
663     value hash for foreign key columns, like:
664         value => {
665             column1 => 'some_value',
666             fk_col2 => {
667                 columnA => 'another_value',
668             }
669         }
670     The hash for fk_col2 here means: create a linked record with build
671     where columnA has this value. In case of a composite FK the hashes
672     are merged.
673
674     Realize that passing primary key values to build may result in undef
675     if a record with that primary key already exists.
676
677 =head2 build_object
678
679 Given a plural Koha::Object-derived class, it creates a random element, and
680 returns the corresponding Koha::Object.
681
682     my $patron = $builder->build_object({ class => 'Koha::Patrons' [, value => { ... }] });
683
684 =head1 AUTHOR
685
686 Yohann Dufour <yohann.dufour@biblibre.com>
687
688 Koha Development Team
689
690 =head1 COPYRIGHT
691
692 Copyright 2014 - Biblibre SARL
693
694 =head1 LICENSE
695
696 This file is part of Koha.
697
698 Koha is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
699 the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
700
701 Koha is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
702
703 You should have received a copy of the GNU General Public License along with Koha; if not, see <http://www.gnu.org/licenses>.
704
705 =cut
706
707 1;