File Coverage

blib/lib/DBIx/PivotQuery.pm
Criterion Covered Total %
statement 128 154 83.1
branch 38 58 65.5
condition 14 19 73.6
subroutine 12 14 85.7
pod 3 5 60.0
total 195 250 78.0


line stmt bran cond sub pod time code
1             package DBIx::PivotQuery;
2 4     4   63219 use strict;
  4         21  
  4         108  
3 4     4   1497 use Filter::signatures;
  4         75203  
  4         125  
4 4     4   23 use feature 'signatures';
  4         10  
  4         751  
5 4     4   710 no warnings 'experimental::signatures';
  4         4  
  4         103  
6              
7 4     4   11 use Exporter 'import';
  4         6  
  4         88  
8 4     4   689 use Carp 'croak';
  4         2  
  4         143  
9 4     4   12 use vars '$VERSION';
  4         3  
  4         146  
10             $VERSION = '0.01';
11              
12 4     4   12 use vars qw(@EXPORT_OK);
  4         7  
  4         4791  
13             @EXPORT_OK = qw(pivot_by pivot_list pivot_sql);
14              
15             =head1 NAME
16              
17             DBIx::PivotQuery - create pivot tables from queries
18              
19             =head1 SYNOPSIS
20              
21             use DBIx::PivotQuery 'pivot_by';
22             my $rows = pivot_by(
23             dbh => $dbh,
24             columns => ['month'],
25             rows => ['region'],
26             aggregate => ['sum(amount) as amount'],
27             sql => <<'SQL');
28             select
29             month(date) as report_month
30             , region
31             , amount
32             from mytable
33             SQL
34              
35             The above code returns a data structure roughly like
36              
37             # [
38             # ['region','1','2',...,'11','12'],
39             # ['East', 0, 0 ,..., 10, 20 ],
40             # ['North', 0, 1 ,..., 10, 20 ],
41             # ['South', 0, 3 ,..., 10, 5 ],
42             # ['West', 0, 6 ,..., 8, 20 ],
43             # ]
44              
45             =head1 FUNCTIONS
46              
47             # This should maybe return a duck-type statement handle so that people
48             # can fetch row-by-row to their hearts content
49             # row-by-row still means we need to know all values for the column key :-/
50              
51             =head2 C<< pivot_by >>
52              
53             my $l = pivot_by(
54             dbh => $test_dbh,
55             rows => ['region'],
56             columns => ['date'],
57             aggregate => ['sum(amount) as amount'],
58             placeholder_values => [],
59             subtotals => 1,
60             sql => <<'SQL',
61             select
62             region
63             , "date"
64             , amount
65             , customer
66             from mytable
67             SQL
68             );
69              
70             Transforms the SQL given and returns an AoA pivot table according to
71             C, C and C.
72              
73             The last word (\w+) of each element of C will be used as the
74             aggregate column name unless C is given.
75              
76             Supplying C for a column name in C will create an empty cell
77             in that place. This is convenient when creating subtotals.
78              
79             =head3 Options
80              
81             =over 4
82              
83             =item B
84              
85             headers => 1,
86              
87             Whether to include the headers as the first row
88              
89             =back
90              
91             Subtotals are calculated by repeatedly running the query. For optimization, you
92             could first select the relevant (aggregated)
93             rows into a temporary table and then create the subtotals from that temporary
94             table if query performance is an issue:
95              
96             select foo, sum(bar) as bar, baz
97             into #tmp_query
98             from mytable
99             where year = ?
100              
101             select foo, bar, baz from #tmp_query
102              
103             =cut
104              
105 5 50   5 1 10165 sub pivot_by( %options ) {
  5         25  
  5         5  
106             croak "Need an SQL string in option 'sql'"
107 5 50       11 unless $options{sql};
108             croak "Need a database handle in option 'dbh'"
109 5 50       14 unless $options{dbh};
110 5   50     9 $options{ placeholder_values } ||= [];
111 5   50     10 $options{ rows } ||= [];
112              
113 5 100 66     16 if( $options{ subtotals } and ! ref $options{ subtotals }) {
114 1         1 $options{ subtotals } = [@{ $options{rows}}];
  1         2  
115             };
116              
117 5         8 my $subtotals = delete $options{ subtotals };
118              
119 5         17 my $result = simple_pivot_by( %options );
120              
121 5 100       15 if( $subtotals ) {
122 1         4 for my $i ( reverse 0..$#$subtotals ) {
123 2         2 $subtotals->[$i] = undef;
124 2         7 my $s = simple_pivot_by(
125             %options,
126             rows => $subtotals,
127             headers => 0
128             );
129              
130             # Now splice our subtotals into the list
131             # Wherever the subtotals key changes, insert the subtotal
132 2 50       6 my $p = $options{ headers } ? 1 : 0;
133 2         3 my $last;
134 2   66     8 while( @$s and $p < @$result ) {
135 14         11 my $curr = join "\0", @{ $result->[$p] }[0..$i-1];
  14         13  
136 14   100     25 $last ||= $curr;
137 14 100       20 if( $last ne $curr ) {
138 3         3 splice @$result, $p, 0, shift @$s;
139 3         3 $p++;
140 3         4 $last = join "\0", @{ $result->[$p] }[0..$i-1];
  3         3  
141             };
142 14         34 $p++;
143             };
144              
145             # Whatever remains will just be appended
146 2         4 push @$result, @$s;
147             };
148             };
149              
150 5         12 $result;
151             }
152              
153 7 50   7 0 15 sub simple_pivot_by( %options ) {
  7         18  
  7         5  
154 7         18 my $sql = pivot_sql( %options );
155 7         36 my $sth = $options{ dbh }->prepare( $sql );
156 7         497 $sth->execute( @{$options{ placeholder_values }} );
  7         188  
157 7         66 my $rows = $sth->fetchall_arrayref({});
158 7         767 my @aggregate_columns;
159 7 50       15 if( exists $options{ aggregate_columns }) {
160 0         0 @aggregate_columns = @{ $options{ aggregate_columns }};
  0         0  
161             } else {
162 7 50       8 @aggregate_columns = map {/(\w+)\w*$/ ? $1 : $_ } @{ $options{ aggregate }};
  7         68  
  7         13  
163             };
164 7         26 pivot_list( %options, aggregate => \@aggregate_columns, list => $rows );
165             }
166              
167             # Takes an AoA and derives the total order from it if possible
168             # Returns the total order of the keys. Not every key is expected to be available
169             # in every row
170 0 0   0 0 0 sub partial_order( $comparator, $keygen, @list ) {
  0         0  
  0         0  
  0         0  
  0         0  
171 0         0 my %sort;
172             my %keys;
173              
174 0         0 for my $row (@list) {
175 0         0 my $last_key;
176 0         0 for my $col (@$row) {
177             # This approach doesn't have the transitive property
178             # We need to place items in arrays resp. on a float lattice
179             # $sort{ $item } = (max( $sort_after($item ) - min( $sort_before($item)) / 2
180 0         0 my $key = $keygen->( $col );
181 0         0 $keys{ $key } = 1;
182 0 0       0 if( defined $last_key ) {
183 0         0 for my $cmp (["$last_key\0$key",-1],
184             ["$key\0$last_key",1],
185             ) {
186 0         0 my ($k,$v) = @$cmp;
187 0         0 $sort{$k} = $v;
188             }
189             } else {
190 0         0 $last_key = $key;
191             };
192             }
193             }
194              
195 0         0 sort { $sort{ $a } <=> $sort{$b} } keys %keys;
  0         0  
196             }
197              
198             # Pivots an AoH (no AoA support yet!?)
199             # The list must already be sorted by @rows, @columns
200             # At least one line must contain all column values (!)
201              
202             =head2 C<< pivot_list >>
203              
204             my $l = pivot_list(
205             list => @AoH,
206             columns => ['date'],
207             rows => ['region'],
208             aggregate => ['amount'],
209             );
210              
211             The rows of C<@$l> are then plain arrays not hashes.
212             The first row of C<@$l> will contain the column titles.
213              
214             The column titles are built from joining the pivot column values by C<$;> .
215              
216             =over 4
217              
218             =item B
219              
220             headers => 1,
221              
222             Whether to include the headers as the first row
223              
224             =back
225              
226             =cut
227              
228 10 50   10 1 1802 sub pivot_list( %options ) {
  10         31  
  10         10  
229 10         10 my @rows;
230             my %colnum;
231 0         0 my %rownum;
232              
233 10 100       21 if( ! exists $options{ headers }) {
234 6         22 $options{ headers } = 1;
235             };
236              
237 10 100       8 my @key_cols = @{ $options{ columns } || [] };
  10         35  
238 10 100       12 my @key_rows = @{ $options{ rows } || [] };
  10         27  
239 10 50       8 my @aggregates = @{ $options{ aggregate } || [] };
  10         26  
240 10         7 my @colhead;
241              
242             # Now we need to determine the numbers for all the columns
243 10 50       17 if( $options{ sort_columns } ) {
244             # If we have a user-supplied sorting function, use that:
245 0     0   0 @colnum{ sort( sub { $options{ sort_columns }->($a,$b) }, keys %colnum )}
  0         0  
246             = (@key_rows)..((@key_rows)+(keys %colnum)-1);
247 0         0 for( keys %colnum ) {
248 0         0 $colhead[ $colnum{ $_ }] = $_;
249             };
250             } else {
251             # We assume that the first row contains all columns in order.
252             # Following lines may skip values or have additional columns which
253             # will be appended. This could be smarter by introducing a partial
254             # order in the hope that everything will work out in the end.
255 10         12 my $col = @key_rows;
256 10         8 for my $cell (@{ $options{ list }}) {
  10         19  
257 147         84 my $colkey = join $;, @{ $cell }{ @key_cols };
  147         129  
258 147 100       185 if( ! exists $colnum{ $colkey }) {
259 48   66     142 $colnum{ $colkey } ||= $col++;
260 48         53 push @colhead, $colkey;
261             };
262             };
263             }
264              
265 10         11 my @effective_key_rows = grep { defined $_ } @key_rows; # remove placeholders
  15         26  
266              
267 10 50       18 if( ! @colhead) {
268 0         0 @colhead = $aggregates[0];
269             };
270              
271 10         11 my $last_row;
272             my @row;
273 10         10 for my $cell (@{ $options{ list }}) {
  10         9  
274 147         96 my $colkey = join $;, @{ $cell }{ @key_cols };
  147         133  
275 147         79 my $rowkey = join $;, @{ $cell }{ @effective_key_rows };
  147         134  
276              
277 147 100 100     373 if( defined $last_row and $rowkey ne $last_row ) {
278 37         53 push @rows, [splice @row, 0];
279             };
280              
281             # We should have %row instead, but how to name the
282             # columns and rows that are values now?!
283             # prefix "pivot_" ?
284             # Allow the user to supply names?
285             # Expect the user to rename the keys?
286 147 100       183 if( ! @row ) {
287 47 100       36 @row = map { defined $_ ? $cell->{$_} : undef } @key_rows;
  80         137  
288             };
289              
290 147         277 my %cellv = %$cell;
291 147         108 @cellv{ @aggregates } = @{$cell}{@aggregates};
  147         126  
292             #$row[ $colnum{ $colkey }] = \%cellv;
293 147         148 $row[ $colnum{ $colkey }] = $cell->{ $aggregates[0] };
294 147         156 $last_row = $rowkey;
295             };
296 10 50       18 if(@row) {
297 10         11 push @rows, \@row;
298             };
299              
300             unshift @rows, [ @key_rows, @colhead ]
301 10 100       30 if $options{ headers };
302              
303             \@rows
304 10         128 }
305              
306             =head2 C<< pivot_sql >>
307              
308             pivot_sql(
309             columns => ['date'],
310             rows => ['region'],
311             aggregate => ['sum(amount) as amount'],
312             sql => <<'SQL' );
313             select
314             "date"
315             , region
316             , amount
317             from mytable
318             SQL
319              
320             Creates SQL around a subselect that aggregates the given
321             columns.
322              
323             The SQL created by the call above would be
324              
325             select "region"
326             , "date"
327             , sum(amount) as amount
328             from (
329             select
330             "date"
331             , region
332             , amount
333             from mytable
334             ) foo
335             group by "region, "date"
336             order by "region", "date"
337              
338             Note that the values in the C and C options will be automatically
339             enclosed in double quotes.
340              
341             This function is convenient if you want to ccreate ad-hoc pivot queries instead
342             of setting up the appropriate views in the database.
343              
344             If you want to produce subtotals, this function can be called
345             with the elements removed successively from C<$options{rows}> or
346             C<$options{columns}> for computing row or column totals.
347              
348             =cut
349              
350 8 50   8 1 25 sub pivot_sql( %options ) {
  8         18  
  8         8  
351 8 50       9 my @columns = (grep { defined $_ } @{ $options{ rows } || [] }, @{ $options{ columns } || []});
  21 50       34  
  8         20  
  8         20  
352 8         15 my $qcolumns = join "\n , ", @columns, @{ $options{ aggregate }};
  8         20  
353 8         13 my $keycolumns = join "\n , ", @columns;
354 8         7 my $clauses = '';
355 8 50       15 if($keycolumns) {
356 8         18 $clauses = join "\n",
357             "group by $keycolumns",
358             "order by $keycolumns",
359             };
360              
361             return <
362             select
363             $qcolumns
364             from (
365             $options{sql}
366             ) foo
367             $clauses
368             SQL
369 8         35 }
370             1;
371              
372             =head1 Unsupported features
373              
374             Currently only one aggregate value is allowed.
375              
376             Row aggregates ("totals") are not supported yet. Row aggregates will
377             mean heavy rewriting of the SQL to wrap the aggregate function over the column
378             names of the query.
379              
380             =head1 SEE ALSO
381              
382             L
383              
384             =head1 REPOSITORY
385              
386             The public repository of this module is
387             L.
388              
389             =head1 SUPPORT
390              
391             The public support forum of this module is
392             L.
393              
394             =head1 BUG TRACKER
395              
396             Please report bugs in this module via the RT CPAN bug queue at
397             L
398             or via mail to L.
399              
400             =head1 AUTHOR
401              
402             Max Maischein C
403              
404             =head1 COPYRIGHT (c)
405              
406             Copyright 2017 by Max Maischein C.
407              
408             =head1 LICENSE
409              
410             This module is released under the same terms as Perl itself.
411              
412             =cut