From f296f2fc6cffa22b52316641ff9169b0c5f4ba07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Cohen=20Arazi?= Date: Wed, 14 Jan 2026 16:41:33 -0300 Subject: [PATCH] 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 --- Koha/CSV.pm | 226 +++++++++++++++++++++++++++++++++++++++++++++++++++ t/Koha/CSV.t | 140 +++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 Koha/CSV.pm create mode 100755 t/Koha/CSV.t diff --git a/Koha/CSV.pm b/Koha/CSV.pm new file mode 100644 index 0000000000..e64c80d128 --- /dev/null +++ b/Koha/CSV.pm @@ -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 . + +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 - Defaults to I system preference (or ',' if not set) + +=item * B - Always 1 (required for UTF-8 support) + +=item * B - Always 'empty' (security: prevents formula injection) + +=item * B - Defaults to 0, can be overridden + +=item * B - 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; diff --git a/t/Koha/CSV.t b/t/Koha/CSV.t new file mode 100755 index 0000000000..1c0a1e9d76 --- /dev/null +++ b/t/Koha/CSV.t @@ -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 . + +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' + ); +}; -- 2.50.1 (Apple Git-155)