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:
parent
ab14eb2bca
commit
8895b74bee
|
|
@ -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;
|
||||
|
|
@ -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'
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue