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