Files
percona-toolkit/lib/ReportFormatter.pm
Viktor Szépe 2bd40d8c39 Remove trailing spaces (#665)
* Remove trailing spaces

* PR-665 -  Remove trailing spaces

- Updated not stable test t/pt-online-schema-change/preserve_triggers.t
- Updated utilities in bin directory

* PR-665 -  Remove trailing spaces

- Fixed typos

* PR-665 -  Remove trailing spaces

- Fixed typos

---------

Co-authored-by: Sveta Smirnova <sveta.smirnova@percona.com>
2023-09-06 01:15:12 +03:00

515 lines
17 KiB
Perl

# This program is copyright 2009-2011 Percona Ireland Ltd.
# Feedback and improvements are welcome.
#
# THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
# WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
# MERCHANTIBILITY AND FITNESS FOR A PARTICULAR PURPOSE.
#
# This program 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, version 2; OR the Perl Artistic License. On UNIX and similar
# systems, you can issue `man perlgpl' or `man perlartistic' to read these
# licenses.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place, Suite 330, Boston, MA 02111-1307 USA.
# ###########################################################################
# ReportFormatter package
# ###########################################################################
{
# Package: ReportFormatter
# ReportFormatter makes columnized reports given variable-width data lines.
# It does the hard work of automatically sizing columns and truncating data
# to fit the line width (unless all data fits the line which doesn't happen
# often). This involves the following magic.
#
# Internally, all column widths are *first* treated as percentages of the
# line width. Even if a column is specified with width=>N where N is some
# length of characters, this is converted to a percent/line width (rounded up).
#
# Columns specified with width=>N or width_pct=>P (where P is some percent
# of *total* line width, not remaining line width when used with other width=>N
# columns) are fixed. You get exactly what you specify even if this results
# in the column header/name or values being truncated to fit. Otherwise,
# the column is "auto-width" and you get whatever the package gives you.
#
# add_line() keeps track of min and max column values. When get_report() is
# called, it calls _calculate_column_widths() which begins the magic. It
# converts each column's percentage width to characters, called the print width.
# So width_pct=>50 == print_width=>39 (characters). If the column is fixed
# (i.e. *not* auto-width) then print width is fixed. Otherwise, the print
# width is adjusted as follows.
#
# The print width is set to the min val if, for some reason, it's less than
# the min val. This is so the column is at least wide enough to print the
# minimum value. Else, if there's a max val and the print val is wider than
# it, then the print val is set to the max val. This reclaims "extra space"
# from auto-width cols.
#
# Extra space is distributed evenly among auto-width cols with print widths
# less than the column's max val or header/name. This widens auto-width cols
# to either show longer values or truncate the column header/name less.
#
# After these adjustments, get_report() calls _truncate_headers() and
# _truncate_line_values(). These truncate output to the columns' final,
# calculated widths.
package ReportFormatter;
use Lmo;
use English qw(-no_match_vars);
use constant PTDEBUG => $ENV{PTDEBUG} || 0;
use List::Util qw(min max);
use POSIX qw(ceil);
eval { require Term::ReadKey };
my $have_term = $EVAL_ERROR ? 0 : 1;
# * underline_header bool: underline headers with =
# * line_prefix scalar: prefix every line with this string
# * line_width scalar: line width in characters or 'auto'
# * column_spacing scalar: string between columns (default one space)
# * extend_right bool: allow right-most column to extend beyond
# line width (default: no)
# * column_errors scalar: die or warn on column errors (default warn)
# * truncate_header_side scalar: left or right (default left)
# * strip_whitespace bool: strip leading and trailing whitespace
# * title scalar: title for the report
has underline_header => (
is => 'ro',
isa => 'Bool',
default => sub { 1 },
);
has line_prefix => (
is => 'ro',
isa => 'Str',
default => sub { '# ' },
);
has line_width => (
is => 'ro',
isa => 'Int',
default => sub { 78 },
);
has column_spacing => (
is => 'ro',
isa => 'Str',
default => sub { ' ' },
);
has extend_right => (
is => 'ro',
isa => 'Bool',
default => sub { '' },
);
has truncate_line_mark => (
is => 'ro',
isa => 'Str',
default => sub { '...' },
);
has column_errors => (
is => 'ro',
isa => 'Str',
default => sub { 'warn' },
);
has truncate_header_side => (
is => 'ro',
isa => 'Str',
default => sub { 'left' },
);
has strip_whitespace => (
is => 'ro',
isa => 'Bool',
default => sub { 1 },
);
has title => (
is => 'rw',
isa => 'Str',
predicate => 'has_title',
);
# Internal
has n_cols => (
is => 'rw',
isa => 'Int',
default => sub { 0 },
init_arg => undef,
);
has cols => (
is => 'ro',
isa => 'ArrayRef',
init_arg => undef,
default => sub { [] },
clearer => 'clear_cols',
);
has lines => (
is => 'ro',
isa => 'ArrayRef',
init_arg => undef,
default => sub { [] },
clearer => 'clear_lines',
);
has truncate_headers => (
is => 'rw',
isa => 'Bool',
default => sub { undef },
init_arg => undef,
clearer => 'clear_truncate_headers',
);
sub BUILDARGS {
my $class = shift;
my $args = $class->SUPER::BUILDARGS(@_);
# This is not tested or currently used, but I like the idea and
# think one day it will be very handy in pt-config-diff.
if ( ($args->{line_width} || '') eq 'auto' ) {
die "Cannot auto-detect line width because the Term::ReadKey module "
. "is not installed" unless $have_term;
($args->{line_width}) = Term::ReadKey::GetTerminalSize();
PTDEBUG && _d('Line width:', $args->{line_width});
}
return $args;
}
# @cols is an array of hashrefs. Each hashref describes a column and can
# have the following keys:
# Required args:
# * name column's name
# Optional args:
# * width fixed column width in characters
# * width_pct relative column width as percentage of line width
# * truncate can truncate column (default yes)
# * truncate_mark append string to truncate col vals (default ...)
# * truncate_side truncate left or right side of value (default right)
# * undef_value string for undef values (default '')
sub set_columns {
my ( $self, @cols ) = @_;
my $min_hdr_wid = 0; # check that header fits on line
my $used_width = 0;
my @auto_width_cols;
for my $i ( 0..$#cols ) {
my $col = $cols[$i];
my $col_name = $col->{name};
my $col_len = length $col_name;
die "Column does not have a name" unless defined $col_name;
if ( $col->{width} ) {
$col->{width_pct} = ceil(($col->{width} * 100) / $self->line_width());
PTDEBUG && _d('col:', $col_name, 'width:', $col->{width}, 'chars =',
$col->{width_pct}, '%');
}
if ( $col->{width_pct} ) {
$used_width += $col->{width_pct};
}
else {
# Auto-width columns get an equal share of whatever amount
# of line width remains. Later, they can be adjusted again.
PTDEBUG && _d('Auto width col:', $col_name);
$col->{auto_width} = 1;
push @auto_width_cols, $i;
}
# Set defaults if another value wasn't given.
$col->{truncate} = 1 unless defined $col->{truncate};
$col->{truncate_mark} = '...' unless defined $col->{truncate_mark};
$col->{truncate_side} ||= 'right';
$col->{undef_value} = '' unless defined $col->{undef_value};
# These values will be computed/updated as lines are added.
$col->{min_val} = 0;
$col->{max_val} = 0;
# Calculate if the minimum possible header width will exceed the line.
$min_hdr_wid += $col_len;
$col->{header_width} = $col_len;
# Used with extend_right.
$col->{right_most} = 1 if $i == $#cols;
push @{$self->cols}, $col;
}
$self->n_cols( scalar @cols );
if ( ($used_width || 0) > 100 ) {
die "Total width_pct for all columns is >100%";
}
# Divide remain line width (in %) among auto-width columns.
if ( @auto_width_cols ) {
my $wid_per_col = int((100 - $used_width) / scalar @auto_width_cols);
PTDEBUG && _d('Line width left:', (100-$used_width), '%;',
'each auto width col:', $wid_per_col, '%');
map { $self->cols->[$_]->{width_pct} = $wid_per_col } @auto_width_cols;
}
# Add to the minimum possible header width the spacing between columns.
$min_hdr_wid += ($self->n_cols() - 1) * length $self->column_spacing();
PTDEBUG && _d('min header width:', $min_hdr_wid);
if ( $min_hdr_wid > $self->line_width() ) {
PTDEBUG && _d('Will truncate headers because min header width',
$min_hdr_wid, '> line width', $self->line_width());
$self->truncate_headers(1);
}
return;
}
# Add a line to the report. Does not print the line or the report.
# @vals is an array of values for each column. There should be as
# many vals as columns. Use undef for columns that have no values.
sub add_line {
my ( $self, @vals ) = @_;
my $n_vals = scalar @vals;
if ( $n_vals != $self->n_cols() ) {
$self->_column_error("Number of values $n_vals does not match "
. "number of columns " . $self->n_cols());
}
for my $i ( 0..($n_vals-1) ) {
my $col = $self->cols->[$i];
my $val = defined $vals[$i] ? $vals[$i] : $col->{undef_value};
if ( $self->strip_whitespace() ) {
$val =~ s/^\s+//g;
$val =~ s/\s+$//;
$vals[$i] = $val;
}
my $width = length $val;
$col->{min_val} = min($width, ($col->{min_val} || $width));
$col->{max_val} = max($width, ($col->{max_val} || $width));
}
push @{$self->lines}, \@vals;
return;
}
# Returns the formatted report for the columns and lines added earlier.
sub get_report {
my ( $self, %args ) = @_;
$self->_calculate_column_widths();
if ( $self->truncate_headers() ) {
$self->_truncate_headers();
}
$self->_truncate_line_values(%args);
my @col_fmts = $self->_make_column_formats();
my $fmt = $self->line_prefix()
. join($self->column_spacing(), @col_fmts);
PTDEBUG && _d('Format:', $fmt);
# Make the printf line format for the header and ensure that its labels
# are always left justified.
(my $hdr_fmt = $fmt) =~ s/%([^-])/%-$1/g;
# Build the report line by line, starting with the title and header lines.
my @lines;
push @lines, $self->line_prefix() . $self->title() if $self->has_title();
push @lines, $self->_truncate_line(
sprintf($hdr_fmt, map { $_->{name} } @{$self->cols}),
strip => 1,
mark => '',
);
if ( $self->underline_header() ) {
my @underlines = map { '=' x $_->{print_width} } @{$self->cols};
push @lines, $self->_truncate_line(
sprintf($fmt, map { $_ || '' } @underlines),
mark => '',
);
}
push @lines, map {
my $vals = $_;
my $i = 0;
my @vals = map {
my $val = defined $_ ? $_ : $self->cols->[$i++]->{undef_value};
$val = '' if !defined $val;
$val =~ s/\n/ /g;
$val;
} @$vals;
my $line = sprintf($fmt, @vals);
if ( $self->extend_right() ) {
$line;
}
else {
$self->_truncate_line($line);
}
} @{$self->lines};
# Clean up any leftover state
$self->clear_cols();
$self->clear_lines();
$self->clear_truncate_headers();
return join("\n", @lines) . "\n";
}
sub truncate_value {
my ( $self, $col, $val, $width, $side ) = @_;
return $val if length $val <= $width;
return $val if $col->{right_most} && $self->extend_right();
$side ||= $col->{truncate_side};
my $mark = $col->{truncate_mark};
if ( $side eq 'right' ) {
$val = substr($val, 0, $width - length $mark);
$val .= $mark;
}
elsif ( $side eq 'left') {
$val = $mark . substr($val, -1 * $width + length $mark);
}
else {
PTDEBUG && _d("I don't know how to", $side, "truncate values");
}
return $val;
}
sub _calculate_column_widths {
my ( $self ) = @_;
my $extra_space = 0;
foreach my $col ( @{$self->cols} ) {
my $print_width = int($self->line_width() * ($col->{width_pct} / 100));
PTDEBUG && _d('col:', $col->{name}, 'width pct:', $col->{width_pct},
'char width:', $print_width,
'min val:', $col->{min_val}, 'max val:', $col->{max_val});
if ( $col->{auto_width} ) {
if ( $col->{min_val} && $print_width < $col->{min_val} ) {
PTDEBUG && _d('Increased to min val width:', $col->{min_val});
$print_width = $col->{min_val};
}
elsif ( $col->{max_val} && $print_width > $col->{max_val} ) {
PTDEBUG && _d('Reduced to max val width:', $col->{max_val});
$extra_space += $print_width - $col->{max_val};
$print_width = $col->{max_val};
}
}
$col->{print_width} = $print_width;
PTDEBUG && _d('print width:', $col->{print_width});
}
PTDEBUG && _d('Extra space:', $extra_space);
while ( $extra_space-- ) {
foreach my $col ( @{$self->cols} ) {
if ( $col->{auto_width}
&& ( $col->{print_width} < $col->{max_val}
|| $col->{print_width} < $col->{header_width})
) {
# PTDEBUG && _d('Increased', $col->{name}, 'width');
$col->{print_width}++;
}
}
}
return;
}
sub _truncate_headers {
my ( $self, $col ) = @_;
my $side = $self->truncate_header_side();
foreach my $col ( @{$self->cols} ) {
my $col_name = $col->{name};
my $print_width = $col->{print_width};
next if length $col_name <= $print_width;
$col->{name} = $self->truncate_value($col, $col_name, $print_width, $side);
PTDEBUG && _d('Truncated hdr', $col_name, 'to', $col->{name},
'max width:', $print_width);
}
return;
}
sub _truncate_line_values {
my ( $self, %args ) = @_;
my $n_vals = $self->n_cols() - 1;
foreach my $vals ( @{$self->lines} ) {
for my $i ( 0..$n_vals ) {
my $col = $self->cols->[$i];
my $val = defined $vals->[$i] ? $vals->[$i] : $col->{undef_value};
my $width = length $val;
if ( $col->{print_width} && $width > $col->{print_width} ) {
if ( !$col->{truncate} ) {
$self->_column_error("Value '$val' is too wide for column "
. $col->{name});
}
# If _column_error() dies then we never get here. If it warns
# then we truncate the value despite $col->{truncate} being
# false so the user gets something rather than nothing.
my $callback = $args{truncate_callback};
my $print_width = $col->{print_width};
$val = $callback ? $callback->($col, $val, $print_width)
: $self->truncate_value($col, $val, $print_width);
PTDEBUG && _d('Truncated val', $vals->[$i], 'to', $val,
'; max width:', $print_width);
$vals->[$i] = $val;
}
}
}
return;
}
# Make the printf line format for each row given the columns' settings.
sub _make_column_formats {
my ( $self ) = @_;
my @col_fmts;
my $n_cols = $self->n_cols() - 1;
for my $i ( 0..$n_cols ) {
my $col = $self->cols->[$i];
# Normally right-most col has no width so it can potentially
# extend_right. But if it's right-justified, it requires a width.
my $width = $col->{right_most} && !$col->{right_justify} ? ''
: $col->{print_width};
my $col_fmt = '%' . ($col->{right_justify} ? '' : '-') . $width . 's';
push @col_fmts, $col_fmt;
}
return @col_fmts;
}
sub _truncate_line {
my ( $self, $line, %args ) = @_;
my $mark = defined $args{mark} ? $args{mark} : $self->truncate_line_mark();
if ( $line ) {
$line =~ s/\s+$// if $args{strip};
my $len = length($line);
if ( $len > $self->line_width() ) {
$line = substr($line, 0, $self->line_width() - length $mark);
$line .= $mark if $mark;
}
}
return $line;
}
sub _column_error {
my ( $self, $err ) = @_;
my $msg = "Column error: $err";
$self->column_errors() eq 'die' ? die $msg : warn $msg;
return;
}
sub _d {
my ($package, undef, $line) = caller 0;
@_ = map { (my $temp = $_) =~ s/\n/\n# /g; $temp; }
map { defined $_ ? $_ : 'undef' }
@_;
print STDERR "# $package:$line $PID ", join(' ', @_), "\n";
}
no Lmo;
1;
}
# ###########################################################################
# End ReportFormatter package
# ###########################################################################