diff --git a/README b/README index fa74124..8025763 100644 --- a/README +++ b/README @@ -84,6 +84,8 @@ DESCRIPTION --no-space-function : remove space between function call and the open parenthesis. --redundant-parenthesis: do not remove redundant parenthesis in DML. + --vertical-align : vertically align CREATE TABLE column definitions and + trailing comments. Disabled by default. Examples: @@ -265,6 +267,24 @@ SPECIAL FORMATTING in some cases they must be preseved. Using this option will keep redundant parenthesis untouched. + Option --vertical-align + Use this option to vertically align direct, single-line column + definitions inside CREATE TABLE statements. It aligns column names, data + types, column constraints such as DEFAULT, and trailing comments. For + example: + + CREATE TABLE example ( + id serial PRIMARY KEY, -- some comment + name varchar(100) NOT NULL, -- another comment + email varchar(100) NOT NULL, -- third comment + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP -- last comment + ); + + Table-level constraints and multiline column definitions are preserved + but are not included in the alignment calculation. The option is disabled + by default and only applies to text output. It can also be enabled in a + configuration file with vertical-align=1. + HINTS Configuration If the default settings of pg_format doesn't fit all your needs you can diff --git a/doc/pg_format.conf.sample b/doc/pg_format.conf.sample index 549d0c9..b3fd6cb 100644 --- a/doc/pg_format.conf.sample +++ b/doc/pg_format.conf.sample @@ -86,3 +86,5 @@ wrap-comment=0 # Remove the space character between a function call and the open parenthesis that follow. no-space-function=0 +# Vertically align CREATE TABLE column definitions and trailing comments. +vertical-align=0 diff --git a/doc/pg_format.pod b/doc/pg_format.pod index 84347b6..f55210e 100644 --- a/doc/pg_format.pod +++ b/doc/pg_format.pod @@ -278,6 +278,23 @@ By default, pgFormatter tries to remove redundant parenthesis in DML but in some cases they must be preseved. Using this option will keep redundant parenthesis untouched. +=head2 Option --vertical-align + +Use this option to vertically align direct, single-line column definitions +inside CREATE TABLE statements. It aligns column names, data types, column +constraints such as DEFAULT, and trailing comments. For example: + + CREATE TABLE example ( + id serial PRIMARY KEY, -- some comment + name varchar(100) NOT NULL, -- another comment + email varchar(100) NOT NULL, -- third comment + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP -- last comment + ); + +Table-level constraints and multiline column definitions are preserved but are +not included in the alignment calculation. The option is disabled by default +and only applies to text output. + =head1 HINTS =head2 Configuration diff --git a/lib/pgFormatter/Beautify.pm b/lib/pgFormatter/Beautify.pm index 83cc358..1cfba1a 100755 --- a/lib/pgFormatter/Beautify.pm +++ b/lib/pgFormatter/Beautify.pm @@ -166,6 +166,8 @@ Takes options as hash. Following options are recognized: =item * redundant_parenthesis - do not eliminate redundant parenthesis in DML queries +=item * vertical_align - vertically align CREATE TABLE column definitions + =back For defaults, please check function L. @@ -180,7 +182,7 @@ sub new { $self->set_defaults(); for my $key ( - qw( query spaces space break wrap keywords functions rules uc_keywords uc_functions uc_types no_comments no_grouping placeholder multiline separator comma comma_break format colorize format_type wrap_limit wrap_after wrap_comment numbering redshift no_extra_line keep_newline no_space_function redundant_parenthesis) + qw( query spaces space break wrap keywords functions rules uc_keywords uc_functions uc_types no_comments no_grouping placeholder multiline separator comma comma_break format colorize format_type wrap_limit wrap_after wrap_comment numbering redshift no_extra_line keep_newline no_space_function redundant_parenthesis vertical_align) ) { $self->{$key} = $options{$key} if defined $options{$key}; @@ -498,6 +500,11 @@ s/CODEPART[B]*(\d+)CODEPART[B]*/$self->{ 'placeholder_values' }[$1]/igs; # Replace any PGFESCQ2 by "" $self->{'content'} =~ s/PGFESCQ2/""/g; + if ( $self->{'vertical_align'} && $self->{'format'} eq 'text' ) { + $self->{'content'} = + $self->_align_create_table_columns( $self->{'content'} ); + } + return $self->{'content'}; } @@ -778,6 +785,646 @@ sub tokenize_sql { $self->{'_tokens'} = \@query; } +=head2 _parse_create_table_column + +Parse one single-line CREATE TABLE column definition into the parts needed by +vertical alignment. A trailing comment is stored separately from the SQL. +Returns undef for table constraints, embedded comments, multiline input, and +definitions that do not contain both a column name and a data type. + +This method only analyzes input. It does not change the formatted SQL. + +=cut + +sub _parse_create_table_column { + my ( $self, $line ) = @_; + + return if ( !defined $line || $line !~ /\S/ || $line =~ /[\r\n]/ ); + + my ($indent) = $line =~ /^(\s*)/; + my $definition = $line; + $definition =~ s/^\s+|\s+$//g; + + my @tokens = $self->tokenize_sql($definition); + + # A trailing comment belongs to the complete column definition rather than + # to its data type or constraints. Store it separately so the SQL can be + # aligned first and comments can be placed in their own output column. + my $comment = ''; + if ( @tokens && $self->_is_comment( $tokens[-1] ) ) { + $comment = pop @tokens; + $comment =~ s/^\s+//; + } + + # Comments embedded in the middle of a definition remain unsupported. They + # can affect SQL structure, so preserving the original line is safer than + # attempting to rearrange it. + return if ( grep { $self->_is_comment($_) } @tokens ); + + my $comma = ''; + if ( @tokens && $tokens[-1] eq ',' ) { + pop @tokens; + $comma = ','; + } + + return if ( @tokens < 2 ); + + my $name = shift @tokens; + + # These tokens start table-level definitions rather than column definitions. + my %table_constraint = map { $_ => 1 } qw( + CHECK CONSTRAINT EXCLUDE FOREIGN LIKE PRIMARY UNIQUE + ); + return if ( $name !~ /^"/ && $table_constraint{ uc($name) } ); + return if ( $name =~ /^(?:[\[\](),;])$/ ); + + # A column's data type ends at the first top-level column constraint. Tokens + # inside type modifiers, such as numeric(10, 2), must remain part of the type. + my %column_constraint = map { $_ => 1 } qw( + CHECK COLLATE COMPRESSION CONSTRAINT DEFAULT GENERATED IDENTITY NOT NULL + PRIMARY REFERENCES STORAGE UNIQUE + ); + + my $parenthesis_depth = 0; + my $bracket_depth = 0; + my $constraint_index = scalar @tokens; + + for my $index ( 0 .. $#tokens ) { + my $token = $tokens[$index]; + + if ( + $parenthesis_depth == 0 + && $bracket_depth == 0 + && $column_constraint{ uc($token) } + ) + { + $constraint_index = $index; + last; + } + + $parenthesis_depth++ if ( $token eq '(' ); + $parenthesis_depth-- + if ( $token eq ')' && $parenthesis_depth > 0 ); + $bracket_depth++ if ( $token eq '[' ); + $bracket_depth-- if ( $token eq ']' && $bracket_depth > 0 ); + } + + my @declaration_tokens = + $constraint_index > 0 ? @tokens[ 0 .. $constraint_index - 1 ] : (); + my @remainder_tokens = + $constraint_index <= $#tokens ? @tokens[ $constraint_index .. $#tokens ] : (); + + return if ( !@declaration_tokens ); + + # Keep an immediately following PRIMARY KEY or UNIQUE with the declaration. + # This supports the intended visual grouping without reordering SQL tokens. + if ( + @remainder_tokens >= 2 + && uc( $remainder_tokens[0] ) eq 'PRIMARY' + && uc( $remainder_tokens[1] ) eq 'KEY' + ) + { + push @declaration_tokens, splice( @remainder_tokens, 0, 2 ); + } + elsif ( @remainder_tokens && uc( $remainder_tokens[0] ) eq 'UNIQUE' ) { + push @declaration_tokens, shift @remainder_tokens; + } + + return { + indent => $indent, + name => $name, + declaration_tokens => \@declaration_tokens, + remainder_tokens => \@remainder_tokens, + comma => $comma, + comment => $comment, + }; +} + +=head2 _render_sql_tokens + +Render a list of SQL tokens as one compact, single-line SQL fragment. + +This helper is intentionally limited to the fragments produced by +_parse_create_table_column(). It preserves token order and only decides where +spaces belong. It does not perform alignment or change keyword casing. + +=cut + +sub _render_sql_tokens { + my ( $self, $tokens ) = @_; + + return '' if ( !$tokens || !@{$tokens} ); + + my %space_before_parenthesis = map { $_ => 1 } qw( + AS CHECK DEFAULT IN REFERENCES + ); + + my $rendered = ''; + + for my $index ( 0 .. $#{$tokens} ) { + my $token = $tokens->[$index]; + my $previous_token = $index > 0 ? $tokens->[ $index - 1 ] : undef; + my $before_previous = $index > 1 ? $tokens->[ $index - 2 ] : undef; + + if ( !defined $previous_token ) { + $rendered = $token; + next; + } + + # Closing punctuation, casts, and array suffixes attach directly to the + # preceding token. Tokens immediately inside parentheses and brackets do + # not receive a leading space either. + if ( + $token =~ /^(?:\)|\]|,|;)$/ + || $token =~ /^::/ + || $previous_token eq '(' + || $previous_token eq '[' + || $token eq '[' + ) + { + $rendered .= $token; + next; + } + + if ( $token eq '(' ) { + my $force_space = + $space_before_parenthesis{ uc($previous_token) } + || ( defined $before_previous + && uc($before_previous) eq 'REFERENCES' ); + + # Preserve pgFormatter's existing parenthesis policy: known functions + # and data types stay attached, while unknown names keep a space unless + # --no-space-function was requested. + my $attach_parenthesis = + $self->{'no_space_function'} + || $self->_is_function($previous_token) + || $self->_is_type($previous_token); + + $rendered .= + ( $force_space || !$attach_parenthesis ? ' ' : '' ) . $token; + next; + } + + $rendered .= ' ' . $token; + } + + return $rendered; +} + +=head2 _pad_right + +Pad a string with spaces until it reaches the requested width. + +The helper returns the original string unchanged when it is already at least as +wide as the requested width. Alignment uses spaces deliberately: tabs would +make the result depend on the tab width configured by the editor displaying the +SQL. + +=cut + +sub _pad_right { + my ( $self, $value, $width ) = @_; + + $value = '' if ( !defined $value ); + $width = length($value) if ( !defined $width ); + + my $padding = $width - length($value); + return $value if ( $padding <= 0 ); + + return $value . ( ' ' x $padding ); +} + +=head2 _align_create_table_column_keyword + +Align a top-level keyword within the remainder of parsed CREATE TABLE columns. + +For every matching row, the tokens before the keyword are treated as a prefix. +Shorter prefixes are padded so the keyword starts in the same output column. +Keywords nested inside parentheses or array brackets are ignored. + +The method updates the rendered remainder stored in each matching row. It does +not reorder tokens or affect rows that do not contain the requested keyword. + +=cut + +sub _align_create_table_column_keyword { + my ( $self, $rows, $keyword ) = @_; + + return if ( !$rows || !@{$rows} || !defined $keyword || $keyword eq '' ); + + my $keyword_upper = uc($keyword); + my $prefix_width = 0; + my @matches; + + for my $row ( @{$rows} ) { + next if ( exists $row->{'original'} ); + + my $tokens = $row->{'remainder_tokens'}; + next if ( !$tokens || !@{$tokens} ); + + my $parenthesis_depth = 0; + my $bracket_depth = 0; + my $keyword_index; + + for my $index ( 0 .. $#{$tokens} ) { + my $token = $tokens->[$index]; + + if ( + $parenthesis_depth == 0 + && $bracket_depth == 0 + && uc($token) eq $keyword_upper + ) + { + $keyword_index = $index; + last; + } + + $parenthesis_depth++ if ( $token eq '(' ); + $parenthesis_depth-- + if ( $token eq ')' && $parenthesis_depth > 0 ); + $bracket_depth++ if ( $token eq '[' ); + $bracket_depth-- if ( $token eq ']' && $bracket_depth > 0 ); + } + + next if ( !defined $keyword_index ); + + my @prefix_tokens = + $keyword_index > 0 ? @{$tokens}[ 0 .. $keyword_index - 1 ] : (); + my @clause_tokens = @{$tokens}[ $keyword_index .. $#{$tokens} ]; + + my $prefix = $self->_render_sql_tokens( \@prefix_tokens ); + my $clause = $self->_render_sql_tokens( \@clause_tokens ); + + my $current_width = length($prefix); + $prefix_width = $current_width if ( $current_width > $prefix_width ); + + push @matches, + { + row => $row, + prefix => $prefix, + clause => $clause, + }; + } + + return if ( @matches < 2 ); + + for my $match (@matches) { + my $prefix = $match->{'prefix'}; + + if ( length($prefix) ) { + $match->{'row'}->{'remainder'} = + $self->_pad_right( $prefix, $prefix_width ) + . ' ' + . $match->{'clause'}; + } + else { + $match->{'row'}->{'remainder'} = + ( ' ' x ( $prefix_width + 1 ) ) . $match->{'clause'}; + } + } + + return; +} + +=head2 _parenthesis_delta + +Return the net parenthesis depth change for one SQL line. + +The existing SQL tokenizer is reused so parentheses inside quoted strings and +comments are not mistaken for structural parentheses. + +=cut + +sub _parenthesis_delta { + my ( $self, $line ) = @_; + + return 0 if ( !defined $line || $line eq '' ); + + my $delta = 0; + for my $token ( $self->tokenize_sql($line) ) { + $delta++ if ( $token eq '(' ); + $delta-- if ( $token eq ')' ); + } + + return $delta; +} + +=head2 _create_table_start + +Inspect one formatted line for the beginning of a regular CREATE TABLE body. + +The return value is a three-element list: whether the line begins a supported +CREATE TABLE statement, whether its opening parenthesis is present, and the net +parenthesis depth on that line. When the opening parenthesis is absent it may +occur on the following line. + +CREATE TABLE AS, PARTITION OF, and typed-table forms are deliberately skipped +because their parentheses do not necessarily contain ordinary column +definitions. + +=cut + +sub _create_table_start { + my ( $self, $line ) = @_; + + return ( 0, 0, 0 ) + if ( !defined $line || $line !~ /^\s*CREATE\b/i ); + + my @tokens = $self->tokenize_sql($line); + return ( 0, 0, 0 ) if ( !@tokens || uc( $tokens[0] ) ne 'CREATE' ); + + my $table_index; + for my $index ( 1 .. $#tokens ) { + my $token = uc( $tokens[$index] ); + + if ( $token eq 'TABLE' ) { + $table_index = $index; + last; + } + + return ( 0, 0, 0 ) + if ( $token !~ /^(?:GLOBAL|LOCAL|TEMP|TEMPORARY|UNLOGGED)$/ ); + } + + return ( 0, 0, 0 ) if ( !defined $table_index ); + + my $has_opening_parenthesis = 0; + + for my $index ( $table_index + 1 .. $#tokens ) { + my $token = uc( $tokens[$index] ); + + return ( 0, 0, 0 ) if ( $token =~ /^(?:AS|OF|PARTITION)$/ ); + + if ( $tokens[$index] eq '(' ) { + $has_opening_parenthesis = 1; + last; + } + } + + return ( + 1, + $has_opening_parenthesis, + $has_opening_parenthesis ? $self->_parenthesis_delta($line) : 0, + ); +} + +=head2 _align_create_table_body + +Align eligible top-level lines from one already formatted CREATE TABLE body. + +Only lines that begin and end at the table body's direct parenthesis depth are +sent to the column-group aligner. This excludes continuation lines belonging +to multiline CHECK expressions or other nested constructs while still allowing +single-line type modifiers and function calls. + +=cut + +sub _align_create_table_body { + my ( $self, $body ) = @_; + + return [] if ( !$body || !@{$body} ); + + my @result = map { $_->{'line'} } @{$body}; + my @candidate_indices; + my @candidate_lines; + + for my $index ( 0 .. $#{$body} ) { + my $entry = $body->[$index]; + next + if ( $entry->{'depth_before'} != 1 || $entry->{'depth_after'} != 1 ); + + push @candidate_indices, $index; + push @candidate_lines, $entry->{'line'}; + } + + return \@result if ( !@candidate_lines ); + + my $aligned = + $self->_align_create_table_column_group( \@candidate_lines ); + + for my $index ( 0 .. $#candidate_indices ) { + $result[ $candidate_indices[$index] ] = $aligned->[$index]; + } + + return \@result; +} + +=head2 _align_create_table_columns + +Apply vertical column alignment to regular CREATE TABLE statements in a fully +formatted SQL document. + +The method is a final text-only rendering pass. It preserves all lines outside +supported table bodies and delegates actual row formatting to the smaller +parser, renderer, and group-alignment helpers. + +=cut + +sub _align_create_table_columns { + my ( $self, $content ) = @_; + + return $content if ( !defined $content || $content eq '' ); + + my $newline = $content =~ /\r\n/ ? "\r\n" : "\n"; + my @lines = split( /\Q$newline\E/, $content, -1 ); + my @output; + my @body; + my $depth = 0; + my $inside_table = 0; + my $pending_table = 0; + + for my $line (@lines) { + if ($inside_table) { + my $depth_before = $depth; + my $depth_after = $depth + $self->_parenthesis_delta($line); + + if ( $depth_after <= 0 ) { + my $aligned_body = $self->_align_create_table_body( \@body ); + push @output, @{$aligned_body}, $line; + + @body = (); + $depth = 0; + $inside_table = 0; + next; + } + + push @body, + { + line => $line, + depth_before => $depth_before, + depth_after => $depth_after, + }; + $depth = $depth_after; + next; + } + + if ($pending_table) { + my @tokens = $self->tokenize_sql($line); + my $first_token = @tokens ? $tokens[0] : ''; + + push @output, $line; + + if ( $first_token eq '(' ) { + $depth = $self->_parenthesis_delta($line); + $inside_table = $depth > 0 ? 1 : 0; + $pending_table = 0; + } + elsif ( + $line =~ /\S/ + && !( @tokens && $self->_is_comment( $tokens[0] ) ) + ) + { + $pending_table = 0; + } + + next; + } + + my ( $is_create_table, $has_opening_parenthesis, $opening_depth ) = + $self->_create_table_start($line); + + push @output, $line; + next if ( !$is_create_table ); + + if ($has_opening_parenthesis) { + $depth = $opening_depth; + $inside_table = $depth > 0 ? 1 : 0; + } + else { + $pending_table = 1; + } + } + + # Incomplete input is still valid formatter input. Preserve any collected + # lines instead of dropping them when the closing parenthesis is missing. + if (@body) { + push @output, map { $_->{'line'} } @body; + } + + return join( $newline, @output ); +} + +=head2 _align_create_table_column_comments + +Append trailing comments at a shared output column. + +The SQL portion of every supported column is measured after name, declaration, +and DEFAULT alignment. Only rows containing comments receive padding, so rows +without comments do not gain trailing whitespace. Unsupported lines are ignored +when calculating the comment column. + +=cut + +sub _align_create_table_column_comments { + my ( $self, $rows ) = @_; + + return if ( !$rows || !@{$rows} ); + + my $sql_width = 0; + my $has_comment = 0; + + for my $row ( @{$rows} ) { + next if ( exists $row->{'original'} ); + + my $current_width = length( $row->{'rendered'} ); + $sql_width = $current_width if ( $current_width > $sql_width ); + $has_comment = 1 if ( length( $row->{'comment'} ) ); + } + + return if ( !$has_comment ); + + for my $row ( @{$rows} ) { + next if ( exists $row->{'original'} || !length( $row->{'comment'} ) ); + + $row->{'rendered'} = + $self->_pad_right( $row->{'rendered'}, $sql_width ) + . ' ' + . $row->{'comment'}; + } + + return; +} + +=head2 _align_create_table_column_group + +Align a group of single-line CREATE TABLE column definitions. + +The method aligns the column names and the beginning of each remainder, while +preserving indentation, token order, trailing commas, and unsupported lines. +It only returns rendered lines; it does not modify the formatter output. + +=cut + +sub _align_create_table_column_group { + my ( $self, $lines ) = @_; + + return [] if ( !$lines || !@{$lines} ); + + my @rows; + my $name_width = 0; + my $declaration_width = 0; + my $alignable_count = 0; + + for my $line ( @{$lines} ) { + my $column = $self->_parse_create_table_column($line); + + if ( !$column ) { + push @rows, { original => $line }; + next; + } + + $column->{'declaration'} = + $self->_render_sql_tokens( $column->{'declaration_tokens'} ); + $column->{'remainder'} = + $self->_render_sql_tokens( $column->{'remainder_tokens'} ); + + my $current_name_width = length( $column->{'name'} ); + $name_width = $current_name_width + if ( $current_name_width > $name_width ); + + my $current_declaration_width = length( $column->{'declaration'} ); + $declaration_width = $current_declaration_width + if ( $current_declaration_width > $declaration_width ); + + push @rows, $column; + $alignable_count++; + } + + # A single definition has nothing to align with. Return a new array reference + # so callers can safely use the result without mutating their input array. + return [ @{$lines} ] if ( $alignable_count < 2 ); + + $self->_align_create_table_column_keyword( \@rows, 'DEFAULT' ); + + for my $row (@rows) { + next if ( exists $row->{'original'} ); + + my $line = + $row->{'indent'} + . $self->_pad_right( $row->{'name'}, $name_width ) + . ' ' + . $row->{'declaration'}; + + if ( length( $row->{'remainder'} ) ) { + $line .= + ( ' ' x + ( $declaration_width - length( $row->{'declaration'} ) ) ) + . ' ' + . $row->{'remainder'}; + } + + $row->{'rendered'} = $line . $row->{'comma'}; + } + + $self->_align_create_table_column_comments( \@rows ); + + my @aligned_lines = map { + exists $_->{'original'} ? $_->{'original'} : $_->{'rendered'} + } @rows; + + return \@aligned_lines; +} + sub _pop_level { my ( $self, $token, $last_token ) = @_; @@ -4918,6 +5565,7 @@ sub set_defaults { $self->{'keep_newline'} = 0; $self->{'no_space_function'} = 0; $self->{'redundant_parenthesis'} = 0; + $self->{'vertical_align'} = 0; return; } diff --git a/lib/pgFormatter/CGI.pm b/lib/pgFormatter/CGI.pm index 271de53..131b812 100755 --- a/lib/pgFormatter/CGI.pm +++ b/lib/pgFormatter/CGI.pm @@ -196,6 +196,7 @@ sub set_config { $self->{'extra_keyword'} //= ''; $self->{'no_space_function'} //= 0; $self->{'redundant_parenthesis'} //= 0; + $self->{'vertical_align'} //= 0; # Backward compatibility $self->{'extra_keyword'} = 'redshift' @@ -262,7 +263,7 @@ sub get_params { my $cgi = $self->{'cgi'}; for my $param_name ( - qw( colorize spaces uc_keyword uc_function uc_type content nocomment nogrouping show_example anonymize separator comma comma_break format_type wrap_after original_content numbering redshift keep_newline no_space_function redundant_parenthesis) + qw( colorize spaces uc_keyword uc_function uc_type content nocomment nogrouping show_example anonymize separator comma comma_break format_type wrap_after original_content numbering redshift keep_newline no_space_function redundant_parenthesis vertical_align) ) { $self->{$param_name} = $cgi->param($param_name) @@ -323,7 +324,7 @@ sub get_api_params { my $postdata = $cgi->param('POSTDATA'); my $json_params = decode_json($postdata); for my $param_name ( - qw( colorize spaces uc_keyword uc_function uc_type content nocomment nogrouping show_example anonymize separator comma comma_break format_type wrap_after original_content numbering redshift keep_newline no_space_function redundant_parenthesis) + qw( colorize spaces uc_keyword uc_function uc_type content nocomment nogrouping show_example anonymize separator comma comma_break format_type wrap_after original_content numbering redshift keep_newline no_space_function redundant_parenthesis vertical_align) ) { $self->{$param_name} = $json_params->{$param_name} @@ -364,6 +365,8 @@ sub sanitize_params { if ( $self->{'no_space_function'} !~ /^(0|1)$/ ); $self->{'redundant_parenthesis'} = 0 if ( $self->{'redundant_parenthesis'} !~ /^(0|1)$/ ); + $self->{'vertical_align'} = 0 + if ( $self->{'vertical_align'} !~ /^(0|1)$/ ); if ( $self->{'show_example'} ) { $self->{'content'} = q{ @@ -412,6 +415,7 @@ sub beautify_query { $args{'keep_newline'} = 1 if $self->{'keep_newline'}; $args{'no_space_function'} = 1 if $self->{'no_space_function'}; $args{'redundant_parenthesis'} = 1 if $self->{'redundant_parenthesis'}; + $args{'vertical_align'} = 1 if $self->{'vertical_align'}; $self->{'content'} = &remove_extra_parenthesis( $self->{'content'} ) if ( !$self->{'redundant_parenthesis'} && $self->{'content'} ); diff --git a/lib/pgFormatter/CLI.pm b/lib/pgFormatter/CLI.pm index f3c8ae1..7a15baa 100755 --- a/lib/pgFormatter/CLI.pm +++ b/lib/pgFormatter/CLI.pm @@ -130,6 +130,7 @@ sub beautify { $args{'extra_keyword'} = $self->{'cfg'}->{'extra-keyword'}; $args{'no_space_function'} = $self->{'cfg'}->{'no-space-function'}; $args{'redundant_parenthesis'} = $self->{'cfg'}->{'redundant-parenthesis'}; + $args{'vertical_align'} = $self->{'cfg'}->{'vertical-align'}; # Backward compatibility $args{'extra_keyword'} = 'redshift' @@ -323,6 +324,8 @@ Options: --no-space-function : remove space between function call and the open parenthesis. --redundant-parenthesis: do not remove redundant parenthesis in DML. + --vertical-align : vertically align CREATE TABLE column definitions and + trailing comments. Examples: @@ -390,7 +393,7 @@ sub get_command_line_args { 'wrap-limit|w=i', 'wrap-after|W=i', 'inplace|i!', 'extra-function=s', 'extra-keyword=s', 'no-space-function!', - 'redundant-parenthesis!', + 'redundant-parenthesis!', 'vertical-align!', ); $self->show_help_and_die(1) unless GetOptions( \%cfg, @options ); @@ -460,7 +463,8 @@ sub get_command_line_args { $cfg{'redshift'} //= 0; $cfg{'no-extra-line'} //= 0; $cfg{'inplace'} //= 0; - $cfg{'extra-keyword'} //= ''; + $cfg{'extra-keyword'} //= ''; + $cfg{'vertical-align'} //= 0; $cfg{'extra-keyword'} = 'redshift' if ( $cfg{'redshift'} ); if ( $cfg{'tabs'} ) { diff --git a/t/02_regress.t b/t/02_regress.t index 22da5f6..f1b742a 100755 --- a/t/02_regress.t +++ b/t/02_regress.t @@ -1,4 +1,4 @@ -use Test::Simple tests => 82; +use Test::Simple tests => 83; use File::Temp qw/ tempfile /; my $pg_format = $ENV{PG_FORMAT} // './pg_format'; # set to the full path to 'pg_format' to test installed binary in /usr/bin @@ -28,6 +28,7 @@ foreach my $f (@files) $opt = "--keyword-case 2 --function-case 1 --comma-start --wrap-after 1 --wrap-limit 40 --tabs --spaces 4 " if ($f =~ m#/ex58.sql$#); $opt = "--no-space-function" if ($f =~ m#/ex70.sql$#); $opt = "--keyword-case 1 --type-case 1" if ($f =~ m#/ex71.sql$#); + $opt = "--vertical-align --no-extra-line" if ($f =~ m#/ex81\.sql$#); if ($f =~ m#/ex61.sql$#) { my ($fh, $tmpfile) = tempfile('tmp_pgformatXXXX', SUFFIX => '.lst', TMPDIR => 1, O_TEMPORARY => 1, UNLINK => 1 ); diff --git a/t/03_vertical_align.t b/t/03_vertical_align.t new file mode 100644 index 0000000..8f09b42 --- /dev/null +++ b/t/03_vertical_align.t @@ -0,0 +1,85 @@ +use strict; +use warnings; + +use Test::More; +use lib 'lib'; +use pgFormatter::Beautify; + +sub format_sql { + my ($query) = @_; + my $formatter = pgFormatter::Beautify->new( + query => $query, + vertical_align => 1, + uc_keywords => 1, + uc_types => 1, + uc_functions => 1, + no_extra_line => 1, + ); + + $formatter->beautify(); + return $formatter->content(); +} + +my $input = <<'SQL'; +create table example ( + id uuid primary key default gen_random_uuid(), + name varchar(100) not null, -- a comment + owner_id uuid references app_user (id), + created_at timestamptz not null default now(), + constraint example_name_unique unique (name) +); +SQL + +my $expected = <<'SQL'; +create table example ( + id uuid primary key default gen_random_uuid (), + name varchar(100) not null, -- a comment + owner_id uuid references app_user (id), + created_at timestamptz not null default now(), + constraint example_name_unique unique (name) +); +SQL + +is( + format_sql($input), + $expected, + 'aligns columns, defaults, references, and comments' +); + +my $unsupported = <<'SQL'; +CREATE TABLE measurements ( + id uuid primary key, + score integer check ( + score >= 0 + and score <= 100 + ), + descriptive_name text +); + +CREATE TABLE copied AS +SELECT id FROM measurements; +SQL + +my $beautifier = pgFormatter::Beautify->new(); +my $result = $beautifier->_align_create_table_columns($unsupported); + +like( + $result, + qr/score integer check \(\n score >= 0\n and score <= 100\n \),/, + 'preserves multiline definitions' +); + +like( + $result, + qr/CREATE TABLE copied AS\nSELECT id FROM measurements;/, + 'skips CREATE TABLE AS' +); + +my $aligned = format_sql($input); +is( + $beautifier->_align_create_table_columns($aligned), + $aligned, + 'is idempotent' +); + +done_testing(); diff --git a/t/regress_test.pl b/t/regress_test.pl index 407d8cd..34a61d6 100644 --- a/t/regress_test.pl +++ b/t/regress_test.pl @@ -27,6 +27,7 @@ $opt = "--keyword-case 2 --function-case 1 --comma-start --wrap-after 1 --wrap-limit 40 --tabs --spaces 4 " if ($f =~ m#/ex58.sql$#); $opt = "--no-space-function" if ($f =~ m#/ex70.sql$#); $opt = "--keyword-case 1 --type-case 1" if ($f =~ m#/ex71.sql$#); + $opt = "--vertical-align --no-extra-line" if ( $f =~ m#/ex81\.sql$# ); if ($f =~ m#/ex61.sql$#) { my ($fh, $tmpfile) = tempfile('tmp_pgformatXXXX', SUFFIX => '.lst', TMPDIR => 1, O_TEMPORARY => 1, UNLINK => 1 ); diff --git a/t/test-files/ex81.sql b/t/test-files/ex81.sql new file mode 100644 index 0000000..58437b4 --- /dev/null +++ b/t/test-files/ex81.sql @@ -0,0 +1,6 @@ +CREATE TABLE example ( + id serial PRIMARY KEY, -- some comment + name varchar(100) NOT NULL, -- another comment + email varchar(100) NOT NULL, -- third comment + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP -- last comment +); diff --git a/t/test-files/expected/ex81.sql b/t/test-files/expected/ex81.sql new file mode 100644 index 0000000..82d4e88 --- /dev/null +++ b/t/test-files/expected/ex81.sql @@ -0,0 +1,6 @@ +CREATE TABLE example ( + id serial PRIMARY KEY, -- some comment + name varchar(100) NOT NULL, -- another comment + email varchar(100) NOT NULL, -- third comment + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP -- last comment +);