View | Details | Raw Unified | Return to bug 19871
Collapse All | Expand All

(-)a/Koha/Object.pm (-40 / +12 lines)
Lines 173-226 sub store { Link Here
173
    try {
173
    try {
174
        return $self->_result()->update_or_insert() ? $self : undef;
174
        return $self->_result()->update_or_insert() ? $self : undef;
175
    } catch {
175
    } catch {
176
176
        # Use centralized exception translation
177
        # Catch problems and raise relevant exceptions
177
        warn $_->{msg} if ref($_) eq 'DBIx::Class::Exception';
178
        if ( ref($_) eq 'DBIx::Class::Exception' ) {
178
179
            warn $_->{msg};
179
        # For enum data truncation, we need to pass the object value which the utility can't access
180
            if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
180
        if ( ref($_) eq 'DBIx::Class::Exception' && $_->{msg} =~ /Data truncated for column \W?(?<property>\w+)/ ) {
181
181
            my $property = $+{property};
182
                # FK constraints
182
            my $type     = $columns_info->{$property}->{data_type} // '';
183
                # FIXME: MySQL error, if we support more DB engines we should implement this for each
183
            if ( $type eq 'enum' ) {
184
                if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
185
                    Koha::Exceptions::Object::FKConstraint->throw(
186
                        error     => 'Broken FK constraint',
187
                        broken_fk => $+{column}
188
                    );
189
                }
190
            } elsif ( $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ ) {
191
                Koha::Exceptions::Object::DuplicateID->throw(
192
                    error        => 'Duplicate ID',
193
                    duplicate_id => $+{key}
194
                );
195
            } elsif ( $_->{msg} =~ /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/ )
196
            {    # The optional \W in the regex might be a quote or backtick
197
                my $type     = $+{type};
198
                my $value    = $+{value};
199
                my $property = $+{property};
200
                $property =~ s/['`]//g;
201
                Koha::Exceptions::Object::BadValue->throw(
202
                    type     => $type,
203
                    value    => $value,
204
                    property => $property =~ /(\w+\.\w+)$/
205
                    ? $1
206
                    : $property,    # results in table.column without quotes or backtics
207
                );
208
            } elsif ( $_->{msg} =~ /Data truncated for column \W?(?<property>\w+)/ )
209
            {                       # The optional \W in the regex might be a quote or backtick
210
                my $property = $+{property};
211
                my $type     = $columns_info->{$property}->{data_type};
212
                Koha::Exceptions::Object::BadValue->throw(
184
                Koha::Exceptions::Object::BadValue->throw(
213
                    type     => 'enum',
185
                    type     => 'enum',
214
                    property => $property =~ /(\w+\.\w+)$/
186
                    property => $property =~ /(\w+\.\w+)$/
215
                    ? $1
187
                    ? $1
216
                    : $property,    # results in table.column without quotes or backtics
188
                    : $property,    # results in table.column without quotes or backticks
217
                    value => $self->$property,
189
                    value => $self->$property,
218
                ) if $type eq 'enum';
190
                );
219
            }
191
            }
220
        }
192
        }
221
193
222
        # Catch-all for foreign key breakages. It will help find other use cases
194
        # Delegate to centralized exception translation
223
        $_->rethrow();
195
        $self->_result->result_source->schema->translate_exception($_, $columns_info);
224
    }
196
    }
225
}
197
}
226
198
(-)a/Koha/Schema.pm (+63 lines)
Lines 15-20 __PACKAGE__->load_namespaces; Link Here
15
# Created by DBIx::Class::Schema::Loader v0.07025 @ 2013-10-14 20:56:21
15
# Created by DBIx::Class::Schema::Loader v0.07025 @ 2013-10-14 20:56:21
16
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:oDUxXckmfk6H9YCjW8PZTw
16
# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:oDUxXckmfk6H9YCjW8PZTw
17
17
18
use Try::Tiny qw( catch try );
19
use Koha::Schema::Util::ExceptionTranslator;
20
21
=head1 UTILITY METHODS
22
23
=head2 safe_do
24
25
    $schema->safe_do(sub {
26
        # DBIx::Class operations that might throw exceptions
27
        $schema->resultset('SomeTable')->create(\%data);
28
    }, $columns_info);
29
30
Execute a code block with automatic DBIx::Class exception translation.
31
This provides a centralized way to handle database exceptions throughout the application.
32
33
=head3 Parameters
34
35
=over 4
36
37
=item * C<$code_ref> - Code reference to execute
38
39
=item * C<$columns_info> (optional) - Hash reference of column information for enhanced error reporting
40
41
=back
42
43
=head3 Example Usage
44
45
    # Basic usage
46
    $schema->safe_do(sub {
47
        $register->_result->add_to_cash_register_actions(\%action_data);
48
    });
49
50
    # With column info for enhanced error reporting
51
    my $columns_info = $register->_result->result_source->columns_info;
52
    $schema->safe_do(sub {
53
        $register->_result->create_related('some_relation', \%data);
54
    }, $columns_info);
55
56
=cut
57
58
sub safe_do {
59
    my ( $self, $code_ref, $columns_info ) = @_;
60
61
    try {
62
        return $code_ref->();
63
    } catch {
64
        Koha::Schema::Util::ExceptionTranslator->translate_exception($_, $columns_info);
65
    };
66
}
67
68
=head2 translate_exception
69
70
    $schema->translate_exception($exception, $columns_info);
71
72
Convenience method that delegates to the ExceptionTranslator utility.
73
This allows the schema to act as a central point for exception handling.
74
75
=cut
76
77
sub translate_exception {
78
    my ( $self, $exception, $columns_info ) = @_;
79
    return Koha::Schema::Util::ExceptionTranslator->translate_exception($exception, $columns_info);
80
}
18
81
19
# You can replace this text with custom content, and it will be preserved on regeneration
82
# You can replace this text with custom content, and it will be preserved on regeneration
20
1;
83
1;
(-)a/Koha/Schema/Util/ExceptionTranslator.pm (+167 lines)
Line 0 Link Here
1
package Koha::Schema::Util::ExceptionTranslator;
2
3
# Copyright 2025 Koha Development team
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Koha is distributed in the hope that it will be useful, but
13
# WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20
use Modern::Perl;
21
22
use Koha::Exceptions::Object;
23
24
=encoding utf8
25
26
=head1 NAME
27
28
Koha::Schema::Util::ExceptionTranslator - Centralized DBIx::Class exception translation
29
30
=head1 SYNOPSIS
31
32
    use Koha::Schema::Util::ExceptionTranslator;
33
34
    try {
35
        # DBIx::Class operation that might fail
36
        $schema->resultset('SomeTable')->create(\%data);
37
    } catch {
38
        Koha::Schema::Util::ExceptionTranslator->translate_exception($_, \%columns_info);
39
    };
40
41
=head1 DESCRIPTION
42
43
This utility class provides centralized exception translation from DBIx::Class
44
exceptions to Koha-specific exceptions. This eliminates the need for duplicated
45
exception handling code throughout the codebase.
46
47
=head1 METHODS
48
49
=head2 translate_exception
50
51
    Koha::Schema::Util::ExceptionTranslator->translate_exception($exception, $columns_info);
52
53
Translates a DBIx::Class exception into an appropriate Koha exception and throws it.
54
If the exception cannot be translated, it rethrows the original exception.
55
56
=head3 Parameters
57
58
=over 4
59
60
=item * C<$exception> - The caught exception object
61
62
=item * C<$columns_info> (optional) - Hash reference of column information for enhanced error reporting
63
64
=back
65
66
=head3 Exception Types Handled
67
68
=over 4
69
70
=item * Foreign key constraint violations → C<Koha::Exceptions::Object::FKConstraint>
71
72
=item * Duplicate key violations → C<Koha::Exceptions::Object::DuplicateID>
73
74
=item * Invalid data type values → C<Koha::Exceptions::Object::BadValue>
75
76
=item * Data truncation for enum columns → C<Koha::Exceptions::Object::BadValue>
77
78
=back
79
80
=cut
81
82
sub translate_exception {
83
    my ( $class, $exception, $columns_info ) = @_;
84
85
    # Only handle DBIx::Class exceptions
86
    return $exception->rethrow() unless ref($exception) eq 'DBIx::Class::Exception';
87
88
    my $msg = $exception->{msg};
89
90
    # Foreign key constraint failures
91
    if ( $msg =~ /Cannot add or update a child row: a foreign key constraint fails/ ) {
92
        # FIXME: MySQL error, if we support more DB engines we should implement this for each
93
        if ( $msg =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) {
94
            Koha::Exceptions::Object::FKConstraint->throw(
95
                error     => 'Broken FK constraint',
96
                broken_fk => $+{column}
97
            );
98
        }
99
    }
100
    # Duplicate key violations
101
    elsif ( $msg =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ ) {
102
        Koha::Exceptions::Object::DuplicateID->throw(
103
            error        => 'Duplicate ID',
104
            duplicate_id => $+{key}
105
        );
106
    }
107
    # Invalid data type values
108
    elsif ( $msg =~ /Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/ ) {
109
        # The optional \W in the regex might be a quote or backtick
110
        my $type     = $+{type};
111
        my $value    = $+{value};
112
        my $property = $+{property};
113
        $property =~ s/['`]//g;
114
115
        Koha::Exceptions::Object::BadValue->throw(
116
            type     => $type,
117
            value    => $value,
118
            property => $property =~ /(\w+\.\w+)$/
119
                ? $1
120
                : $property,    # results in table.column without quotes or backticks
121
        );
122
    }
123
    # Data truncation for enum columns
124
    elsif ( $msg =~ /Data truncated for column \W?(?<property>\w+)/ ) {
125
        # The optional \W in the regex might be a quote or backtick
126
        my $property = $+{property};
127
128
        # Only handle enum truncation if we have column info
129
        if ( $columns_info && $columns_info->{$property} ) {
130
            my $type = $columns_info->{$property}->{data_type};
131
            if ( $type && $type eq 'enum' ) {
132
                Koha::Exceptions::Object::BadValue->throw(
133
                    type     => 'enum',
134
                    property => $property =~ /(\w+\.\w+)$/
135
                        ? $1
136
                        : $property,    # results in table.column without quotes or backticks
137
                    value => 'Invalid enum value',  # We don't have access to the object here
138
                );
139
            }
140
        }
141
    }
142
143
    # Catch-all: rethrow the original exception if we can't translate it
144
    $exception->rethrow();
145
}
146
147
=head1 FUTURE ENHANCEMENTS
148
149
This utility is designed to be extended to support:
150
151
=over 4
152
153
=item * Multiple database engines (PostgreSQL, SQLite, etc.)
154
155
=item * Additional exception types as they are identified
156
157
=item * Enhanced error reporting with more context
158
159
=back
160
161
=head1 AUTHOR
162
163
Koha Development Team
164
165
=cut
166
167
1;
(-)a/t/db_dependent/Koha/Schema/Util/ExceptionTranslator.t (-1 / +154 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
2
3
# Copyright 2025 Koha Development team
4
#
5
# This file is part of Koha
6
#
7
# Koha is free software; you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Koha is distributed in the hope that it will be useful, but
13
# WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20
use Modern::Perl;
21
use Test::NoWarnings;
22
use Test::More tests => 7;
23
use Test::Exception;
24
25
use Koha::Database;
26
use Koha::Schema::Util::ExceptionTranslator;
27
28
use t::lib::TestBuilder;
29
30
my $builder = t::lib::TestBuilder->new;
31
my $schema  = Koha::Database->new->schema;
32
33
subtest 'foreign_key_constraint_translation' => sub {
34
    plan tests => 1;
35
36
    $schema->storage->txn_begin;
37
38
    # Create a mock DBIx::Class::Exception for FK constraint
39
    my $exception = bless {
40
        msg => "Cannot add or update a child row: a foreign key constraint fails (`koha`.`items`, CONSTRAINT `items_ibfk_1` FOREIGN KEY (`biblionumber`) REFERENCES `biblio` (`biblionumber`))"
41
    }, 'DBIx::Class::Exception';
42
43
    throws_ok {
44
        Koha::Schema::Util::ExceptionTranslator->translate_exception($exception);
45
    } 'Koha::Exceptions::Object::FKConstraint', 'FK constraint exception is properly translated';
46
47
    $schema->storage->txn_rollback;
48
};
49
50
subtest 'duplicate_key_translation' => sub {
51
    plan tests => 1;
52
53
    $schema->storage->txn_begin;
54
55
    # Create a mock DBIx::Class::Exception for duplicate key
56
    my $exception = bless {
57
        msg => "Duplicate entry 'test\@example.com' for key 'borrowers.email'"
58
    }, 'DBIx::Class::Exception';
59
60
    throws_ok {
61
        Koha::Schema::Util::ExceptionTranslator->translate_exception($exception);
62
    } 'Koha::Exceptions::Object::DuplicateID', 'Duplicate key exception is properly translated';
63
64
    $schema->storage->txn_rollback;
65
};
66
67
subtest 'bad_value_translation' => sub {
68
    plan tests => 1;
69
70
    $schema->storage->txn_begin;
71
72
    # Create a mock DBIx::Class::Exception for bad value
73
    my $exception = bless {
74
        msg => "Incorrect datetime value: '2025-13-45' for column 'date_due' at row 1"
75
    }, 'DBIx::Class::Exception';
76
77
    throws_ok {
78
        Koha::Schema::Util::ExceptionTranslator->translate_exception($exception);
79
    } 'Koha::Exceptions::Object::BadValue', 'Bad value exception is properly translated';
80
81
    $schema->storage->txn_rollback;
82
};
83
84
subtest 'enum_truncation_translation' => sub {
85
    plan tests => 1;
86
87
    $schema->storage->txn_begin;
88
89
    # Create a mock DBIx::Class::Exception for enum truncation
90
    my $exception = bless {
91
        msg => "Data truncated for column 'status' at row 1"
92
    }, 'DBIx::Class::Exception';
93
94
    my $columns_info = {
95
        status => { data_type => 'enum' }
96
    };
97
98
    throws_ok {
99
        Koha::Schema::Util::ExceptionTranslator->translate_exception($exception, $columns_info);
100
    } 'Koha::Exceptions::Object::BadValue', 'Enum truncation exception is properly translated';
101
102
    $schema->storage->txn_rollback;
103
};
104
105
subtest 'non_dbix_exception_passthrough' => sub {
106
    plan tests => 1;
107
108
    $schema->storage->txn_begin;
109
110
    # Create a regular exception (not DBIx::Class::Exception)
111
    my $exception = bless {
112
        msg => "Some other error"
113
    }, 'Some::Other::Exception';
114
115
    # Mock the rethrow method
116
    $exception->{rethrown} = 0;
117
    {
118
        package Some::Other::Exception;
119
        sub rethrow { $_[0]->{rethrown} = 1; die $_[0]; }
120
    }
121
122
    throws_ok {
123
        Koha::Schema::Util::ExceptionTranslator->translate_exception($exception);
124
    } qr/Some::Other::Exception/, 'Non-DBIx::Class exceptions are rethrown unchanged';
125
126
    $schema->storage->txn_rollback;
127
};
128
129
subtest 'schema_safe_do_method' => sub {
130
    plan tests => 2;
131
132
    $schema->storage->txn_begin;
133
134
    # Test successful operation
135
    my $result = $schema->safe_do(sub {
136
        return "success";
137
    });
138
    is( $result, "success", 'safe_do returns result on success' );
139
140
    # Test exception translation
141
    throws_ok {
142
        $schema->safe_do(sub {
143
            # Create a mock DBIx::Class::Exception
144
            my $exception = bless {
145
                msg => "Duplicate entry 'test' for key 'primary'"
146
            }, 'DBIx::Class::Exception';
147
            die $exception;
148
        });
149
    } 'Koha::Exceptions::Object::DuplicateID', 'safe_do translates exceptions properly';
150
151
    $schema->storage->txn_rollback;
152
};
153
154
1;

Return to bug 19871