Bug 23091: Add handling for new lostreturn rules
[koha.git] / t / lib / TestBuilder.pm
blob26de949fb38ae9a1c53ebe19b39421886347943b
1 package t::lib::TestBuilder;
3 use Modern::Perl;
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 );
12 use Bytes::Random::Secure;
13 use Carp;
14 use Module::Load;
15 use String::Random;
17 use constant {
18 SIZE_BARCODE => 20, # Not perfect but avoid to fetch the value when creating a new item
21 sub new {
22 my ($class) = @_;
23 my $self = {};
24 bless( $self, $class );
26 $self->schema( Koha::Database->new()->schema );
27 $self->schema->storage->sql_maker->quote_char('`');
29 $self->{gen_type} = _gen_type();
30 $self->{default_values} = _gen_default_values();
31 return $self;
34 sub schema {
35 my ($self, $schema) = @_;
37 if( defined( $schema ) ) {
38 $self->{schema} = $schema;
40 return $self->{schema};
43 # sub clear has been obsoleted; use delete_all from the schema resultset
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++;
65 return $rv;
68 sub build_object {
69 my ( $self, $params ) = @_;
71 my $class = $params->{class};
72 my $value = $params->{value};
74 if ( not defined $class ) {
75 carp "Missing class param";
76 return;
79 my @unknowns = grep( !/^(class|value)$/, keys %{ $params });
80 carp "Unknown parameter(s): ", join( ', ', @unknowns ) if scalar @unknowns;
82 load $class;
83 my $source = $class->_type;
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 };
98 $object = $class->find( @ids );
101 return $object;
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;
113 my $value = $params->{value};
115 my @unknowns = grep( !/^(source|value)$/, keys %{ $params });
116 carp "Unknown parameter(s): ", join( ', ', @unknowns ) if scalar @unknowns;
118 my $col_values = $self->_buildColumnValues({
119 source => $source,
120 value => $value,
122 return if !$col_values; # did not meet unique constraints?
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->{$_};
139 # store this record and return hashref
140 return $self->_storeColumnValues({
141 source => $source,
142 values => $col_values,
146 sub build_sample_biblio {
147 my ( $self, $args ) = @_;
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;
155 my $marcflavour = C4::Context->preference('marcflavour');
157 my $record = MARC::Record->new();
158 my ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 200, 'a' ) : ( 245, 'a' );
159 $record->append_fields(
160 MARC::Field->new( $tag, ' ', ' ', $subfield => $title ),
163 ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 200, 'f' ) : ( 100, 'a' );
164 $record->append_fields(
165 MARC::Field->new( $tag, ' ', ' ', $subfield => $author ),
168 ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 995, 'r' ) : ( 942, 'c' );
169 $record->append_fields(
170 MARC::Field->new( $tag, ' ', ' ', $subfield => $itemtype )
173 my ($biblio_id) = C4::Biblio::AddBiblio( $record, $frameworkcode );
174 return Koha::Biblios->find($biblio_id);
177 sub build_sample_item {
178 my ( $self, $args ) = @_;
180 my $biblionumber =
181 delete $args->{biblionumber} || $self->build_sample_biblio->biblionumber;
182 my $library = delete $args->{library}
183 || $self->build_object( { class => 'Koha::Libraries' } )->branchcode;
185 # If itype is not passed it will be picked from the biblio (see Koha::Item->store)
187 my $barcode = delete $args->{barcode}
188 || $self->_gen_text( { info => { size => SIZE_BARCODE } } );
190 return Koha::Item->new(
192 biblionumber => $biblionumber,
193 homebranch => $library,
194 holdingbranch => $library,
195 barcode => $barcode,
196 %$args,
198 )->store->get_from_storage;
201 # ------------------------------------------------------------------------------
202 # Internal helper routines
204 sub _create_links {
205 # returns undef for failure to create linked records
206 # otherwise returns hashref containing new column values for parent record
207 my ( $self, $linked_tbl, $keys, $col_values, $value ) = @_;
209 my $fk_value = {};
210 my ( $cnt_scalar, $cnt_null ) = ( 0, 0 );
212 # First, collect all values for creating a linked record (if needed)
213 foreach my $fk ( @$keys ) {
214 my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
215 if( ref( $value->{$col} ) eq 'HASH' ) {
216 # add all keys from the FK hash
217 $fk_value = { %{ $value->{$col} }, %$fk_value };
219 if( exists $col_values->{$col} ) {
220 # add specific value (this does not necessarily exclude some
221 # values from the hash in the preceding if)
222 $fk_value->{ $destcol } = $col_values->{ $col };
223 $cnt_scalar++;
224 $cnt_null++ if !defined( $col_values->{$col} );
228 # If we saw all FK columns, first run the following checks
229 if( $cnt_scalar == @$keys ) {
230 # if one or more fk cols are null, the FK constraint will not be forced
231 return {} if $cnt_null > 0;
232 # does the record exist already?
233 return {} if $self->schema->resultset($linked_tbl)->find( $fk_value );
235 # create record with a recursive build call
236 my $row = $self->build({ source => $linked_tbl, value => $fk_value });
237 return if !$row; # failure
239 # Finally, only return the new values
240 my $rv = {};
241 foreach my $fk ( @$keys ) {
242 my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
243 next if exists $col_values->{ $col };
244 $rv->{ $col } = $row->{ $destcol };
246 return $rv; # success
249 sub _formatSource {
250 my ($params) = @_;
251 my $source = $params->{source} || return;
252 $source =~ s|(\w+)$|$1|;
253 return $source;
256 sub _buildColumnValues {
257 my ($self, $params) = @_;
258 my $source = _formatSource( $params ) || return;
259 my $original_value = $params->{value};
261 my $col_values = {};
262 my @columns = $self->schema->source($source)->columns;
263 my %unique_constraints = $self->schema->source($source)->unique_constraints();
265 my $build_value = 5;
266 # we try max $build_value times if there are unique constraints
267 BUILD_VALUE: while ( $build_value ) {
268 # generate random values for all columns
269 for my $col_name( @columns ) {
270 my $valref = $self->_buildColumnValue({
271 source => $source,
272 column_name => $col_name,
273 value => $original_value,
275 return if !$valref; # failure
276 if( @$valref ) { # could be empty
277 # there will be only one value, but it could be undef
278 $col_values->{$col_name} = $valref->[0];
282 # verify the data would respect each unique constraint
283 # note that this is INCOMPLETE since not all col_values are filled
284 CONSTRAINTS: foreach my $constraint (keys %unique_constraints) {
286 my $condition;
287 my $constraint_columns = $unique_constraints{$constraint};
288 # loop through all constraint columns and build the condition
289 foreach my $constraint_column ( @$constraint_columns ) {
290 # build the filter
291 # if one column does not exist or is undef, skip it
292 # an insert with a null will not trigger the constraint
293 next CONSTRAINTS
294 if !exists $col_values->{ $constraint_column } ||
295 !defined $col_values->{ $constraint_column };
296 $condition->{ $constraint_column } =
297 $col_values->{ $constraint_column };
299 my $count = $self->schema
300 ->resultset( $source )
301 ->search( $condition )
302 ->count();
303 if ( $count > 0 ) {
304 # no point checking more stuff, exit the loop
305 $build_value--;
306 next BUILD_VALUE;
309 last; # you passed all tests
311 return $col_values if $build_value > 0;
313 # if you get here, we have a problem
314 warn "Violation of unique constraint in $source";
315 return;
318 sub _getForeignKeys {
320 # Returns the following arrayref
321 # [ [ source => name, keys => [ col_name => A, col_fk_name => B ] ], ... ]
322 # The array gives source name and keys for each FK constraint
324 my ($self, $params) = @_;
325 my $source = $self->schema->source( $params->{source} );
327 my ( @foreign_keys, $check_dupl );
328 my @relationships = $source->relationships;
329 for my $rel_name( @relationships ) {
330 my $rel_info = $source->relationship_info($rel_name);
331 if( $rel_info->{attrs}->{is_foreign_key_constraint} ) {
332 $rel_info->{source} =~ s/^.*:://g;
333 my $rel = { source => $rel_info->{source} };
335 my @keys;
336 while( my ($col_fk_name, $col_name) = each(%{$rel_info->{cond}}) ) {
337 $col_name =~ s|self.(\w+)|$1|;
338 $col_fk_name =~ s|foreign.(\w+)|$1|;
339 push @keys, {
340 col_name => $col_name,
341 col_fk_name => $col_fk_name,
344 # check if the combination table and keys is unique
345 # so skip double belongs_to relations (as in Biblioitem)
346 my $tag = $rel->{source}. ':'.
347 join ',', sort map { $_->{col_name} } @keys;
348 next if $check_dupl->{$tag};
349 $check_dupl->{$tag} = 1;
350 $rel->{keys} = \@keys;
351 push @foreign_keys, $rel;
354 return \@foreign_keys;
357 sub _storeColumnValues {
358 my ($self, $params) = @_;
359 my $source = $params->{source};
360 my $col_values = $params->{values};
361 my $new_row = $self->schema->resultset( $source )->create( $col_values );
362 return $new_row? { $new_row->get_columns }: {};
365 sub _buildColumnValue {
366 # returns an arrayref if all goes well
367 # an empty arrayref typically means: auto_incr column or fk column
368 # undef means failure
369 my ($self, $params) = @_;
370 my $source = $params->{source};
371 my $value = $params->{value};
372 my $col_name = $params->{column_name};
374 my $col_info = $self->schema->source($source)->column_info($col_name);
376 my $retvalue = [];
377 if( $col_info->{is_auto_increment} ) {
378 if( exists $value->{$col_name} ) {
379 warn "Value not allowed for auto_incr $col_name in $source";
380 return;
382 # otherwise: no need to assign a value
383 } elsif( $col_info->{is_foreign_key} || _should_be_fk($source,$col_name) ) {
384 if( exists $value->{$col_name} ) {
385 if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
386 # This explicit undef is not allowed
387 warn "Null value for $col_name in $source not allowed";
388 return;
390 if( ref( $value->{$col_name} ) ne 'HASH' ) {
391 push @$retvalue, $value->{$col_name};
393 # sub build will handle a passed hash value later on
395 } elsif( ref( $value->{$col_name} ) eq 'HASH' ) {
396 # this is not allowed for a column that is not a FK
397 warn "Hash not allowed for $col_name in $source";
398 return;
399 } elsif( exists $value->{$col_name} ) {
400 if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
401 # This explicit undef is not allowed
402 warn "Null value for $col_name in $source not allowed";
403 return;
405 push @$retvalue, $value->{$col_name};
406 } elsif( exists $self->{default_values}{$source}{$col_name} ) {
407 my $v = $self->{default_values}{$source}{$col_name};
408 $v = &$v() if ref($v) eq 'CODE';
409 push @$retvalue, $v;
410 } else {
411 my $data_type = $col_info->{data_type};
412 $data_type =~ s| |_|;
413 if( my $hdlr = $self->{gen_type}->{$data_type} ) {
414 push @$retvalue, &$hdlr( $self, { info => $col_info } );
415 } else {
416 warn "Unknown type $data_type for $col_name in $source";
417 return;
420 return $retvalue;
423 sub _should_be_fk {
424 # This sub is only needed for inconsistencies in the schema
425 # A column is not marked as FK, but a belongs_to relation is defined
426 my ( $source, $column ) = @_;
427 my $inconsistencies = {
428 'Item.biblionumber' => 1, #FIXME: Please remove me when I become FK
430 return $inconsistencies->{ "$source.$column" };
433 sub _gen_type {
434 return {
435 tinyint => \&_gen_int,
436 smallint => \&_gen_int,
437 mediumint => \&_gen_int,
438 integer => \&_gen_int,
439 bigint => \&_gen_int,
441 float => \&_gen_real,
442 decimal => \&_gen_real,
443 double_precision => \&_gen_real,
445 timestamp => \&_gen_datetime,
446 datetime => \&_gen_datetime,
447 date => \&_gen_date,
449 char => \&_gen_text,
450 varchar => \&_gen_text,
451 tinytext => \&_gen_text,
452 text => \&_gen_text,
453 mediumtext => \&_gen_text,
454 longtext => \&_gen_text,
456 set => \&_gen_set_enum,
457 enum => \&_gen_set_enum,
459 tinyblob => \&_gen_blob,
460 mediumblob => \&_gen_blob,
461 blob => \&_gen_blob,
462 longblob => \&_gen_blob,
466 sub _gen_int {
467 my ($self, $params) = @_;
468 my $data_type = $params->{info}->{data_type};
470 my $max = 1;
471 if( $data_type eq 'tinyint' ) {
472 $max = 127;
474 elsif( $data_type eq 'smallint' ) {
475 $max = 32767;
477 elsif( $data_type eq 'mediumint' ) {
478 $max = 8388607;
480 elsif( $data_type eq 'integer' ) {
481 $max = 2147483647;
483 elsif( $data_type eq 'bigint' ) {
484 $max = 9223372036854775807;
486 return int( rand($max+1) );
489 sub _gen_real {
490 my ($self, $params) = @_;
491 my $max = 10 ** 38;
492 if( defined( $params->{info}->{size} ) ) {
493 $max = 10 ** ($params->{info}->{size}->[0] - $params->{info}->{size}->[1]);
495 $max = 10 ** 5 if $max > 10 ** 5;
496 return sprintf("%.2f", rand($max-0.1));
499 sub _gen_date {
500 my ($self, $params) = @_;
501 return $self->schema->storage->datetime_parser->format_date(dt_from_string)
504 sub _gen_datetime {
505 my ($self, $params) = @_;
506 return $self->schema->storage->datetime_parser->format_datetime(dt_from_string);
509 sub _gen_text {
510 my ($self, $params) = @_;
511 # From perldoc String::Random
512 my $size = $params->{info}{size} // 10;
513 $size -= alt_rand(0.5 * $size);
514 my $regex = $size > 1
515 ? '[A-Za-z][A-Za-z0-9_]{'.($size-1).'}'
516 : '[A-Za-z]';
517 my $random = String::Random->new( rand_gen => \&alt_rand );
518 # rand_gen is only supported from 0.27 onward
519 return $random->randregex($regex);
522 sub alt_rand { #Alternative randomizer
523 my ($max) = @_;
524 my $random = Bytes::Random::Secure->new( NonBlocking => 1 );
525 my $r = $random->irand / 2**32;
526 return int( $r * $max );
529 sub _gen_set_enum {
530 my ($self, $params) = @_;
531 return $params->{info}->{extra}->{list}->[0];
534 sub _gen_blob {
535 my ($self, $params) = @_;;
536 return 'b';
539 sub _gen_default_values {
540 my ($self) = @_;
541 return {
542 Borrower => {
543 login_attempts => 0,
544 gonenoaddress => undef,
545 lost => undef,
546 debarred => undef,
547 borrowernotes => '',
549 Item => {
550 notforloan => 0,
551 itemlost => 0,
552 withdrawn => 0,
553 restricted => 0,
554 damaged => 0,
555 materials => undef,
556 more_subfields_xml => undef,
558 Category => {
559 enrolmentfee => 0,
560 reservefee => 0,
561 # Not X, used for statistics
562 category_type => sub { return [ qw( A C S I P ) ]->[int(rand(5))] },
563 min_password_length => undef,
564 require_strong_password => undef,
566 Branch => {
567 pickup_location => 0,
569 Reserve => {
570 non_priority => 0,
572 Itemtype => {
573 rentalcharge => 0,
574 rentalcharge_daily => 0,
575 rentalcharge_hourly => 0,
576 defaultreplacecost => 0,
577 processfee => 0,
578 notforloan => 0,
580 Aqbookseller => {
581 tax_rate => 0,
582 discount => 0,
584 AuthHeader => {
585 marcxml => '',
590 =head1 NAME
592 t::lib::TestBuilder.pm - Koha module to create test records
594 =head1 SYNOPSIS
596 use t::lib::TestBuilder;
597 my $builder = t::lib::TestBuilder->new;
599 # The following call creates a patron, linked to branch CPL.
600 # Surname is provided, other columns are randomly generated.
601 # Branch CPL is created if it does not exist.
602 my $patron = $builder->build({
603 source => 'Borrower',
604 value => { surname => 'Jansen', branchcode => 'CPL' },
607 =head1 DESCRIPTION
609 This module automatically creates database records for you.
610 If needed, records for foreign keys are created too.
611 Values will be randomly generated if not passed to TestBuilder.
612 Note that you should wrap these actions in a transaction yourself.
614 =head1 METHODS
616 =head2 new
618 my $builder = t::lib::TestBuilder->new;
620 Constructor - Returns the object TestBuilder
622 =head2 schema
624 my $schema = $builder->schema;
626 Getter - Returns the schema of DBIx::Class
628 =head2 delete
630 $builder->delete({
631 source => $source,
632 records => $patron, # OR: records => [ $patron, ... ],
635 Delete individual records, created by builder.
636 Returns the number of delete attempts, or undef.
638 =head2 build
640 $builder->build({ source => $source_name, value => $value });
642 Create a test record in the table, represented by $source_name.
643 The name is required and must conform to the DBIx::Class schema.
644 Values may be specified by the optional $value hashref. Will be
645 randomized otherwise.
646 If needed, TestBuilder creates linked records for foreign keys.
647 Returns the values of the new record as a hashref, or undef if
648 the record could not be created.
650 Note that build also supports recursive hash references inside the
651 value hash for foreign key columns, like:
652 value => {
653 column1 => 'some_value',
654 fk_col2 => {
655 columnA => 'another_value',
658 The hash for fk_col2 here means: create a linked record with build
659 where columnA has this value. In case of a composite FK the hashes
660 are merged.
662 Realize that passing primary key values to build may result in undef
663 if a record with that primary key already exists.
665 =head2 build_object
667 Given a plural Koha::Object-derived class, it creates a random element, and
668 returns the corresponding Koha::Object.
670 my $patron = $builder->build_object({ class => 'Koha::Patrons' [, value => { ... }] });
672 =head1 AUTHOR
674 Yohann Dufour <yohann.dufour@biblibre.com>
676 Koha Development Team
678 =head1 COPYRIGHT
680 Copyright 2014 - Biblibre SARL
682 =head1 LICENSE
684 This file is part of Koha.
686 Koha is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
687 the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
689 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.
691 You should have received a copy of the GNU General Public License along with Koha; if not, see <http://www.gnu.org/licenses>.
693 =cut