Bug 41619: Add Koha::CSV

This patch introduces Koha::CSV, a wrapper around Text::CSV_XS that
provides consistent defaults and methods for CSV generation across Koha.

The class:
- Inherits CSV delimiter from CSVDelimiter system preference
- Enforces security defaults (binary=1, formula='empty')
- Provides convenient methods for common CSV operations
- Allows per-instance configuration overrides

This establishes a foundation for standardizing CSV exports throughout
Koha, eliminating inconsistencies in CSV generation and providing a
testable, maintainable approach to CSV handling.

Test plan:
1. Apply patch
2. Run:
   $ ktd --shell
  k$ prove t/Koha/CSV.t
=> SUCCESS: Tests pass!
3. Tests cover:
   - Default initialization with CSVDelimiter preference
   - Preference value inheritance (comma, semicolon, tabulation)
   - Constructor parameter overrides
   - add_row() method with various data types
   - combine() and string() methods
   - print() to filehandle
   - Proper quote escaping and empty field handling
4. Sign off :-D

Signed-off-by: David Nind <david@davidnind.com>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Tomás Cohen Arazi 2026-01-14 16:41:33 -03:00 committed by Lucas Gass
parent ab14eb2bca
commit 8895b74bee
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
2 changed files with 366 additions and 0 deletions

226
Koha/CSV.pm Normal file
View File

@ -0,0 +1,226 @@
package Koha::CSV;
# Copyright 2026 Theke Solutions
#
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# 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.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <https://www.gnu.org/licenses>.
use Modern::Perl;
use Text::CSV_XS;
use C4::Context;
use base qw(Class::Accessor);
__PACKAGE__->mk_accessors(qw( binary formula always_quote eol sep_char ));
=head1 NAME
Koha::CSV - Wrapper around Text::CSV_XS with Koha-specific defaults
=head1 SYNOPSIS
my $csv = Koha::CSV->new();
$csv->combine(@fields);
my $line = $csv->string();
# Override defaults
my $csv = Koha::CSV->new( { sep_char => ';', always_quote => 1 } );
=head1 DESCRIPTION
This class wraps Text::CSV_XS providing consistent defaults and methods
used across Koha for CSV generation and parsing.
Configuration is inherited from Koha system preferences where applicable,
but can be overridden per instance.
=head2 Configuration Inheritance
=over 4
=item * B<sep_char> - Defaults to I<CSVDelimiter> system preference (or ',' if not set)
=item * B<binary> - Always 1 (required for UTF-8 support)
=item * B<formula> - Always 'empty' (security: prevents formula injection)
=item * B<always_quote> - Defaults to 0, can be overridden
=item * B<eol> - Defaults to "\n", can be overridden
=back
=cut
=head2 new
my $csv = Koha::CSV->new();
my $csv = Koha::CSV->new({ sep_char => ';', always_quote => 1 });
Creates a new Koha::CSV object. Accepts optional parameters to override defaults.
Parameters:
- sep_char: CSV delimiter (defaults to CSVDelimiter system preference)
- always_quote: Whether to quote all fields (defaults to 0)
- eol: End of line character (defaults to "\n")
Note: binary and formula parameters are fixed for security and cannot be overridden.
=cut
sub new {
my ( $class, $params ) = @_;
$params //= {};
# Get delimiter from Koha configuration if not explicitly provided
my $sep_char = $params->{sep_char} // C4::Context->csv_delimiter();
my $self = $class->SUPER::new(
{
binary => 1, # Always 1 for UTF-8
formula => 'empty', # Always 'empty' for security
always_quote => $params->{always_quote} // 0, # Overridable
eol => $params->{eol} // "\n", # Overridable
sep_char => $sep_char, # From Koha config or override
}
);
$self->{_csv} = Text::CSV_XS->new(
{
binary => $self->binary,
formula => $self->formula,
always_quote => $self->always_quote,
eol => $self->eol,
sep_char => $self->sep_char,
}
);
return $self;
}
=head2 combine
$csv->combine(@fields);
Combines fields into a CSV line. Returns true on success.
=cut
sub combine {
my ( $self, @fields ) = @_;
return $self->{_csv}->combine(@fields);
}
=head2 string
my $line = $csv->string();
Returns the combined CSV line as a string.
=cut
sub string {
my ($self) = @_;
return $self->{_csv}->string();
}
=head2 add_row
my $line = $csv->add_row(@fields);
# or
my $line = $csv->add_row(\@fields);
Convenience method that combines fields and returns the CSV line as a string.
Accepts either an array or arrayref of fields.
=cut
sub add_row {
my ( $self, @fields ) = @_;
# Handle arrayref or array
@fields = @{ $fields[0] } if @fields == 1 && ref $fields[0] eq 'ARRAY';
$self->combine(@fields) or return;
return $self->string();
}
=head2 parse
$csv->parse($line);
Parses a CSV line. Returns true on success.
=cut
sub parse {
my ( $self, $line ) = @_;
return $self->{_csv}->parse($line);
}
=head2 fields
my @fields = $csv->fields();
Returns the parsed fields from the last parse() call.
=cut
sub fields {
my ($self) = @_;
return $self->{_csv}->fields();
}
=head2 print
$csv->print($fh, \@fields);
Prints fields to a filehandle as a CSV line.
=cut
sub print {
my ( $self, $fh, $fields ) = @_;
return $self->{_csv}->print( $fh, $fields );
}
=head2 getline
my $fields = $csv->getline($fh);
Reads and parses a line from a filehandle. Returns arrayref of fields.
=cut
sub getline {
my ( $self, $fh ) = @_;
return $self->{_csv}->getline($fh);
}
=head2 error_diag
my $error = $csv->error_diag();
Returns error diagnostics from the last operation.
=cut
sub error_diag {
my ($self) = @_;
return $self->{_csv}->error_diag();
}
1;

140
t/Koha/CSV.t Executable file
View File

@ -0,0 +1,140 @@
#!/usr/bin/perl
# Copyright 2026 Theke Solutions
#
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# 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.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <https://www.gnu.org/licenses>.
use Modern::Perl;
use Test::More tests => 8;
use Test::Warn;
use Test::NoWarnings;
use t::lib::Mocks;
use C4::Context;
BEGIN {
use_ok('Koha::CSV');
}
subtest 'new() tests' => sub {
plan tests => 5;
my $csv = Koha::CSV->new();
isa_ok( $csv, 'Koha::CSV', 'Object created' );
is( $csv->binary, 1, 'binary defaults to 1' );
is( $csv->formula, 'empty', 'formula defaults to empty' );
is( $csv->always_quote, 0, 'always_quote defaults to 0' );
is( $csv->eol, "\n", 'eol defaults to newline' );
};
subtest 'new() with CSVDelimiter preference tests' => sub {
plan tests => 3;
t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' );
my $csv = Koha::CSV->new();
is( $csv->sep_char, ',', 'Uses comma from preference' );
t::lib::Mocks::mock_preference( 'CSVDelimiter', ';' );
$csv = Koha::CSV->new();
is( $csv->sep_char, ';', 'Uses semicolon from preference' );
t::lib::Mocks::mock_preference( 'CSVDelimiter', 'tabulation' );
$csv = Koha::CSV->new();
is( $csv->sep_char, "\t", 'Converts tabulation to tab character' );
};
subtest 'new() with overrides tests' => sub {
plan tests => 3;
my $csv = Koha::CSV->new(
{
sep_char => '|',
always_quote => 1,
eol => "\r\n",
}
);
is( $csv->sep_char, '|', 'sep_char overridden' );
is( $csv->always_quote, 1, 'always_quote overridden' );
is( $csv->eol, "\r\n", 'eol overridden' );
};
subtest 'add_row() tests' => sub {
plan tests => 4;
t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' );
my $csv = Koha::CSV->new();
# Test with array
my $line = $csv->add_row( 'Title', 'Author', 'Year' );
is( $line, "Title,Author,Year\n", 'add_row with array' );
# Test with arrayref
$line = $csv->add_row( [ 'Book 1', 'Smith, John', '2024' ] );
is( $line, "\"Book 1\",\"Smith, John\",2024\n", 'add_row with arrayref and quoted field' );
# Test with special characters
$line = $csv->add_row( [ 'Title with "quotes"', 'Normal', 'Value' ] );
is( $line, "\"Title with \"\"quotes\"\"\",Normal,Value\n", 'add_row with quotes escaped' );
# Test with empty fields
$line = $csv->add_row( [ 'Field1', '', 'Field3' ] );
is( $line, "Field1,,Field3\n", 'add_row with empty field' );
};
subtest 'combine() and string() tests' => sub {
plan tests => 3;
t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' );
my $csv = Koha::CSV->new();
my $status = $csv->combine( 'A', 'B', 'C' );
ok( $status, 'combine returns true on success' );
my $line = $csv->string();
is( $line, "A,B,C\n", 'string returns combined line' );
# Test with always_quote
$csv = Koha::CSV->new( { always_quote => 1 } );
$csv->combine( 'A', 'B', 'C' );
$line = $csv->string();
is( $line, "\"A\",\"B\",\"C\"\n", 'always_quote wraps all fields' );
};
subtest 'print() tests' => sub {
plan tests => 2;
t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' );
my $csv = Koha::CSV->new();
# Create temp file
my $output = '';
open my $fh, '>', \$output or die "Cannot open string as file: $!";
my $status = $csv->print( $fh, [ 'Header1', 'Header2', 'Header3' ] );
ok( $status, 'print returns true on success' );
$csv->print( $fh, [ 'Value1', 'Value2', 'Value3' ] );
close $fh;
is(
$output,
"Header1,Header2,Header3\nValue1,Value2,Value3\n",
'print writes correct CSV to filehandle'
);
};