Bug 19463: (follow-up) Cosmetic changes
[koha.git] / t / lib / TestBuilder.pm
blob4ec6dba574dc8659cc8950afc0062d063d4b46b8
1 package t::lib::TestBuilder;
3 use Modern::Perl;
5 use Koha::Database;
7 use Bytes::Random::Secure;
8 use Carp;
9 use Module::Load;
10 use String::Random;
12 sub new {
13 my ($class) = @_;
14 my $self = {};
15 bless( $self, $class );
17 $self->schema( Koha::Database->new()->schema );
18 $self->schema->storage->sql_maker->quote_char('`');
20 $self->{gen_type} = _gen_type();
21 $self->{default_values} = _gen_default_values();
22 return $self;
25 sub schema {
26 my ($self, $schema) = @_;
28 if( defined( $schema ) ) {
29 $self->{schema} = $schema;
31 return $self->{schema};
34 # sub clear has been obsoleted; use delete_all from the schema resultset
36 sub delete {
37 my ( $self, $params ) = @_;
38 my $source = $params->{source} || return;
39 my @recs = ref( $params->{records} ) eq 'ARRAY'?
40 @{$params->{records}}: ( $params->{records} // () );
41 # tables without PK are not supported
42 my @pk = $self->schema->source( $source )->primary_columns;
43 return if !@pk;
44 my $rv = 0;
45 foreach my $rec ( @recs ) {
46 # delete only works when you supply full primary key values
47 # $cond does not include searches for undef (not allowed in PK)
48 my $cond = { map { defined $rec->{$_}? ($_, $rec->{$_}): (); } @pk };
49 next if keys %$cond < @pk;
50 $self->schema->resultset( $source )->search( $cond )->delete;
51 # we clear the pk columns in the supplied hash
52 # this indirectly signals at least an attempt to delete
53 map { delete $rec->{$_}; } @pk;
54 $rv++;
56 return $rv;
59 sub build_object {
60 my ( $self, $params ) = @_;
62 my $class = $params->{class};
63 my $value = $params->{value};
65 if ( not defined $class ) {
66 carp "Missing class param";
67 return;
70 load $class;
71 my $source = $class->_type;
72 my @pks = $self->schema->source( $class->_type )->primary_columns;
74 my $hashref = $self->build({ source => $source, value => $value });
75 my @ids;
77 foreach my $pk ( @pks ) {
78 push @ids, $hashref->{ $pk };
81 my $object = $class->find( @ids );
83 return $object;
86 sub build {
87 # build returns a hash of column values for a created record, or undef
88 # build does NOT update a record, or pass back values of an existing record
89 my ($self, $params) = @_;
90 my $source = $params->{source};
91 if( !$source ) {
92 carp "Source parameter not specified!";
93 return;
95 my $value = $params->{value};
97 my @unknowns = grep( !/^(source|value)$/, keys %{ $params });
98 carp "Unknown parameter(s): ", join( ', ', @unknowns ) if scalar @unknowns;
100 my $col_values = $self->_buildColumnValues({
101 source => $source,
102 value => $value,
104 return if !$col_values; # did not meet unique constraints?
106 # loop thru all fk and create linked records if needed
107 # fills remaining entries in $col_values
108 my $foreign_keys = $self->_getForeignKeys( { source => $source } );
109 for my $fk ( @$foreign_keys ) {
110 # skip when FK points to itself: e.g. borrowers:guarantorid
111 next if $fk->{source} eq $source;
112 my $keys = $fk->{keys};
113 my $tbl = $fk->{source};
114 my $res = $self->_create_links( $tbl, $keys, $col_values, $value );
115 return if !$res; # failed: no need to go further
116 foreach( keys %$res ) { # save new values
117 $col_values->{$_} = $res->{$_};
121 # store this record and return hashref
122 return $self->_storeColumnValues({
123 source => $source,
124 values => $col_values,
128 # ------------------------------------------------------------------------------
129 # Internal helper routines
131 sub _create_links {
132 # returns undef for failure to create linked records
133 # otherwise returns hashref containing new column values for parent record
134 my ( $self, $linked_tbl, $keys, $col_values, $value ) = @_;
136 my $fk_value = {};
137 my ( $cnt_scalar, $cnt_null ) = ( 0, 0 );
139 # First, collect all values for creating a linked record (if needed)
140 foreach my $fk ( @$keys ) {
141 my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
142 if( ref( $value->{$col} ) eq 'HASH' ) {
143 # add all keys from the FK hash
144 $fk_value = { %{ $value->{$col} }, %$fk_value };
146 if( exists $col_values->{$col} ) {
147 # add specific value (this does not necessarily exclude some
148 # values from the hash in the preceding if)
149 $fk_value->{ $destcol } = $col_values->{ $col };
150 $cnt_scalar++;
151 $cnt_null++ if !defined( $col_values->{$col} );
155 # If we saw all FK columns, first run the following checks
156 if( $cnt_scalar == @$keys ) {
157 # if one or more fk cols are null, the FK constraint will not be forced
158 return {} if $cnt_null > 0;
159 # does the record exist already?
160 return {} if $self->schema->resultset($linked_tbl)->find( $fk_value );
162 # create record with a recursive build call
163 my $row = $self->build({ source => $linked_tbl, value => $fk_value });
164 return if !$row; # failure
166 # Finally, only return the new values
167 my $rv = {};
168 foreach my $fk ( @$keys ) {
169 my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
170 next if exists $col_values->{ $col };
171 $rv->{ $col } = $row->{ $destcol };
173 return $rv; # success
176 sub _formatSource {
177 my ($params) = @_;
178 my $source = $params->{source} || return;
179 $source =~ s|(\w+)$|$1|;
180 return $source;
183 sub _buildColumnValues {
184 my ($self, $params) = @_;
185 my $source = _formatSource( $params ) || return;
186 my $original_value = $params->{value};
188 my $col_values = {};
189 my @columns = $self->schema->source($source)->columns;
190 my %unique_constraints = $self->schema->source($source)->unique_constraints();
192 my $build_value = 5;
193 # we try max $build_value times if there are unique constraints
194 BUILD_VALUE: while ( $build_value ) {
195 # generate random values for all columns
196 for my $col_name( @columns ) {
197 my $valref = $self->_buildColumnValue({
198 source => $source,
199 column_name => $col_name,
200 value => $original_value,
202 return if !$valref; # failure
203 if( @$valref ) { # could be empty
204 # there will be only one value, but it could be undef
205 $col_values->{$col_name} = $valref->[0];
209 # verify the data would respect each unique constraint
210 # note that this is INCOMPLETE since not all col_values are filled
211 CONSTRAINTS: foreach my $constraint (keys %unique_constraints) {
213 my $condition;
214 my $constraint_columns = $unique_constraints{$constraint};
215 # loop through all constraint columns and build the condition
216 foreach my $constraint_column ( @$constraint_columns ) {
217 # build the filter
218 # if one column does not exist or is undef, skip it
219 # an insert with a null will not trigger the constraint
220 next CONSTRAINTS
221 if !exists $col_values->{ $constraint_column } ||
222 !defined $col_values->{ $constraint_column };
223 $condition->{ $constraint_column } =
224 $col_values->{ $constraint_column };
226 my $count = $self->schema
227 ->resultset( $source )
228 ->search( $condition )
229 ->count();
230 if ( $count > 0 ) {
231 # no point checking more stuff, exit the loop
232 $build_value--;
233 next BUILD_VALUE;
236 last; # you passed all tests
238 return $col_values if $build_value > 0;
240 # if you get here, we have a problem
241 warn "Violation of unique constraint in $source";
242 return;
245 sub _getForeignKeys {
247 # Returns the following arrayref
248 # [ [ source => name, keys => [ col_name => A, col_fk_name => B ] ], ... ]
249 # The array gives source name and keys for each FK constraint
251 my ($self, $params) = @_;
252 my $source = $self->schema->source( $params->{source} );
254 my ( @foreign_keys, $check_dupl );
255 my @relationships = $source->relationships;
256 for my $rel_name( @relationships ) {
257 my $rel_info = $source->relationship_info($rel_name);
258 if( $rel_info->{attrs}->{is_foreign_key_constraint} ) {
259 $rel_info->{source} =~ s/^.*:://g;
260 my $rel = { source => $rel_info->{source} };
262 my @keys;
263 while( my ($col_fk_name, $col_name) = each(%{$rel_info->{cond}}) ) {
264 $col_name =~ s|self.(\w+)|$1|;
265 $col_fk_name =~ s|foreign.(\w+)|$1|;
266 push @keys, {
267 col_name => $col_name,
268 col_fk_name => $col_fk_name,
271 # check if the combination table and keys is unique
272 # so skip double belongs_to relations (as in Biblioitem)
273 my $tag = $rel->{source}. ':'.
274 join ',', sort map { $_->{col_name} } @keys;
275 next if $check_dupl->{$tag};
276 $check_dupl->{$tag} = 1;
277 $rel->{keys} = \@keys;
278 push @foreign_keys, $rel;
281 return \@foreign_keys;
284 sub _storeColumnValues {
285 my ($self, $params) = @_;
286 my $source = $params->{source};
287 my $col_values = $params->{values};
288 my $new_row = $self->schema->resultset( $source )->create( $col_values );
289 return $new_row? { $new_row->get_columns }: {};
292 sub _buildColumnValue {
293 # returns an arrayref if all goes well
294 # an empty arrayref typically means: auto_incr column or fk column
295 # undef means failure
296 my ($self, $params) = @_;
297 my $source = $params->{source};
298 my $value = $params->{value};
299 my $col_name = $params->{column_name};
301 my $col_info = $self->schema->source($source)->column_info($col_name);
303 my $retvalue = [];
304 if( $col_info->{is_auto_increment} ) {
305 if( exists $value->{$col_name} ) {
306 warn "Value not allowed for auto_incr $col_name in $source";
307 return;
309 # otherwise: no need to assign a value
310 } elsif( $col_info->{is_foreign_key} || _should_be_fk($source,$col_name) ) {
311 if( exists $value->{$col_name} ) {
312 if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
313 # This explicit undef is not allowed
314 warn "Null value for $col_name in $source not allowed";
315 return;
317 if( ref( $value->{$col_name} ) ne 'HASH' ) {
318 push @$retvalue, $value->{$col_name};
320 # sub build will handle a passed hash value later on
322 } elsif( ref( $value->{$col_name} ) eq 'HASH' ) {
323 # this is not allowed for a column that is not a FK
324 warn "Hash not allowed for $col_name in $source";
325 return;
326 } elsif( exists $value->{$col_name} ) {
327 if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
328 # This explicit undef is not allowed
329 warn "Null value for $col_name in $source not allowed";
330 return;
332 push @$retvalue, $value->{$col_name};
333 } elsif( exists $self->{default_values}{$source}{$col_name} ) {
334 push @$retvalue, $self->{default_values}{$source}{$col_name};
335 } else {
336 my $data_type = $col_info->{data_type};
337 $data_type =~ s| |_|;
338 if( my $hdlr = $self->{gen_type}->{$data_type} ) {
339 push @$retvalue, &$hdlr( $self, { info => $col_info } );
340 } else {
341 warn "Unknown type $data_type for $col_name in $source";
342 return;
345 return $retvalue;
348 sub _should_be_fk {
349 # This sub is only needed for inconsistencies in the schema
350 # A column is not marked as FK, but a belongs_to relation is defined
351 my ( $source, $column ) = @_;
352 my $inconsistencies = {
353 'Item.biblionumber' => 1, #FIXME: Please remove me when I become FK
355 return $inconsistencies->{ "$source.$column" };
358 sub _gen_type {
359 return {
360 tinyint => \&_gen_int,
361 smallint => \&_gen_int,
362 mediumint => \&_gen_int,
363 integer => \&_gen_int,
364 bigint => \&_gen_int,
366 float => \&_gen_real,
367 decimal => \&_gen_real,
368 double_precision => \&_gen_real,
370 timestamp => \&_gen_datetime,
371 datetime => \&_gen_datetime,
372 date => \&_gen_date,
374 char => \&_gen_text,
375 varchar => \&_gen_text,
376 tinytext => \&_gen_text,
377 text => \&_gen_text,
378 mediumtext => \&_gen_text,
379 longtext => \&_gen_text,
381 set => \&_gen_set_enum,
382 enum => \&_gen_set_enum,
384 tinyblob => \&_gen_blob,
385 mediumblob => \&_gen_blob,
386 blob => \&_gen_blob,
387 longblob => \&_gen_blob,
391 sub _gen_int {
392 my ($self, $params) = @_;
393 my $data_type = $params->{info}->{data_type};
395 my $max = 1;
396 if( $data_type eq 'tinyint' ) {
397 $max = 127;
399 elsif( $data_type eq 'smallint' ) {
400 $max = 32767;
402 elsif( $data_type eq 'mediumint' ) {
403 $max = 8388607;
405 elsif( $data_type eq 'integer' ) {
406 $max = 2147483647;
408 elsif( $data_type eq 'bigint' ) {
409 $max = 9223372036854775807;
411 return int( rand($max+1) );
414 sub _gen_real {
415 my ($self, $params) = @_;
416 my $max = 10 ** 38;
417 if( defined( $params->{info}->{size} ) ) {
418 $max = 10 ** ($params->{info}->{size}->[0] - $params->{info}->{size}->[1]);
420 return rand($max) + 1;
423 sub _gen_date {
424 my ($self, $params) = @_;
425 return $self->schema->storage->datetime_parser->format_date(DateTime->now())
428 sub _gen_datetime {
429 my ($self, $params) = @_;
430 return $self->schema->storage->datetime_parser->format_datetime(DateTime->now());
433 sub _gen_text {
434 my ($self, $params) = @_;
435 # From perldoc String::Random
436 my $size = $params->{info}{size} // 10;
437 $size -= alt_rand(0.5 * $size);
438 my $regex = $size > 1
439 ? '[A-Za-z][A-Za-z0-9_]{'.($size-1).'}'
440 : '[A-Za-z]';
441 my $random = String::Random->new( rand_gen => \&alt_rand );
442 # rand_gen is only supported from 0.27 onward
443 return $random->randregex($regex);
446 sub alt_rand { #Alternative randomizer
447 my ($max) = @_;
448 my $random = Bytes::Random::Secure->new( NonBlocking => 1 );
449 my $r = $random->irand / 2**32;
450 return int( $r * $max );
453 sub _gen_set_enum {
454 my ($self, $params) = @_;
455 return $params->{info}->{extra}->{list}->[0];
458 sub _gen_blob {
459 my ($self, $params) = @_;;
460 return 'b';
463 sub _gen_default_values {
464 my ($self) = @_;
465 return {
466 Borrower => {
467 login_attempts => 0,
468 gonenoaddress => undef,
469 lost => undef,
470 debarred => undef,
471 borrowernotes => '',
473 Item => {
474 more_subfields_xml => undef,
479 =head1 NAME
481 t::lib::TestBuilder.pm - Koha module to create test records
483 =head1 SYNOPSIS
485 use t::lib::TestBuilder;
486 my $builder = t::lib::TestBuilder->new;
488 # The following call creates a patron, linked to branch CPL.
489 # Surname is provided, other columns are randomly generated.
490 # Branch CPL is created if it does not exist.
491 my $patron = $builder->build({
492 source => 'Borrower',
493 value => { surname => 'Jansen', branchcode => 'CPL' },
496 =head1 DESCRIPTION
498 This module automatically creates database records for you.
499 If needed, records for foreign keys are created too.
500 Values will be randomly generated if not passed to TestBuilder.
501 Note that you should wrap these actions in a transaction yourself.
503 =head1 METHODS
505 =head2 new
507 my $builder = t::lib::TestBuilder->new;
509 Constructor - Returns the object TestBuilder
511 =head2 schema
513 my $schema = $builder->schema;
515 Getter - Returns the schema of DBIx::Class
517 =head2 delete
519 $builder->delete({
520 source => $source,
521 records => $patron, # OR: records => [ $patron, ... ],
524 Delete individual records, created by builder.
525 Returns the number of delete attempts, or undef.
527 =head2 build
529 $builder->build({ source => $source_name, value => $value });
531 Create a test record in the table, represented by $source_name.
532 The name is required and must conform to the DBIx::Class schema.
533 Values may be specified by the optional $value hashref. Will be
534 randomized otherwise.
535 If needed, TestBuilder creates linked records for foreign keys.
536 Returns the values of the new record as a hashref, or undef if
537 the record could not be created.
539 Note that build also supports recursive hash references inside the
540 value hash for foreign key columns, like:
541 value => {
542 column1 => 'some_value',
543 fk_col2 => {
544 columnA => 'another_value',
547 The hash for fk_col2 here means: create a linked record with build
548 where columnA has this value. In case of a composite FK the hashes
549 are merged.
551 Realize that passing primary key values to build may result in undef
552 if a record with that primary key already exists.
554 =head2 build_object
556 Given a plural Koha::Object-derived class, it creates a random element, and
557 returns the corresponding Koha::Object.
559 my $patron = $builder->build_object({ class => 'Koha::Patrons' [, value => { ... }] });
561 =head1 AUTHOR
563 Yohann Dufour <yohann.dufour@biblibre.com>
565 Koha Development Team
567 =head1 COPYRIGHT
569 Copyright 2014 - Biblibre SARL
571 =head1 LICENSE
573 This file is part of Koha.
575 Koha is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
576 the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
578 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.
580 You should have received a copy of the GNU General Public License along with Koha; if not, see <http://www.gnu.org/licenses>.
582 =cut