Bug 21684: Fix delete methods and add more tests
[koha.git] / Koha / Object.pm
1 package Koha::Object;
2
3 # Copyright ByWater Solutions 2014
4 # Copyright 2016 Koha Development Team
5 #
6 # This file is part of Koha.
7 #
8 # Koha is free software; you can redistribute it and/or modify it under the
9 # terms of the GNU General Public License as published by the Free Software
10 # Foundation; either version 3 of the License, or (at your option) any later
11 # version.
12 #
13 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
14 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
15 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License along
18 # with Koha; if not, write to the Free Software Foundation, Inc.,
19 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
20
21 use Modern::Perl;
22
23 use Carp;
24 use Mojo::JSON;
25 use Scalar::Util qw( looks_like_number );
26 use Try::Tiny;
27
28 use Koha::Database;
29 use Koha::Exceptions::Object;
30 use Koha::DateUtils;
31
32 =head1 NAME
33
34 Koha::Object - Koha Object base class
35
36 =head1 SYNOPSIS
37
38     use Koha::Object;
39     my $object = Koha::Object->new({ property1 => $property1, property2 => $property2, etc... } );
40
41 =head1 DESCRIPTION
42
43 This class must always be subclassed.
44
45 =head1 API
46
47 =head2 Class Methods
48
49 =cut
50
51 =head3 Koha::Object->new();
52
53 my $object = Koha::Object->new();
54 my $object = Koha::Object->new($attributes);
55
56 Note that this cannot be used to retrieve record from the DB.
57
58 =cut
59
60 sub new {
61     my ( $class, $attributes ) = @_;
62     my $self = {};
63
64     if ($attributes) {
65         my $schema = Koha::Database->new->schema;
66
67         # Remove the arguments which exist, are not defined but NOT NULL to use the default value
68         my $columns_info = $schema->resultset( $class->_type )->result_source->columns_info;
69         for my $column_name ( keys %$attributes ) {
70             my $c_info = $columns_info->{$column_name};
71             next if $c_info->{is_nullable};
72             next if not exists $attributes->{$column_name} or defined $attributes->{$column_name};
73             delete $attributes->{$column_name};
74         }
75
76         $self->{_result} =
77           $schema->resultset( $class->_type() )->new($attributes);
78     }
79
80     croak("No _type found! Koha::Object must be subclassed!")
81       unless $class->_type();
82
83     bless( $self, $class );
84
85 }
86
87 =head3 Koha::Object->_new_from_dbic();
88
89 my $object = Koha::Object->_new_from_dbic($dbic_row);
90
91 =cut
92
93 sub _new_from_dbic {
94     my ( $class, $dbic_row ) = @_;
95     my $self = {};
96
97     # DBIC result row
98     $self->{_result} = $dbic_row;
99
100     croak("No _type found! Koha::Object must be subclassed!")
101       unless $class->_type();
102
103     croak( "DBIC result _type " . ref( $self->{_result} ) . " isn't of the _type " . $class->_type() )
104       unless ref( $self->{_result} ) eq "Koha::Schema::Result::" . $class->_type();
105
106     bless( $self, $class );
107
108 }
109
110 =head3 $object->store();
111
112 Saves the object in storage.
113 If the object is new, it will be created.
114 If the object previously existed, it will be updated.
115
116 Returns:
117     $self  if the store was a success
118     undef  if the store failed
119
120 =cut
121
122 sub store {
123     my ($self) = @_;
124
125     my $columns_info = $self->_result->result_source->columns_info;
126
127     # Handle not null and default values for integers and dates
128     foreach my $col ( keys %{$columns_info} ) {
129         # Integers
130         if ( _numeric_column_type( $columns_info->{$col}->{data_type} ) ) {
131             # Has been passed but not a number, usually an empty string
132             if ( defined $self->$col and not looks_like_number( $self->$col ) ) {
133                 if ( $columns_info->{$col}->{is_nullable} ) {
134                     # If nullable, default to null
135                     $self->$col(undef);
136                 } else {
137                     # If cannot be null, get the default value
138                     # What if cannot be null and does not have a default value? Possible?
139                     $self->$col($columns_info->{$col}->{default_value});
140                 }
141             }
142         }
143         elsif ( _date_or_datetime_column_type( $columns_info->{$col}->{data_type} ) ) {
144             # Set to null if an empty string (or == 0 but should not happen)
145             if ( defined $self->$col and not $self->$col ) {
146                 if ( $columns_info->{$col}->{is_nullable} ) {
147                     $self->$col(undef);
148                 } else {
149                     $self->$col($columns_info->{$col}->{default_value});
150                 }
151             }
152         }
153     }
154
155     try {
156         return $self->_result()->update_or_insert() ? $self : undef;
157     }
158     catch {
159         # Catch problems and raise relevant exceptions
160         if (ref($_) eq 'DBIx::Class::Exception') {
161             if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
162                 # FK constraints
163                 # FIXME: MySQL error, if we support more DB engines we should implement this for each
164                 if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
165                     Koha::Exceptions::Object::FKConstraint->throw(
166                         error     => 'Broken FK constraint',
167                         broken_fk => $+{column}
168                     );
169                 }
170             }
171             elsif( $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ ) {
172                 Koha::Exceptions::Object::DuplicateID->throw(
173                     error => 'Duplicate ID',
174                     duplicate_id => $+{key}
175                 );
176             }
177             elsif( $_->{msg} =~ /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/ ) { # The optional \W in the regex might be a quote or backtick
178                 my $type = $+{type};
179                 my $value = $+{value};
180                 my $property = $+{property};
181                 $property =~ s/['`]//g;
182                 Koha::Exceptions::Object::BadValue->throw(
183                     type     => $type,
184                     value    => $value,
185                     property => $property =~ /(\w+\.\w+)$/ ? $1 : $property, # results in table.column without quotes or backtics
186                 );
187             }
188         }
189         # Catch-all for foreign key breakages. It will help find other use cases
190         $_->rethrow();
191     }
192 }
193
194 =head3 $object->update();
195
196 A shortcut for set + store in one call.
197
198 =cut
199
200 sub update {
201     my ($self, $values) = @_;
202     return $self->set($values)->store();
203 }
204
205 =head3 $object->delete();
206
207 Removes the object from storage.
208
209 Returns:
210     1  if the deletion was a success
211     0  if the deletion failed
212     -1 if the object was never in storage
213
214 =cut
215
216 sub delete {
217     my ($self) = @_;
218
219     return $self->_result()->delete;
220 }
221
222 =head3 $object->set( $properties_hashref )
223
224 $object->set(
225     {
226         property1 => $property1,
227         property2 => $property2,
228         property3 => $propery3,
229     }
230 );
231
232 Enables multiple properties to be set at once
233
234 Returns:
235     1      if all properties were set.
236     0      if one or more properties do not exist.
237     undef  if all properties exist but a different error
238            prevents one or more properties from being set.
239
240 If one or more of the properties do not exist,
241 no properties will be set.
242
243 =cut
244
245 sub set {
246     my ( $self, $properties ) = @_;
247
248     my @columns = @{$self->_columns()};
249
250     foreach my $p ( keys %$properties ) {
251         unless ( grep {/^$p$/} @columns ) {
252             Koha::Exceptions::Object::PropertyNotFound->throw( "No property $p for " . ref($self) );
253         }
254     }
255
256     return $self->_result()->set_columns($properties) ? $self : undef;
257 }
258
259 =head3 $object->unblessed();
260
261 Returns an unblessed representation of object.
262
263 =cut
264
265 sub unblessed {
266     my ($self) = @_;
267
268     return { $self->_result->get_columns };
269 }
270
271 =head3 $object->get_from_storage;
272
273 =cut
274
275 sub get_from_storage {
276     my ( $self, $attrs ) = @_;
277     my $stored_object = $self->_result->get_from_storage($attrs);
278     my $object_class  = Koha::Object::_get_object_class( $self->_result->result_class );
279     return $object_class->_new_from_dbic($stored_object);
280 }
281
282 =head3 $object->TO_JSON
283
284 Returns an unblessed representation of the object, suitable for JSON output.
285
286 =cut
287
288 sub TO_JSON {
289
290     my ($self) = @_;
291
292     my $unblessed    = $self->unblessed;
293     my $columns_info = Koha::Database->new->schema->resultset( $self->_type )
294         ->result_source->{_columns};
295
296     foreach my $col ( keys %{$columns_info} ) {
297
298         if ( $columns_info->{$col}->{is_boolean} )
299         {    # Handle booleans gracefully
300             $unblessed->{$col}
301                 = ( $unblessed->{$col} )
302                 ? Mojo::JSON->true
303                 : Mojo::JSON->false;
304         }
305         elsif ( _numeric_column_type( $columns_info->{$col}->{data_type} )
306             and looks_like_number( $unblessed->{$col} )
307         ) {
308
309             # TODO: Remove once the solution for
310             # https://rt.cpan.org/Ticket/Display.html?id=119904
311             # is ported to whatever distro we support by that time
312             $unblessed->{$col} += 0;
313         }
314         elsif ( _datetime_column_type( $columns_info->{$col}->{data_type} ) ) {
315             eval {
316                 return unless $unblessed->{$col};
317                 $unblessed->{$col} = output_pref({
318                     dateformat => 'rfc3339',
319                     dt         => dt_from_string($unblessed->{$col}, 'sql'),
320                 });
321             };
322         }
323     }
324     return $unblessed;
325 }
326
327 sub _date_or_datetime_column_type {
328     my ($column_type) = @_;
329
330     my @dt_types = (
331         'timestamp',
332         'date',
333         'datetime'
334     );
335
336     return ( grep { $column_type eq $_ } @dt_types) ? 1 : 0;
337 }
338 sub _datetime_column_type {
339     my ($column_type) = @_;
340
341     my @dt_types = (
342         'timestamp',
343         'datetime'
344     );
345
346     return ( grep { $column_type eq $_ } @dt_types) ? 1 : 0;
347 }
348
349 sub _numeric_column_type {
350     # TODO: Remove once the solution for
351     # https://rt.cpan.org/Ticket/Display.html?id=119904
352     # is ported to whatever distro we support by that time
353     my ($column_type) = @_;
354
355     my @numeric_types = (
356         'bigint',
357         'integer',
358         'int',
359         'mediumint',
360         'smallint',
361         'tinyint',
362         'decimal',
363         'double precision',
364         'float'
365     );
366
367     return ( grep { $column_type eq $_ } @numeric_types) ? 1 : 0;
368 }
369
370 =head3 to_api
371
372     my $object_for_api = $object->to_api;
373
374 Returns a representation of the object, suitable for API output.
375
376 =cut
377
378 sub to_api {
379     my ( $self ) = @_;
380     my $json_object = $self->TO_JSON;
381
382     # Rename attributes if there's a mapping
383     if ( $self->can('to_api_mapping') ) {
384         foreach my $column ( keys %{$self->to_api_mapping} ) {
385             my $mapped_column = $self->to_api_mapping->{$column};
386             if ( exists $json_object->{$column}
387                 && defined $mapped_column )
388             {
389                 # key != undef
390                 $json_object->{$mapped_column} = delete $json_object->{$column};
391             }
392             elsif ( exists $json_object->{$column}
393                 && !defined $mapped_column )
394             {
395                 # key == undef
396                 delete $json_object->{$column};
397             }
398         }
399     }
400
401     return $json_object;
402 }
403
404 =head3 $object->unblessed_all_relateds
405
406 my $everything_into_one_hashref = $object->unblessed_all_relateds
407
408 The unblessed method only retrieves column' values for the column of the object.
409 In a *few* cases we want to retrieve the information of all the prefetched data.
410
411 =cut
412
413 sub unblessed_all_relateds {
414     my ($self) = @_;
415
416     my %data;
417     my $related_resultsets = $self->_result->{related_resultsets} || {};
418     my $rs = $self->_result;
419     while ( $related_resultsets and %$related_resultsets ) {
420         my @relations = keys %{ $related_resultsets };
421         if ( @relations ) {
422             my $relation = $relations[0];
423             $rs = $rs->related_resultset($relation)->get_cache;
424             $rs = $rs->[0]; # Does it makes sense to have several values here?
425             my $object_class = Koha::Object::_get_object_class( $rs->result_class );
426             my $koha_object = $object_class->_new_from_dbic( $rs );
427             $related_resultsets = $rs->{related_resultsets};
428             %data = ( %data, %{ $koha_object->unblessed } );
429         }
430     }
431     %data = ( %data, %{ $self->unblessed } );
432     return \%data;
433 }
434
435 =head3 $object->_result();
436
437 Returns the internal DBIC Row object
438
439 =cut
440
441 sub _result {
442     my ($self) = @_;
443
444     # If we don't have a dbic row at this point, we need to create an empty one
445     $self->{_result} ||=
446       Koha::Database->new()->schema()->resultset( $self->_type() )->new({});
447
448     return $self->{_result};
449 }
450
451 =head3 $object->_columns();
452
453 Returns an arrayref of the table columns
454
455 =cut
456
457 sub _columns {
458     my ($self) = @_;
459
460     # If we don't have a dbic row at this point, we need to create an empty one
461     $self->{_columns} ||= [ $self->_result()->result_source()->columns() ];
462
463     return $self->{_columns};
464 }
465
466 sub _get_object_class {
467     my ( $type ) = @_;
468     return unless $type;
469
470     if( $type->can('koha_object_class') ) {
471         return $type->koha_object_class;
472     }
473     $type =~ s|Schema::Result::||;
474     return ${type};
475 }
476
477 =head3 AUTOLOAD
478
479 The autoload method is used only to get and set values for an objects properties.
480
481 =cut
482
483 sub AUTOLOAD {
484     my $self = shift;
485
486     my $method = our $AUTOLOAD;
487     $method =~ s/.*://;
488
489     my @columns = @{$self->_columns()};
490     # Using direct setter/getter like $item->barcode() or $item->barcode($barcode);
491     if ( grep {/^$method$/} @columns ) {
492         if ( @_ ) {
493             $self->_result()->set_column( $method, @_ );
494             return $self;
495         } else {
496             my $value = $self->_result()->get_column( $method );
497             return $value;
498         }
499     }
500
501     my @known_methods = qw( is_changed id in_storage get_column discard_changes make_column_dirty );
502
503     Koha::Exceptions::Object::MethodNotCoveredByTests->throw(
504         error      => sprintf("The method %s->%s is not covered by tests!", ref($self), $method),
505         show_trace => 1
506     ) unless grep { /^$method$/ } @known_methods;
507
508
509     my $r = eval { $self->_result->$method(@_) };
510     if ( $@ ) {
511         Koha::Exceptions::Object->throw( ref($self) . "::$method generated this error: " . $@ );
512     }
513     return $r;
514 }
515
516 =head3 _type
517
518 This method must be defined in the child class. The value is the name of the DBIC resultset.
519 For example, for borrowers, the _type method will return "Borrower".
520
521 =cut
522
523 sub _type { }
524
525 sub DESTROY { }
526
527 =head1 AUTHOR
528
529 Kyle M Hall <kyle@bywatersolutions.com>
530
531 Jonathan Druart <jonathan.druart@bugs.koha-community.org>
532
533 =cut
534
535 1;