Bug 23168: Enable plugins using plain SQL
[koha.git] / t / lib / TestBuilder.pm
blob3bfcb86e0f4bf50accdd87422712525eaaa909ea
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;
84 my @pks = $self->schema->source( $class->_type )->primary_columns;
86 my $hashref = $self->build({ source => $source, value => $value });
87 my @ids;
89 foreach my $pk ( @pks ) {
90 push @ids, $hashref->{ $pk };
93 my $object = $class->find( @ids );
95 return $object;
98 sub build {
99 # build returns a hash of column values for a created record, or undef
100 # build does NOT update a record, or pass back values of an existing record
101 my ($self, $params) = @_;
102 my $source = $params->{source};
103 if( !$source ) {
104 carp "Source parameter not specified!";
105 return;
107 my $value = $params->{value};
109 my @unknowns = grep( !/^(source|value)$/, keys %{ $params });
110 carp "Unknown parameter(s): ", join( ', ', @unknowns ) if scalar @unknowns;
112 my $col_values = $self->_buildColumnValues({
113 source => $source,
114 value => $value,
116 return if !$col_values; # did not meet unique constraints?
118 # loop thru all fk and create linked records if needed
119 # fills remaining entries in $col_values
120 my $foreign_keys = $self->_getForeignKeys( { source => $source } );
121 for my $fk ( @$foreign_keys ) {
122 # skip when FK points to itself: e.g. borrowers:guarantorid
123 next if $fk->{source} eq $source;
124 my $keys = $fk->{keys};
125 my $tbl = $fk->{source};
126 my $res = $self->_create_links( $tbl, $keys, $col_values, $value );
127 return if !$res; # failed: no need to go further
128 foreach( keys %$res ) { # save new values
129 $col_values->{$_} = $res->{$_};
133 # store this record and return hashref
134 return $self->_storeColumnValues({
135 source => $source,
136 values => $col_values,
140 sub build_sample_biblio {
141 my ( $self, $args ) = @_;
143 my $title = $args->{title} || 'Some boring read';
144 my $author = $args->{author} || 'Some boring author';
145 my $frameworkcode = $args->{frameworkcode} || '';
146 my $itemtype = $args->{itemtype}
147 || $self->build_object( { class => 'Koha::ItemTypes' } )->itemtype;
149 my $marcflavour = C4::Context->preference('marcflavour');
151 my $record = MARC::Record->new();
152 my ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 200, 'a' ) : ( 245, 'a' );
153 $record->append_fields(
154 MARC::Field->new( $tag, ' ', ' ', $subfield => $title ),
157 ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 200, 'f' ) : ( 100, 'a' );
158 $record->append_fields(
159 MARC::Field->new( $tag, ' ', ' ', $subfield => $author ),
162 ( $tag, $subfield ) = $marcflavour eq 'UNIMARC' ? ( 995, 'r' ) : ( 942, 'c' );
163 $record->append_fields(
164 MARC::Field->new( $tag, ' ', ' ', $subfield => $itemtype )
167 my ($biblio_id) = C4::Biblio::AddBiblio( $record, $frameworkcode );
168 return Koha::Biblios->find($biblio_id);
171 sub build_sample_item {
172 my ( $self, $args ) = @_;
174 my $biblionumber =
175 delete $args->{biblionumber} || $self->build_sample_biblio->biblionumber;
176 my $library = delete $args->{library}
177 || $self->build_object( { class => 'Koha::Libraries' } )->branchcode;
179 my $itype = delete $args->{itype}
180 || $self->build_object( { class => 'Koha::ItemTypes' } )->itemtype;
182 my $barcode = delete $args->{barcode}
183 || $self->_gen_text( { info => { size => SIZE_BARCODE } } );
185 my ( undef, undef, $itemnumber ) = C4::Items::AddItem(
187 homebranch => $library,
188 holdingbranch => $library,
189 barcode => $barcode,
190 itype => $itype,
191 %$args,
193 $biblionumber
195 return Koha::Items->find($itemnumber);
198 # ------------------------------------------------------------------------------
199 # Internal helper routines
201 sub _create_links {
202 # returns undef for failure to create linked records
203 # otherwise returns hashref containing new column values for parent record
204 my ( $self, $linked_tbl, $keys, $col_values, $value ) = @_;
206 my $fk_value = {};
207 my ( $cnt_scalar, $cnt_null ) = ( 0, 0 );
209 # First, collect all values for creating a linked record (if needed)
210 foreach my $fk ( @$keys ) {
211 my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
212 if( ref( $value->{$col} ) eq 'HASH' ) {
213 # add all keys from the FK hash
214 $fk_value = { %{ $value->{$col} }, %$fk_value };
216 if( exists $col_values->{$col} ) {
217 # add specific value (this does not necessarily exclude some
218 # values from the hash in the preceding if)
219 $fk_value->{ $destcol } = $col_values->{ $col };
220 $cnt_scalar++;
221 $cnt_null++ if !defined( $col_values->{$col} );
225 # If we saw all FK columns, first run the following checks
226 if( $cnt_scalar == @$keys ) {
227 # if one or more fk cols are null, the FK constraint will not be forced
228 return {} if $cnt_null > 0;
229 # does the record exist already?
230 return {} if $self->schema->resultset($linked_tbl)->find( $fk_value );
232 # create record with a recursive build call
233 my $row = $self->build({ source => $linked_tbl, value => $fk_value });
234 return if !$row; # failure
236 # Finally, only return the new values
237 my $rv = {};
238 foreach my $fk ( @$keys ) {
239 my ( $col, $destcol ) = ( $fk->{col_name}, $fk->{col_fk_name} );
240 next if exists $col_values->{ $col };
241 $rv->{ $col } = $row->{ $destcol };
243 return $rv; # success
246 sub _formatSource {
247 my ($params) = @_;
248 my $source = $params->{source} || return;
249 $source =~ s|(\w+)$|$1|;
250 return $source;
253 sub _buildColumnValues {
254 my ($self, $params) = @_;
255 my $source = _formatSource( $params ) || return;
256 my $original_value = $params->{value};
258 my $col_values = {};
259 my @columns = $self->schema->source($source)->columns;
260 my %unique_constraints = $self->schema->source($source)->unique_constraints();
262 my $build_value = 5;
263 # we try max $build_value times if there are unique constraints
264 BUILD_VALUE: while ( $build_value ) {
265 # generate random values for all columns
266 for my $col_name( @columns ) {
267 my $valref = $self->_buildColumnValue({
268 source => $source,
269 column_name => $col_name,
270 value => $original_value,
272 return if !$valref; # failure
273 if( @$valref ) { # could be empty
274 # there will be only one value, but it could be undef
275 $col_values->{$col_name} = $valref->[0];
279 # verify the data would respect each unique constraint
280 # note that this is INCOMPLETE since not all col_values are filled
281 CONSTRAINTS: foreach my $constraint (keys %unique_constraints) {
283 my $condition;
284 my $constraint_columns = $unique_constraints{$constraint};
285 # loop through all constraint columns and build the condition
286 foreach my $constraint_column ( @$constraint_columns ) {
287 # build the filter
288 # if one column does not exist or is undef, skip it
289 # an insert with a null will not trigger the constraint
290 next CONSTRAINTS
291 if !exists $col_values->{ $constraint_column } ||
292 !defined $col_values->{ $constraint_column };
293 $condition->{ $constraint_column } =
294 $col_values->{ $constraint_column };
296 my $count = $self->schema
297 ->resultset( $source )
298 ->search( $condition )
299 ->count();
300 if ( $count > 0 ) {
301 # no point checking more stuff, exit the loop
302 $build_value--;
303 next BUILD_VALUE;
306 last; # you passed all tests
308 return $col_values if $build_value > 0;
310 # if you get here, we have a problem
311 warn "Violation of unique constraint in $source";
312 return;
315 sub _getForeignKeys {
317 # Returns the following arrayref
318 # [ [ source => name, keys => [ col_name => A, col_fk_name => B ] ], ... ]
319 # The array gives source name and keys for each FK constraint
321 my ($self, $params) = @_;
322 my $source = $self->schema->source( $params->{source} );
324 my ( @foreign_keys, $check_dupl );
325 my @relationships = $source->relationships;
326 for my $rel_name( @relationships ) {
327 my $rel_info = $source->relationship_info($rel_name);
328 if( $rel_info->{attrs}->{is_foreign_key_constraint} ) {
329 $rel_info->{source} =~ s/^.*:://g;
330 my $rel = { source => $rel_info->{source} };
332 my @keys;
333 while( my ($col_fk_name, $col_name) = each(%{$rel_info->{cond}}) ) {
334 $col_name =~ s|self.(\w+)|$1|;
335 $col_fk_name =~ s|foreign.(\w+)|$1|;
336 push @keys, {
337 col_name => $col_name,
338 col_fk_name => $col_fk_name,
341 # check if the combination table and keys is unique
342 # so skip double belongs_to relations (as in Biblioitem)
343 my $tag = $rel->{source}. ':'.
344 join ',', sort map { $_->{col_name} } @keys;
345 next if $check_dupl->{$tag};
346 $check_dupl->{$tag} = 1;
347 $rel->{keys} = \@keys;
348 push @foreign_keys, $rel;
351 return \@foreign_keys;
354 sub _storeColumnValues {
355 my ($self, $params) = @_;
356 my $source = $params->{source};
357 my $col_values = $params->{values};
358 my $new_row = $self->schema->resultset( $source )->create( $col_values );
359 return $new_row? { $new_row->get_columns }: {};
362 sub _buildColumnValue {
363 # returns an arrayref if all goes well
364 # an empty arrayref typically means: auto_incr column or fk column
365 # undef means failure
366 my ($self, $params) = @_;
367 my $source = $params->{source};
368 my $value = $params->{value};
369 my $col_name = $params->{column_name};
371 my $col_info = $self->schema->source($source)->column_info($col_name);
373 my $retvalue = [];
374 if( $col_info->{is_auto_increment} ) {
375 if( exists $value->{$col_name} ) {
376 warn "Value not allowed for auto_incr $col_name in $source";
377 return;
379 # otherwise: no need to assign a value
380 } elsif( $col_info->{is_foreign_key} || _should_be_fk($source,$col_name) ) {
381 if( exists $value->{$col_name} ) {
382 if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
383 # This explicit undef is not allowed
384 warn "Null value for $col_name in $source not allowed";
385 return;
387 if( ref( $value->{$col_name} ) ne 'HASH' ) {
388 push @$retvalue, $value->{$col_name};
390 # sub build will handle a passed hash value later on
392 } elsif( ref( $value->{$col_name} ) eq 'HASH' ) {
393 # this is not allowed for a column that is not a FK
394 warn "Hash not allowed for $col_name in $source";
395 return;
396 } elsif( exists $value->{$col_name} ) {
397 if( !defined $value->{$col_name} && !$col_info->{is_nullable} ) {
398 # This explicit undef is not allowed
399 warn "Null value for $col_name in $source not allowed";
400 return;
402 push @$retvalue, $value->{$col_name};
403 } elsif( exists $self->{default_values}{$source}{$col_name} ) {
404 push @$retvalue, $self->{default_values}{$source}{$col_name};
405 } else {
406 my $data_type = $col_info->{data_type};
407 $data_type =~ s| |_|;
408 if( my $hdlr = $self->{gen_type}->{$data_type} ) {
409 push @$retvalue, &$hdlr( $self, { info => $col_info } );
410 } else {
411 warn "Unknown type $data_type for $col_name in $source";
412 return;
415 return $retvalue;
418 sub _should_be_fk {
419 # This sub is only needed for inconsistencies in the schema
420 # A column is not marked as FK, but a belongs_to relation is defined
421 my ( $source, $column ) = @_;
422 my $inconsistencies = {
423 'Item.biblionumber' => 1, #FIXME: Please remove me when I become FK
425 return $inconsistencies->{ "$source.$column" };
428 sub _gen_type {
429 return {
430 tinyint => \&_gen_int,
431 smallint => \&_gen_int,
432 mediumint => \&_gen_int,
433 integer => \&_gen_int,
434 bigint => \&_gen_int,
436 float => \&_gen_real,
437 decimal => \&_gen_real,
438 double_precision => \&_gen_real,
440 timestamp => \&_gen_datetime,
441 datetime => \&_gen_datetime,
442 date => \&_gen_date,
444 char => \&_gen_text,
445 varchar => \&_gen_text,
446 tinytext => \&_gen_text,
447 text => \&_gen_text,
448 mediumtext => \&_gen_text,
449 longtext => \&_gen_text,
451 set => \&_gen_set_enum,
452 enum => \&_gen_set_enum,
454 tinyblob => \&_gen_blob,
455 mediumblob => \&_gen_blob,
456 blob => \&_gen_blob,
457 longblob => \&_gen_blob,
461 sub _gen_int {
462 my ($self, $params) = @_;
463 my $data_type = $params->{info}->{data_type};
465 my $max = 1;
466 if( $data_type eq 'tinyint' ) {
467 $max = 127;
469 elsif( $data_type eq 'smallint' ) {
470 $max = 32767;
472 elsif( $data_type eq 'mediumint' ) {
473 $max = 8388607;
475 elsif( $data_type eq 'integer' ) {
476 $max = 2147483647;
478 elsif( $data_type eq 'bigint' ) {
479 $max = 9223372036854775807;
481 return int( rand($max+1) );
484 sub _gen_real {
485 my ($self, $params) = @_;
486 my $max = 10 ** 38;
487 if( defined( $params->{info}->{size} ) ) {
488 $max = 10 ** ($params->{info}->{size}->[0] - $params->{info}->{size}->[1]);
490 return sprintf("%.2f", rand($max-0.1));
493 sub _gen_date {
494 my ($self, $params) = @_;
495 return $self->schema->storage->datetime_parser->format_date(dt_from_string)
498 sub _gen_datetime {
499 my ($self, $params) = @_;
500 return $self->schema->storage->datetime_parser->format_datetime(dt_from_string);
503 sub _gen_text {
504 my ($self, $params) = @_;
505 # From perldoc String::Random
506 my $size = $params->{info}{size} // 10;
507 $size -= alt_rand(0.5 * $size);
508 my $regex = $size > 1
509 ? '[A-Za-z][A-Za-z0-9_]{'.($size-1).'}'
510 : '[A-Za-z]';
511 my $random = String::Random->new( rand_gen => \&alt_rand );
512 # rand_gen is only supported from 0.27 onward
513 return $random->randregex($regex);
516 sub alt_rand { #Alternative randomizer
517 my ($max) = @_;
518 my $random = Bytes::Random::Secure->new( NonBlocking => 1 );
519 my $r = $random->irand / 2**32;
520 return int( $r * $max );
523 sub _gen_set_enum {
524 my ($self, $params) = @_;
525 return $params->{info}->{extra}->{list}->[0];
528 sub _gen_blob {
529 my ($self, $params) = @_;;
530 return 'b';
533 sub _gen_default_values {
534 my ($self) = @_;
535 return {
536 Borrower => {
537 login_attempts => 0,
538 gonenoaddress => undef,
539 lost => undef,
540 debarred => undef,
541 borrowernotes => '',
543 Item => {
544 notforloan => 0,
545 itemlost => 0,
546 withdrawn => 0,
547 restricted => 0,
548 more_subfields_xml => undef,
550 Category => {
551 enrolmentfee => 0,
552 reservefee => 0,
554 Itemtype => {
555 rentalcharge => 0,
556 rentalcharge_daily => 0,
557 rentalcharge_hourly => 0,
558 defaultreplacecost => 0,
559 processfee => 0,
561 Aqbookseller => {
562 tax_rate => 0,
563 discount => 0,
565 AuthHeader => {
566 marcxml => '',
571 =head1 NAME
573 t::lib::TestBuilder.pm - Koha module to create test records
575 =head1 SYNOPSIS
577 use t::lib::TestBuilder;
578 my $builder = t::lib::TestBuilder->new;
580 # The following call creates a patron, linked to branch CPL.
581 # Surname is provided, other columns are randomly generated.
582 # Branch CPL is created if it does not exist.
583 my $patron = $builder->build({
584 source => 'Borrower',
585 value => { surname => 'Jansen', branchcode => 'CPL' },
588 =head1 DESCRIPTION
590 This module automatically creates database records for you.
591 If needed, records for foreign keys are created too.
592 Values will be randomly generated if not passed to TestBuilder.
593 Note that you should wrap these actions in a transaction yourself.
595 =head1 METHODS
597 =head2 new
599 my $builder = t::lib::TestBuilder->new;
601 Constructor - Returns the object TestBuilder
603 =head2 schema
605 my $schema = $builder->schema;
607 Getter - Returns the schema of DBIx::Class
609 =head2 delete
611 $builder->delete({
612 source => $source,
613 records => $patron, # OR: records => [ $patron, ... ],
616 Delete individual records, created by builder.
617 Returns the number of delete attempts, or undef.
619 =head2 build
621 $builder->build({ source => $source_name, value => $value });
623 Create a test record in the table, represented by $source_name.
624 The name is required and must conform to the DBIx::Class schema.
625 Values may be specified by the optional $value hashref. Will be
626 randomized otherwise.
627 If needed, TestBuilder creates linked records for foreign keys.
628 Returns the values of the new record as a hashref, or undef if
629 the record could not be created.
631 Note that build also supports recursive hash references inside the
632 value hash for foreign key columns, like:
633 value => {
634 column1 => 'some_value',
635 fk_col2 => {
636 columnA => 'another_value',
639 The hash for fk_col2 here means: create a linked record with build
640 where columnA has this value. In case of a composite FK the hashes
641 are merged.
643 Realize that passing primary key values to build may result in undef
644 if a record with that primary key already exists.
646 =head2 build_object
648 Given a plural Koha::Object-derived class, it creates a random element, and
649 returns the corresponding Koha::Object.
651 my $patron = $builder->build_object({ class => 'Koha::Patrons' [, value => { ... }] });
653 =head1 AUTHOR
655 Yohann Dufour <yohann.dufour@biblibre.com>
657 Koha Development Team
659 =head1 COPYRIGHT
661 Copyright 2014 - Biblibre SARL
663 =head1 LICENSE
665 This file is part of Koha.
667 Koha is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
668 the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
670 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.
672 You should have received a copy of the GNU General Public License along with Koha; if not, see <http://www.gnu.org/licenses>.
674 =cut