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

(-)a/C4/Context.pm (-93 / +6 lines)
Lines 22-125 use Modern::Perl; Link Here
22
use vars qw($AUTOLOAD $context);
22
use vars qw($AUTOLOAD $context);
23
23
24
BEGIN {
24
BEGIN {
25
    # Load plugins early to ensure they're available before any transactions begin
25
    my $enable_plugins = 0;
26
    my $enable_plugins = 0;
26
    if ( exists $ENV{'KOHA_CONF'} && -e $ENV{'KOHA_CONF'} ) {
27
    if ( exists $ENV{'KOHA_CONF'} && -e $ENV{'KOHA_CONF'} ) {
27
        require XML::Simple;
28
        require Koha::Config;
28
        $enable_plugins =
29
        my $config = Koha::Config->get_instance;
29
            XML::Simple->new->XMLin( $ENV{'KOHA_CONF'}, ForceArray => 0, KeyAttr => [] )->{'config'}->{'enable_plugins'}
30
        $enable_plugins = $config->get('enable_plugins') // 0;
30
            // 0;
31
    }
31
    }
32
32
33
    if ($enable_plugins) {
33
    if ($enable_plugins) {
34
        require Koha::Cache::Memory::Lite;
34
        require Koha::Plugins::Loader;
35
        my $enabled_plugins = Koha::Cache::Memory::Lite->get_from_cache('enabled_plugins');
35
        Koha::Plugins::Loader->get_enabled_plugins();
36
        if ( !$enabled_plugins ) {
37
            require Koha::Config;
38
            my $config = Koha::Config->get_instance;
39
            my $driver = $config->get('db_scheme') eq 'Pg' ? 'Pg' : 'mysql';
40
            my $dsn    = sprintf(
41
                'dbi:%s:database=%s;host=%s;port=%s',
42
                $driver,
43
                $config->get('database_test') || $config->get('database'),
44
                $config->get('hostname'),
45
                $config->get('port') || q{},
46
            );
47
            my $attr = { RaiseError => 1, PrintError => 1 };
48
            if ( $driver eq 'mysql' && ( $config->get("tls") // q{} ) eq 'yes' ) {
49
                $dsn .= sprintf(
50
                    ';mysql_ssl=1;mysql_ssl_client_key=%s;mysql_ssl_client_cert=%s;mysql_ssl_ca_file=%s',
51
                    $config->get('key'), $config->get('cert'), $config->get('ca'),
52
                );
53
                $attr->{'mysql_enable_utf8'} = 1;
54
            }
55
56
            require DBI;
57
            my $dbh = DBI->connect( $dsn, $config->get('user'), $config->get('pass'), $attr );
58
            if ($dbh) {
59
                my $tz = $config->timezone // q{};
60
                my @queries;
61
                push @queries, "SET NAMES 'utf8mb4'"            if $driver eq 'mysql';
62
                push @queries, qq{SET time_zone = "$tz"}        if $tz && $driver eq 'mysql' && $tz ne 'local';
63
                push @queries, qq{SET TIME ZONE = "$tz"}        if $tz && $driver eq 'Pg';
64
                push @queries, 'set client_encoding = \'UTF8\'' if $driver eq 'Pg';
65
                if ( $driver eq 'mysql' ) {
66
                    my $sql_mode =
67
                        ( $config->get('strict_sql_modes') || $ENV{KOHA_TESTING} || ( $ENV{_} // q{} ) =~ m{prove}smx )
68
                        ? 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'
69
                        : 'IGNORE_SPACE,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
70
                    push @queries, qq{SET sql_mode = '$sql_mode'};
71
                }
72
73
                $dbh->do($_) for grep { $_ } @queries;
74
75
                my $table_exists = 0;
76
                if ( $driver eq 'mysql' ) {
77
                    $table_exists = $dbh->selectrow_array(
78
                        <<~'SQL'
79
                            SELECT COUNT(*) FROM information_schema.tables
80
                            WHERE table_schema = DATABASE() AND table_name = 'plugin_data'
81
                        SQL
82
                    );
83
                } elsif ( $driver eq 'Pg' ) {
84
                    $table_exists = $dbh->selectrow_array(
85
                        <<~'SQL'
86
                            SELECT COUNT(*) FROM pg_catalog.pg_tables
87
                            WHERE tablename = 'plugin_data'
88
                        SQL
89
                    );
90
                }
91
92
                my @plugin_classes;
93
                if ($table_exists) {
94
                    my $sth = $dbh->prepare(
95
                        <<~'SQL'
96
                            SELECT plugin_class FROM plugin_data WHERE plugin_key = '__ENABLED__' AND plugin_value = 1
97
                        SQL
98
                    );
99
                    $sth->execute();
100
                    @plugin_classes = map { $_->[0] } @{ $sth->fetchall_arrayref() };
101
                    $sth->finish();
102
                }
103
                $dbh->disconnect();
104
105
                if (@plugin_classes) {
106
                    $enabled_plugins = [];
107
                    require Module::Load::Conditional;
108
                    foreach my $plugin_class (@plugin_classes) {
109
                        next
110
                            if !Module::Load::Conditional::can_load(
111
                            modules => { $plugin_class => undef },
112
                            nocache => 1
113
                            );
114
                        if ( my $plugin = eval { $plugin_class->new() } ) {
115
                            push @{$enabled_plugins}, $plugin;
116
                        }
117
                    }
118
                }
119
120
                Koha::Cache::Memory::Lite->set_in_cache( 'enabled_plugins', $enabled_plugins ) if $enabled_plugins;
121
            }
122
        }
123
    }
36
    }
124
37
125
    if ( $ENV{'HTTP_USER_AGENT'} ) {    # Only hit when plack is not enabled
38
    if ( $ENV{'HTTP_USER_AGENT'} ) {    # Only hit when plack is not enabled
(-)a/C4/Installer.pm (-8 / +2 lines)
Lines 754-767 Missing POD for TableExists. Link Here
754
754
755
sub TableExists {    # Could be renamed table_exists for consistency
755
sub TableExists {    # Could be renamed table_exists for consistency
756
    my $table = shift;
756
    my $table = shift;
757
    eval {
757
    require Koha::Database;
758
        my $dbh = C4::Context->dbh;
758
    return Koha::Database->table_exists( C4::Context->dbh, $table );
759
        local $dbh->{PrintError} = 0;
760
        local $dbh->{RaiseError} = 1;
761
        $dbh->do(qq{SELECT * FROM $table WHERE 1 = 0 });
762
    };
763
    return 1 unless $@;
764
    return 0;
765
}
759
}
766
760
767
=head2 version_from_file
761
=head2 version_from_file
(-)a/Koha/Database.pm (+28 lines)
Lines 233-238 sub generate_dsn { Link Here
233
    return $dsn;
233
    return $dsn;
234
}
234
}
235
235
236
=head2 table_exists
237
238
    my $exists = Koha::Database->table_exists($dbh, 'plugin_data');
239
    my $exists = Koha::Database->new->table_exists('plugin_data');
240
241
Checks if a table exists in the database. Can be called as a class method
242
with a dbh parameter, or as an instance method. This method is safe for use
243
during early initialization.
244
245
=cut
246
247
sub table_exists {
248
    my ( $class, $dbh, $table ) = @_;
249
250
    # If called as instance method, get dbh from instance
251
    if ( ref $class ) {
252
        $table = $dbh;
253
        $dbh   = $class->dbh;
254
    }
255
256
    eval {
257
        local $dbh->{PrintError} = 0;
258
        local $dbh->{RaiseError} = 1;
259
        $dbh->do(qq{SELECT * FROM $table WHERE 1 = 0});
260
    };
261
    return $@ ? 0 : 1;
262
}
263
236
=head2 EXPORT
264
=head2 EXPORT
237
265
238
None by default.
266
None by default.
(-)a/Koha/Plugins.pm (-28 / +3 lines)
Lines 118-151 sub get_enabled_plugins { Link Here
118
118
119
    return unless C4::Context->config('enable_plugins');
119
    return unless C4::Context->config('enable_plugins');
120
120
121
    my $enabled_plugins = Koha::Cache::Memory::Lite->get_from_cache(ENABLED_PLUGINS_CACHE_KEY);
121
    # Delegate to Koha::Plugins::Loader which handles caching and loading
122
    unless ($enabled_plugins) {
122
    require Koha::Plugins::Loader;
123
        my $verbose = $params->{verbose} // $class->_verbose;
123
    return Koha::Plugins::Loader->get_enabled_plugins();
124
        $enabled_plugins = [];
125
126
        my @plugin_classes;
127
        try {
128
            my $rs = Koha::Plugins::Datas->search( { plugin_key => '__ENABLED__', plugin_value => 1 } );
129
            @plugin_classes = $rs->get_column('plugin_class');
130
        } catch {
131
            warn "$_";
132
        };
133
134
        foreach my $plugin_class (@plugin_classes) {
135
            next unless can_load( modules => { $plugin_class => undef }, verbose => $verbose, nocache => 1 );
136
137
            my $plugin = eval { $plugin_class->new() };
138
            if ( $@ || !$plugin ) {
139
                warn "Failed to instantiate plugin $plugin_class: $@";
140
                next;
141
            }
142
143
            push @$enabled_plugins, $plugin;
144
        }
145
        Koha::Cache::Memory::Lite->set_in_cache( ENABLED_PLUGINS_CACHE_KEY, $enabled_plugins );
146
    }
147
148
    return @$enabled_plugins;
149
}
124
}
150
125
151
sub _verbose {
126
sub _verbose {
(-)a/Koha/Plugins/Loader.pm (+127 lines)
Line 0 Link Here
1
package Koha::Plugins::Loader;
2
3
# Copyright 2024 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 <https://www.gnu.org/licenses>.
19
20
use Modern::Perl;
21
use Koha::Database;
22
use Koha::Cache::Memory::Lite;
23
use Module::Load::Conditional qw(can_load);
24
25
=head1 NAME
26
27
Koha::Plugins::Loader - Lightweight plugin loader for early initialization
28
29
=head1 SYNOPSIS
30
31
    # Safe to call from C4::Context BEGIN block
32
    use Koha::Plugins::Loader;
33
    my @plugins = Koha::Plugins::Loader->get_enabled_plugins();
34
35
=head1 DESCRIPTION
36
37
Minimal-dependency plugin loader that can be safely called during early
38
initialization (e.g., from C4::Context BEGIN block) without causing
39
circular dependencies. This module provides a clean separation between
40
plugin loading and the main Koha::Plugins module.
41
42
=head1 METHODS
43
44
=head2 get_enabled_plugins
45
46
    my @plugins = Koha::Plugins::Loader->get_enabled_plugins();
47
48
Returns a list of enabled plugin objects. Results are cached in memory
49
to avoid repeated database queries and plugin instantiation.
50
51
This method:
52
- Checks if the plugin_data table exists
53
- Queries for enabled plugins
54
- Loads and instantiates plugin classes
55
- Caches the results
56
57
Returns an array of plugin objects in list context, or an empty list
58
if plugins are not available or none are enabled.
59
60
=cut
61
62
sub get_enabled_plugins {
63
    my ($class) = @_;
64
65
    my $cache_key = 'enabled_plugins';
66
    my $cached    = Koha::Cache::Memory::Lite->get_from_cache($cache_key);
67
    return @$cached if $cached;
68
69
    # Check if plugin_data table exists (using DBH for early init safety)
70
    my $dbh = eval { Koha::Database->dbh };
71
    return unless $dbh;
72
    return unless Koha::Database->table_exists( $dbh, 'plugin_data' );
73
74
    # Get enabled plugin classes using DBIx::Class when available
75
    # This ensures we see transactional changes in tests
76
    my @plugin_classes;
77
    if ( eval { require Koha::Plugins::Datas; 1 } ) {
78
79
        # Use DBIx::Class for proper transaction support
80
        eval {
81
            my $rs = Koha::Plugins::Datas->search( { plugin_key => '__ENABLED__', plugin_value => 1 } );
82
            @plugin_classes = $rs->get_column('plugin_class');
83
        };
84
    } else {
85
86
        # Fallback to raw DBI for early initialization
87
        my $plugin_classes_arrayref = $dbh->selectcol_arrayref(
88
            'SELECT plugin_class FROM plugin_data WHERE plugin_key = ? AND plugin_value = 1',
89
            {}, '__ENABLED__'
90
        ) || [];
91
        @plugin_classes = @$plugin_classes_arrayref;
92
    }
93
94
    # Load and instantiate plugins
95
    my @plugins;
96
    foreach my $plugin_class (@plugin_classes) {
97
98
        # Check if Koha::Plugins has a mocked can_load (for testing)
99
        # Otherwise use Module::Load::Conditional::can_load
100
        my $can_load_result;
101
        if ( eval { Koha::Plugins->can('can_load') } ) {
102
103
            # Use Koha::Plugins::can_load if it exists (might be mocked in tests)
104
            $can_load_result = eval { Koha::Plugins::can_load( modules => { $plugin_class => undef }, nocache => 1 ) };
105
        }
106
        if ( !defined $can_load_result ) {
107
108
            # Fall back to Module::Load::Conditional
109
            $can_load_result = can_load( modules => { $plugin_class => undef }, nocache => 1 );
110
        }
111
112
        next unless $can_load_result;
113
        my $plugin = eval { $plugin_class->new() };
114
        push @plugins, $plugin if $plugin;
115
    }
116
117
    Koha::Cache::Memory::Lite->set_in_cache( $cache_key, \@plugins ) if @plugins;
118
    return @plugins;
119
}
120
121
=head1 AUTHOR
122
123
Koha Development Team
124
125
=cut
126
127
1;
(-)a/t/db_dependent/Koha/Database.t (-1 / +22 lines)
Lines 17-23 Link Here
17
17
18
use Modern::Perl;
18
use Modern::Perl;
19
use Test::NoWarnings;
19
use Test::NoWarnings;
20
use Test::More tests => 5;
20
use Test::More tests => 6;
21
use C4::Context;
21
use C4::Context;
22
use t::lib::Mocks;
22
use t::lib::Mocks;
23
23
Lines 90-92 subtest 'generate_dsn' => sub { Link Here
90
        'DSN string for MySQL configuration with TLS with ca file.'
90
        'DSN string for MySQL configuration with TLS with ca file.'
91
    );
91
    );
92
};
92
};
93
94
subtest 'table_exists' => sub {
95
    plan tests => 5;
96
97
    # Use the existing dbh established at the top of the test
98
    my $dbh = C4::Context->dbh;
99
100
    # Test with a table that definitely exists
101
    ok( Koha::Database->table_exists( $dbh, 'borrowers' ), 'borrowers table exists (class method with dbh)' );
102
103
    # Test with a table that doesn't exist
104
    ok(
105
        !Koha::Database->table_exists( $dbh, 'nonexistent_table_xyz' ),
106
        'nonexistent table returns false (class method with dbh)'
107
    );
108
109
    # Test with various standard Koha tables
110
    ok( Koha::Database->table_exists( $dbh, 'items' ),       'items table exists' );
111
    ok( Koha::Database->table_exists( $dbh, 'biblioitems' ), 'biblioitems table exists' );
112
    ok( Koha::Database->table_exists( $dbh, 'plugin_data' ), 'plugin_data table exists' );
113
};
(-)a/t/db_dependent/Koha/Plugins/Loader.t (-1 / +126 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it under the
6
# terms of the GNU General Public License as published by the Free Software
7
# Foundation; either version 3 of the License, or (at your option) any later
8
# version.
9
#
10
# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
11
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
13
#
14
# You should have received a copy of the GNU General Public License along
15
# with Koha; if not, see <https://www.gnu.org/licenses>.
16
17
use Modern::Perl;
18
use File::Basename;
19
use Test::MockModule;
20
use Test::More tests => 6;
21
use Test::NoWarnings;
22
use Test::Warn;
23
24
use C4::Context;
25
use Koha::Cache::Memory::Lite;
26
use Koha::Database;
27
use Koha::Plugins::Datas;
28
use Koha::Plugins;
29
30
use t::lib::Mocks;
31
32
BEGIN {
33
    # Mock pluginsdir before loading Plugins module
34
    my $path = dirname(__FILE__) . '/../../../lib/plugins';
35
    t::lib::Mocks::mock_config( 'pluginsdir', $path );
36
37
    use_ok('Koha::Plugins::Loader');
38
}
39
40
my $schema = Koha::Database->new->schema;
41
42
subtest 'get_enabled_plugins - basic functionality' => sub {
43
    plan tests => 4;
44
45
    $schema->storage->txn_begin;
46
47
    # Clear cache before testing
48
    Koha::Cache::Memory::Lite->flush();
49
50
    # Remove any existing plugins
51
    Koha::Plugins::Datas->delete;
52
53
    # Test with no enabled plugins
54
    my @plugins = Koha::Plugins::Loader->get_enabled_plugins();
55
    is( scalar @plugins, 0, 'Returns empty list when no plugins are enabled' );
56
57
    # Test caching behavior
58
    my @plugins_cached = Koha::Plugins::Loader->get_enabled_plugins();
59
    is( scalar @plugins_cached, 0, 'Cached empty result works correctly' );
60
61
    # The core functionality of loading plugins is tested indirectly through
62
    # the Koha::Plugins tests which use the Loader
63
    ok( 1, 'Loader module loaded successfully' );
64
    can_ok( 'Koha::Plugins::Loader', 'get_enabled_plugins' );
65
66
    $schema->storage->txn_rollback;
67
};
68
69
subtest 'get_enabled_plugins - table does not exist' => sub {
70
    plan tests => 2;
71
72
    $schema->storage->txn_begin;
73
74
    # Clear cache
75
    Koha::Cache::Memory::Lite->flush();
76
77
    # Mock table_exists to return false
78
    my $mock_database = Test::MockModule->new('Koha::Database');
79
    $mock_database->mock( 'table_exists', sub { return 0; } );
80
81
    my @plugins = Koha::Plugins::Loader->get_enabled_plugins();
82
    is( scalar @plugins, 0, 'Returns empty list when plugin_data table does not exist' );
83
84
    # Verify it doesn't try to query the non-existent table
85
    # This test passes if no exception is thrown
86
    ok( 1, 'No exception thrown when table does not exist' );
87
88
    $schema->storage->txn_rollback;
89
};
90
91
subtest 'get_enabled_plugins - error handling' => sub {
92
    plan tests => 1;
93
94
    $schema->storage->txn_begin;
95
96
    # Clear cache
97
    Koha::Cache::Memory::Lite->flush();
98
99
    # Remove existing plugins
100
    Koha::Plugins::Datas->delete;
101
102
    # Test with invalid plugin class that can't be loaded by adding directly to DB
103
    Koha::Plugins::Data->new(
104
        {
105
            plugin_class => 'Koha::Plugin::NonExistent',
106
            plugin_key   => '__ENABLED__',
107
            plugin_value => 1,
108
        }
109
    )->store;
110
111
    my @plugins = Koha::Plugins::Loader->get_enabled_plugins();
112
    is( scalar @plugins, 0, 'Returns empty list when plugin class cannot be loaded' );
113
114
    $schema->storage->txn_rollback;
115
};
116
117
subtest 'Integration with Koha::Plugins' => sub {
118
    plan tests => 1;
119
120
    # The Loader is designed to be used by Koha::Plugins::get_enabled_plugins
121
    # Test that the integration point exists
122
    can_ok( 'Koha::Plugins', 'get_enabled_plugins' );
123
124
    # Full integration testing is done in t/db_dependent/Koha/Plugins/Plugins.t
125
    # which exercises the complete plugin loading flow including the Loader
126
};

Return to bug 38384