Bug 17726: TestBuilder - Add default values
[koha.git] / t / lib / TestBuilder.pm
blob0d312d0c54cb080fcf754ab14f16e81fde04deca
1 package t::lib::TestBuilder;
3 use Modern::Perl;
4 use Koha::Database;
5 use String::Random;
7 sub new {
8 my ($class) = @_;
9 my $self = {};
10 bless( $self, $class );
12 $self->schema( Koha::Database->new()->schema );
13 $self->schema->storage->sql_maker->quote_char('`');
15 $self->{gen_type} = _gen_type();
16 $self->{default_values} = _gen_default_values();
17 return $self;
20 sub schema {
21 my ($self, $schema) = @_;
23 if( defined( $schema ) ) {
24 $self->{schema} = $schema;
26 return $self->{schema};
29 # sub clear has been obsoleted; use delete_all from the schema resultset
31 sub delete {
32 my ( $self, $params ) = @_;
33 my $source = $params->{source} || return;
34 my @recs = ref( $params->{records} ) eq 'ARRAY'?
35 @{$params->{records}}: ( $params->{records} // () );
36 # tables without PK are not supported
37 my @pk = $self->schema->source( $source )->primary_columns;
38 return if !@pk;
39 my $rv = 0;
40 foreach my $rec ( @recs ) {
41 # delete only works when you supply full primary key values
42 # $cond does not include searches for undef (not allowed in PK)
43 my $cond = { map { defined $rec->{$_}? ($_, $rec->{$_}): (); } @pk };
44 next if keys %$cond < @pk;
45 $self->schema->resultset( $source )->search( $cond )->delete;
46 # we clear the pk columns in the supplied hash
47 # this indirectly signals at least an attempt to delete
48 map { delete $rec->{$_}; } @pk;
49 $rv++;
51 return $rv;
54 sub build {
55 # build returns a hash of column values for a created record, or undef
56 # build does NOT update a record, or pass back values of an existing record
57 my ($self, $params) = @_;
58 my $source = $params->{source} || return;
59 my $value = $params->{value};
61 my $col_values = $self->_buildColumnValues({
62 source => $source,
63 value => $value,
64 });
65 return if !$col_values; # did not meet unique constraints?
67 # loop thru all fk and create linked records if needed
68 # fills remaining entries in $col_values
69 my $foreign_keys = $self->_getForeignKeys( { source => $source } );
70 for my $fk ( @$foreign_keys ) {
71 # skip when FK points to itself: e.g. borrowers:guarantorid
72 next if $fk->{source} eq $source;
73 my $keys = $fk->{keys};
74 my $tbl = $fk->{source};
75 my $res = $self->_create_links( $tbl, $keys, $col_values, $value );
76 return if !$res; # failed: no need to go further
77 foreach( keys %$res ) { # save new values
78 $col_values->{$_} = $res->{$_};
82 # store this record and return hashref
83 return $self->_storeColumnValues({
84 source => $source,
85 values => $col_values,
86 });
89 # ------------------------------------------------------------------------------
90 # Internal helper routines
92 sub _create_links {
93 # returns undef for failure to create linked records
94 # otherwise returns hashref containing new column values for parent record
95 my ( $self, $linked_tbl, $keys, $col_values, $value ) = @_;
97 my $fk_value = {};
98 my ( $cnt_scalar, $cnt_null ) = ( 0, 0 );
100 # First, collect all values for creating a linked record (if needed)
101 foreach my $fk ( @$keys ) {
102 my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
103 if( ref( $value->{$col} ) eq 'HASH' ) {
104 # add all keys from the FK hash
105 $fk_value = { %{ $value->{$col} }, %$fk_value };
107 if( exists $col_values->{$col} ) {
108 # add specific value (this does not necessarily exclude some
109 # values from the hash in the preceding if)
110 $fk_value->{ $destcol } = $col_values->{ $col };
111 $cnt_scalar++;
112 $cnt_null++ if !defined( $col_values->{$col} );
116 # If we saw all FK columns, first run the following checks
117 if( $cnt_scalar == @$keys ) {
118 # if one or more fk cols are null, the FK constraint will not be forced
119 return {} if $cnt_null > 0;
120 # does the record exist already?
121 return {} if $self->schema->resultset($linked_tbl)->find( $fk_value );
123 # create record with a recursive build call
124 my $row = $self->build({ source => $linked_tbl, value => $fk_value });
125 return if !$row; # failure
127 # Finally, only return the new values
128 my $rv = {};
129 foreach my $fk ( @$keys ) {
130 my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
131 next if exists $col_values->{ $col };
132 $rv->{ $col } = $row->{ $destcol };
134 return $rv; # success
137 sub _formatSource {
138 my ($params) = @_;
139 my $source = $params->{source} || return;
140 $source =~ s|(\w+)$|$1|;
141 return $source;
144 sub _buildColumnValues {
145 my ($self, $params) = @_;
146 my $source = _formatSource( $params ) || return;
147 my $original_value = $params->{value};
149 my $col_values = {};
150 my @columns = $self->schema->source($source)->columns;
151 my %unique_constraints = $self->schema->source($source)->unique_constraints();
153 my $build_value = 3;
154 # we try max three times if there are unique constraints
155 BUILD_VALUE: while ( $build_value ) {
156 # generate random values for all columns
157 for my $col_name( @columns ) {
158 my $valref = $self->_buildColumnValue({
159 source => $source,
160 column_name => $col_name,
161 value => $original_value,
163 return if !$valref; # failure
164 if( @$valref ) { # could be empty
165 # there will be only one value, but it could be undef
166 $col_values->{$col_name} = $valref->[0];
170 # verify the data would respect each unique constraint
171 # note that this is INCOMPLETE since not all col_values are filled
172 CONSTRAINTS: foreach my $constraint (keys %unique_constraints) {
174 my $condition;
175 my $constraint_columns = $unique_constraints{$constraint};
176 # loop through all constraint columns and build the condition
177 foreach my $constraint_column ( @$constraint_columns ) {
178 # build the filter
179 # if one column does not exist or is undef, skip it
180 # an insert with a null will not trigger the constraint
181 next CONSTRAINTS
182 if !exists $col_values->{ $constraint_column } ||
183 !defined $col_values->{ $constraint_column };
184 $condition->{ $constraint_column } =
185 $col_values->{ $constraint_column };
187 my $count = $self->schema
188 ->resultset( $source )
189 ->search( $condition )
190 ->count();
191 if ( $count > 0 ) {
192 # no point checking more stuff, exit the loop
193 $build_value--;
194 next BUILD_VALUE;
197 last; # you passed all tests
199 return $col_values if $build_value > 0;
201 # if you get here, we have a problem
202 warn "Violation of unique constraint in $source";
203 return;
206 sub _getForeignKeys {
208 # Returns the following arrayref
209 # [ [ source => name, keys => [ col_name => A, col_fk_name => B ] ], ... ]
210 # The array gives source name and keys for each FK constraint
212 my ($self, $params) = @_;
213 my $source = $self->schema->source( $params->{source} );
215 my ( @foreign_keys, $check_dupl );
216 my @relationships = $source->relationships;
217 for my $rel_name( @relationships ) {
218 my $rel_info = $source->relationship_info($rel_name);
219 if( $rel_info->{attrs}->{is_foreign_key_constraint} ) {
220 $rel_info->{source} =~ s/^.*:://g;
221 my $rel = { source => $rel_info->{source} };
223 my @keys;
224 while( my ($col_fk_name, $col_name) = each(%{$rel_info->{cond}}) ) {
225 $col_name =~ s|self.(\w+)|$1|;
226 $col_fk_name =~ s|foreign.(\w+)|$1|;
227 push @keys, {
228 col_name => $col_name,
229 col_fk_name => $col_fk_name,
232 # check if the combination table and keys is unique
233 # so skip double belongs_to relations (as in Biblioitem)
234 my $tag = $rel->{source}. ':'.
235 join ',', sort map { $_->{col_name} } @keys;
236 next if $check_dupl->{$tag};
237 $check_dupl->{$tag} = 1;
238 $rel->{keys} = \@keys;
239 push @foreign_keys, $rel;
242 return \@foreign_keys;
245 sub _storeColumnValues {
246 my ($self, $params) = @_;
247 my $source = $params->{source};
248 my $col_values = $params->{values};
249 my $new_row = $self->schema->resultset( $source )->create( $col_values );
250 return $new_row? { $new_row->get_columns }: {};
253 sub _buildColumnValue {
254 # returns an arrayref if all goes well
255 # an empty arrayref typically means: auto_incr column or fk column
256 # undef means failure
257 my ($self, $params) = @_;
258 my $source = $params->{source};
259 my $value = $params->{value};
260 my $col_name = $params->{column_name};
262 my $col_info = $self->schema->source($source)->column_info($col_name);
264 my $retvalue = [];
265 if( $col_info->{is_auto_increment} ) {
266 if( exists $value->{$col_name} ) {
267 warn "Value not allowed for auto_incr $col_name in $source";
268 return;
270 # otherwise: no need to assign a value
271 } elsif( $col_info->{is_foreign_key} || _should_be_fk($source,$col_name) ) {
272 if( exists $value->{$col_name} ) {
273 if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
274 # This explicit undef is not allowed
275 warn "Null value for $col_name in $source not allowed";
276 return;
278 if( ref( $value->{$col_name} ) ne 'HASH' ) {
279 push @$retvalue, $value->{$col_name};
281 # sub build will handle a passed hash value later on
283 } elsif( ref( $value->{$col_name} ) eq 'HASH' ) {
284 # this is not allowed for a column that is not a FK
285 warn "Hash not allowed for $col_name in $source";
286 return;
287 } elsif( exists $value->{$col_name} ) {
288 if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
289 # This explicit undef is not allowed
290 warn "Null value for $col_name in $source not allowed";
291 return;
293 push @$retvalue, $value->{$col_name};
294 } elsif( exists $self->{default_values}{$source}{$col_name} ) {
295 push @$retvalue, $self->{default_values}{$source}{$col_name};
296 } else {
297 my $data_type = $col_info->{data_type};
298 $data_type =~ s| |_|;
299 if( my $hdlr = $self->{gen_type}->{$data_type} ) {
300 push @$retvalue, &$hdlr( $self, { info => $col_info } );
301 } else {
302 warn "Unknown type $data_type for $col_name in $source";
303 return;
306 return $retvalue;
309 sub _should_be_fk {
310 # This sub is only needed for inconsistencies in the schema
311 # A column is not marked as FK, but a belongs_to relation is defined
312 my ( $source, $column ) = @_;
313 my $inconsistencies = {
314 'Item.biblionumber' => 1, #FIXME: Please remove me when I become FK
316 return $inconsistencies->{ "$source.$column" };
319 sub _gen_type {
320 return {
321 tinyint => \&_gen_int,
322 smallint => \&_gen_int,
323 mediumint => \&_gen_int,
324 integer => \&_gen_int,
325 bigint => \&_gen_int,
327 float => \&_gen_real,
328 decimal => \&_gen_real,
329 double_precision => \&_gen_real,
331 timestamp => \&_gen_datetime,
332 datetime => \&_gen_datetime,
333 date => \&_gen_date,
335 char => \&_gen_text,
336 varchar => \&_gen_text,
337 tinytext => \&_gen_text,
338 text => \&_gen_text,
339 mediumtext => \&_gen_text,
340 longtext => \&_gen_text,
342 set => \&_gen_set_enum,
343 enum => \&_gen_set_enum,
345 tinyblob => \&_gen_blob,
346 mediumblob => \&_gen_blob,
347 blob => \&_gen_blob,
348 longblob => \&_gen_blob,
352 sub _gen_int {
353 my ($self, $params) = @_;
354 my $data_type = $params->{info}->{data_type};
356 my $max = 1;
357 if( $data_type eq 'tinyint' ) {
358 $max = 127;
360 elsif( $data_type eq 'smallint' ) {
361 $max = 32767;
363 elsif( $data_type eq 'mediumint' ) {
364 $max = 8388607;
366 elsif( $data_type eq 'integer' ) {
367 $max = 2147483647;
369 elsif( $data_type eq 'bigint' ) {
370 $max = 9223372036854775807;
372 return int( rand($max+1) );
375 sub _gen_real {
376 my ($self, $params) = @_;
377 my $max = 10 ** 38;
378 if( defined( $params->{info}->{size} ) ) {
379 $max = 10 ** ($params->{info}->{size}->[0] - $params->{info}->{size}->[1]);
381 return rand($max) + 1;
384 sub _gen_date {
385 my ($self, $params) = @_;
386 return $self->schema->storage->datetime_parser->format_date(DateTime->now())
389 sub _gen_datetime {
390 my ($self, $params) = @_;
391 return $self->schema->storage->datetime_parser->format_datetime(DateTime->now());
394 sub _gen_text {
395 my ($self, $params) = @_;
396 # From perldoc String::Random
397 # max: specify the maximum number of characters to return for * and other
398 # regular expression patters that don't return a fixed number of characters
399 my $regex = '[A-Za-z][A-Za-z0-9_]*';
400 my $size = $params->{info}{size};
401 if ( defined $size and $size > 1 ) {
402 $size--;
403 } elsif ( defined $size and $size == 1 ) {
404 $regex = '[A-Za-z]';
406 my $random = String::Random->new( max => $size );
407 return $random->randregex($regex);
410 sub _gen_set_enum {
411 my ($self, $params) = @_;
412 return $params->{info}->{extra}->{list}->[0];
415 sub _gen_blob {
416 my ($self, $params) = @_;;
417 return 'b';
420 sub _gen_default_values {
421 my ($self) = @_;
422 return {
423 Item => {
424 more_subfields_xml => undef,
426 Biblioitem => {
427 marcxml => undef,
432 =head1 NAME
434 t::lib::TestBuilder.pm - Koha module to create test records
436 =head1 SYNOPSIS
438 use t::lib::TestBuilder;
439 my $builder = t::lib::TestBuilder->new;
441 # The following call creates a patron, linked to branch CPL.
442 # Surname is provided, other columns are randomly generated.
443 # Branch CPL is created if it does not exist.
444 my $patron = $builder->build({
445 source => 'Borrower',
446 value => { surname => 'Jansen', branchcode => 'CPL' },
449 =head1 DESCRIPTION
451 This module automatically creates database records for you.
452 If needed, records for foreign keys are created too.
453 Values will be randomly generated if not passed to TestBuilder.
454 Note that you should wrap these actions in a transaction yourself.
456 =head1 METHODS
458 =head2 new
460 my $builder = t::lib::TestBuilder->new;
462 Constructor - Returns the object TestBuilder
464 =head2 schema
466 my $schema = $builder->schema;
468 Getter - Returns the schema of DBIx::Class
470 =head2 delete
472 $builder->delete({
473 source => $source,
474 records => $patron, # OR: records => [ $patron, ... ],
477 Delete individual records, created by builder.
478 Returns the number of delete attempts, or undef.
480 =head2 build
482 $builder->build({ source => $source_name, value => $value });
484 Create a test record in the table, represented by $source_name.
485 The name is required and must conform to the DBIx::Class schema.
486 Values may be specified by the optional $value hashref. Will be
487 randomized otherwise.
488 If needed, TestBuilder creates linked records for foreign keys.
489 Returns the values of the new record as a hashref, or undef if
490 the record could not be created.
492 Note that build also supports recursive hash references inside the
493 value hash for foreign key columns, like:
494 value => {
495 column1 => 'some_value',
496 fk_col2 => {
497 columnA => 'another_value',
500 The hash for fk_col2 here means: create a linked record with build
501 where columnA has this value. In case of a composite FK the hashes
502 are merged.
504 Realize that passing primary key values to build may result in undef
505 if a record with that primary key already exists.
507 =head1 AUTHOR
509 Yohann Dufour <yohann.dufour@biblibre.com>
511 Koha Development Team
513 =head1 COPYRIGHT
515 Copyright 2014 - Biblibre SARL
517 =head1 LICENSE
519 This file is part of Koha.
521 Koha is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
522 the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
524 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.
526 You should have received a copy of the GNU General Public License along with Koha; if not, see <http://www.gnu.org/licenses>.
528 =cut