File Coverage

blib/lib/Games/Go/AGA/TDListDB.pm
Criterion Covered Total %
statement 242 323 74.9
branch 71 134 52.9
condition 5 15 33.3
subroutine 43 51 84.3
pod 24 32 75.0
total 385 555 69.3


line stmt bran cond sub pod time code
1             #===============================================================================
2             # FILE: Games::Go::AGA::TDListDB
3             # ABSTRACT: an SQL object for holding AGA TDList data
4             # AUTHOR: Reid Augustin (REID),
5             # CREATED: 12/02/2010 08:51:22 AM PST
6             #===============================================================================
7              
8 1     1   26269 use 5.010;
  1         3  
9 1     1   5 use strict;
  1         1  
  1         25  
10 1     1   14 use warnings;
  1         1  
  1         56  
11              
12             package Games::Go::AGA::TDListDB;
13              
14 1     1   537 use open qw( :utf8 :std ); # UTF8 for all files and STDIO
  1         1022  
  1         5  
15 1     1   646 use IO::Handle; # for autoflush
  1         5520  
  1         43  
16 1     1   5 use DBI;
  1         1  
  1         35  
17 1     1   599 use Readonly;
  1         3249  
  1         52  
18 1     1   6 use Try::Tiny;
  1         2  
  1         52  
19 1     1   6 use POSIX ":sys_wait_h";
  1         1  
  1         8  
20 1     1   873 use LWP::UserAgent;
  1         37093  
  1         35  
21 1     1   473 use LWP::Protocol::https;
  1         71000  
  1         44  
22 1     1   584 use Games::Go::AGA::Parse::TDList;
  1         16589  
  1         42  
23 1     1   6 use Games::Go::AGA::Parse::Util qw( is_Rating );
  1         1  
  1         51  
24 1     1   4 use Readonly;
  1         1  
  1         3601  
25              
26             our $VERSION = '0.048'; # VERSION
27              
28             Readonly my $BUF_MAX => 4096;
29              
30             # list the names of the default database field index subroutines, in order
31             my @_column_names = (qw(
32             last_name
33             first_name
34             id
35             membership
36             rank
37             date
38             club
39             state
40             extra
41             ));
42             my %_column_idx = (
43             last_name => 0,
44             first_name => 1,
45             id => 2,
46             membership => 3,
47             rank => 4,
48             date => 5,
49             club => 6,
50             state => 7,
51             extra => 8,
52             );
53              
54             # list of default column names
55             sub column_idx {
56 52     52 1 129 my ($self, $which) = @_;
57              
58 52 50       156 if (@_ >= 2) {
59 52         454 return $_column_idx{lc $which};
60             }
61             return wantarray
62             ? @_column_names
63 0 0       0 : \@_column_names;
64             }
65              
66             Readonly my @columns => (
67             {last_name => 'VARCHAR(256)', },
68             {first_name => 'VARCHAR(256)', },
69             {id => 'VARCHAR(256) NOT NULL PRIMARY KEY', },
70             {membership => 'VARCHAR(256)', },
71             {rank => 'VARCHAR(256)', },
72             {date => 'VARCHAR(256)', },
73             {club => 'VARCHAR(256)', },
74             {state => 'VARCHAR(256)', },
75             {extra => 'VARCHAR(256)', },
76             );
77              
78             my %pre_defaults = (
79             url => 'https://www.usgo.org/ratings/TDListN.txt',
80             dbdname => 'tdlistdb.sqlite',
81             table_name => 'tdlist',
82             extra_columns => [],
83             extra_columns_callback => sub { return () },
84             max_update_errors => 10,
85             raw_filename => 'TDList.txt',
86             verbose => 0,
87             );
88              
89             __PACKAGE__->run( @ARGV ) if not caller(); # modulino
90              
91             sub new {
92 1     1 1 487 my ($class, %args) = @_;
93              
94 1         2 my $self = {};
95 1   33     9 bless $self, (ref $class || $class);
96              
97 1         5 while (my ($key, $value) = each %pre_defaults) {
98 8 100       17 $value = delete $args{$key} if (exists $args{$key});
99 8         15 $self->$key($value);
100             }
101              
102 1         3 my $db = $self->db(delete $args{db});
103              
104 1         13 for my $key (keys %args) { # any leftovers?
105 0         0 $self->$key($args{$key});
106             }
107              
108             # SQL for finding players by name
109 1         11 $self->sth('select_by_name',
110             $db->prepare(
111             join('',
112             'SELECT * FROM ',
113             $self->table_name,
114             ' WHERE last_name = ?',
115             ' AND first_name = ?',
116             ),
117             ),
118             );
119              
120             # and a statement for inserting new players
121 1         9 $self->sth('insert_player',
122             $db->prepare(
123             join('',
124             'INSERT INTO ',
125             $self->table_name,
126             ' ( ',
127             $self->sql_columns,
128             ' ) ',
129             'VALUES ( ',
130             $self->sql_insert_qs,
131             ' )',
132             ),
133             ),
134             );
135              
136             # SQL for updating when player is already in DB
137 1         15 $self->sth('update_id',
138             $db->prepare(
139             join('',
140             'UPDATE ',
141             $self->table_name,
142             ' SET ',
143             $self->sql_update_qs,
144             ' WHERE id = ?',
145             ),
146             ),
147             );
148              
149             # SQL for finding IDs
150 1         12 $self->sth('select_id',
151             $db->prepare(
152             join('',
153             'SELECT * FROM ',
154             $self->table_name,
155             ' WHERE id = ?',
156             ),
157             ),
158             );
159              
160             # SQL for getting and setting DB update time
161 1         10 $self->sth('select_time',
162             $db->prepare(
163             join('',
164             'SELECT update_time FROM ',
165             $self->table_name_meta,
166             ' WHERE key = 1',
167             ),
168             ),
169             );
170 1         6 $self->sth('update_time',
171             $db->prepare(
172             join('',
173             'UPDATE ',
174             $self->table_name_meta,
175             ' SET update_time = ?',
176             ' WHERE key = 1',
177             ),
178             ),
179             );
180              
181             # SQL to get/set next_tmp marker
182 1         8 $self->sth('select_next_tmp',
183             $db->prepare(
184             join('',
185             'SELECT next_tmp_id FROM ',
186             $self->table_name_meta,
187             ' WHERE key = 1',
188             ),
189             ),
190             );
191 1         7 $self->sth('update_next_tmp',
192             $db->prepare(
193             join('',
194             'UPDATE ',
195             $self->table_name_meta,
196             ' SET next_tmp_id = ?',
197             ' WHERE key = 1',
198             ),
199             )
200             );
201              
202 1         9 $self->init(\%args); # in case any subclass needs initialization
203              
204             map {
205 1 0       3 if (not $self->can($_)) {
  0         0  
206 0         0 my $ref = ref $self;
207 0         0 confess("$ref can't '->$_'\n");
208             }
209 0         0 $self->$_($args{$_});
210             } keys %args;
211              
212 1         6 return $self;
213             }
214              
215             my $usage = qq(
216              
217             TDListDB [ -tdlist_file filename ] [ -sqlite_file filename ]
218             [ -url url | AGA ] [ -verbose ] [ -help ]
219              
220             Options may be abbreviated to their first letter.
221              
222             By default, TDListDB.pm updates from a file in the current
223             directory named TDList.txt. Specify -tdlist_file to update
224             from a different file, or specify -url to update from a
225             website. -url AGA updates from the usual AGA website at
226             https://www.usgo.org/ratings/TDListN.txt
227              
228             );
229              
230             sub run {
231 0     0 0 0 my ($class) = @_;
232              
233 0         0 require Getopt::Long;
234 0         0 Getopt::Long->import(qw( :config pass_through ));
235              
236 0         0 my $verbose;
237             my $url;
238             exit 0 if (not GetOptions(
239             'tdlist_file=s', => \$pre_defaults{raw_filename}, # update from file
240             'sqlite_file=s', => \$pre_defaults{dbdname}, # sqlite file
241             'url=s', => \$url, # URL to update from
242             'verbose', => \$verbose,
243 0     0   0 'help' => sub { print $usage; exit 0; },
  0         0  
244 0 0       0 ));
245              
246 0         0 my $tdlist = $class->new( verbose => $verbose );
247 0         0 my $filename = $tdlist->raw_filename;
248 0         0 my $dbfile = $tdlist->dbdname;
249 0         0 STDOUT->autoflush(1);
250              
251 0 0       0 if ($url) {
252 0 0       0 if (uc $url ne 'AGA') {
253 0         0 $tdlist->url($url);
254             }
255 0         0 $url = $tdlist->url;
256 0         0 print "Updating $dbfile from AGA ($url)\n";
257 0         0 $tdlist->update_from_AGA();
258 0         0 exit;
259             }
260 0         0 print "Updating $dbfile from file ($filename)\n";
261 0         0 $tdlist->update_from_file($filename);
262             }
263              
264             # stub for subclass to override
265             sub init {
266 1     1 0 3 my ($self) = @_;
267             }
268              
269             sub verbose {
270 5     5 0 11 my ($self, $new) = @_;
271              
272 5 100       21 if (@_ > 1) {
273 1         1 $self->{verbose} = $new;
274             }
275              
276 5         16 return $self->{verbose};
277             }
278              
279             sub raw_filename {
280 1     1 1 2 my ($self, $new) = @_;
281              
282 1 50       3 if (@_ > 1) {
283 1         2 $self->{raw_filename} = $new;
284             }
285              
286 1         2 return $self->{raw_filename};
287             }
288              
289             sub dbdname {
290 2     2 1 3 my ($self, $new) = @_;
291              
292 2 100       5 if (@_ > 1) {
293 1         2 $self->{dbdname} = $new;
294             }
295              
296 2         5 return $self->{dbdname};
297             }
298              
299             sub table_name {
300 12     12 1 22 my ($self, $new) = @_;
301              
302 12 100       51 if (@_ > 1) {
303 1         2 $self->{table_name} = $new;
304             }
305              
306 12         209 return quotemeta $self->{table_name};
307             }
308              
309             sub table_name_meta {
310 6     6 0 12 my ($self) = @_;
311              
312 6         19 return $self->table_name . '_meta';
313             }
314              
315             sub url {
316 1     1 1 2 my ($self, $new) = @_;
317              
318 1 50       3 if (@_ > 1) {
319 1         2 $self->{url} = $new;
320             }
321              
322 1         3 return $self->{url};
323             }
324              
325             sub background {
326 2     2 1 5 my ($self, $new) = @_;
327              
328 2 100       10 if (@_ > 1) {
329 1         5 $self->{background} = $new;
330             }
331              
332 2         8 return $self->{background};
333             }
334              
335             sub max_update_errors {
336 14     14 1 27 my ($self, $new) = @_;
337              
338 14 100       47 if (@_ > 1) {
339 1         5 $self->{max_update_errors} = $new;
340             }
341              
342 14         62 return $self->{max_update_errors};
343             }
344              
345             sub extra_columns_callback {
346 14     14 1 26 my ($self, @new) = @_;
347              
348 14 100       42 if (@_ > 1) {
349 1 50       12 if (ref $new[0] ne 'CODE') {
350 0         0 $self->my_print("Must set a code-ref in extra_columns_callback\n");
351 0         0 die;
352             }
353 1         2 $self->{extra_columns_callback} = $new[0];
354             }
355 14         61 return $self->{extra_columns_callback};
356             }
357              
358             sub extra_columns {
359 5     5 1 56 my ($self, @new) = @_;
360              
361 5 100       15 if (@_ > 1) {
362 1 50       4 if (ref $new[0] eq 'ARRAY') {
363 1         2 $self->{extra_columns} = $new[0];
364             }
365             else {
366 0         0 $self->{extra_columns} = \@new;
367             }
368             }
369 5 100       15 return wantarray ? @{$self->{extra_columns}} : $self->{extra_columns};
  4         20  
370             }
371              
372             sub db {
373 13     13 1 994 my ($self, $new) = @_;
374              
375 13 100       46 if (@_ > 1) {
376 1 50       3 if (not $new) {
377 1 50       12 if (my $fname = $self->dbdname) {
378 1         12 $new = DBI->connect( # connect to your database, create if needed
379             "dbi:SQLite:dbname=$fname", # DSN: dbi, driver, database file
380             "", # no user
381             "", # no password
382             {
383             AutoCommit => 1,
384             RaiseError => 1, # complain if something goes wrong
385             },
386             )
387             }
388             else {
389 0         0 $self->my_print("No dbdname for SQLite file\n");
390 0         0 die;
391             }
392             }
393 1         1553 $self->{db} = $new;
394 1         3 $self->_db_schema(); # make sure table exists
395             }
396              
397 13         16635 return $self->{db};
398             }
399              
400             # library of statement handles
401             sub sth {
402 104     104 1 1138 my ($self, $name, $new) = @_;
403              
404 104 50       280 if (not $name) {
405 0         0 $self->my_print("Statement handle name is required\n");
406 0         0 die;
407             }
408 104 100       288 if (@_ > 2) {
409 8         32 $self->{sth}{$name} = $new;
410             }
411              
412 104         251 my $sth = $self->{sth}{$name};
413 104 50       258 if (not $sth) {
414 0         0 $self->my_print("No statement handle called '$name'\n");
415 0         0 die;
416             }
417              
418 104         113663 return $sth;
419             }
420              
421             sub _db_schema {
422 1     1   2 my ($self) = @_;
423              
424 1         6 $self->db->do(
425             join('',
426             'CREATE TABLE IF NOT EXISTS ',
427             $self->table_name,
428             ' (',
429             $self->sql_column_types,
430             ' )',
431             ),
432             );
433              
434 1         171762 $self->db->do(join '',
435             'CREATE TABLE IF NOT EXISTS ',
436             $self->table_name_meta,
437             ' (',
438             'key INTEGER PRIMARY KEY, ',
439             'update_time VARCHAR(12), ',
440             'next_tmp_id VARCHAR(12)',
441             ' )',
442             );
443              
444 1         12156 $self->db->do(join '',
445             'INSERT OR IGNORE INTO ',
446             $self->table_name_meta,
447             ' (',
448             'key, ',
449             'update_time, ',
450             'next_tmp_id',
451             ' ) VALUES ( 1, 0, 1 )',
452             );
453             }
454              
455             sub update_time {
456 6     6 1 35 my ($self, $new) = @_;
457              
458 6 100       39 if (@_ > 1) {
459 4         20 $self->sth('update_time')->execute($new);
460             }
461 6         64 $self->sth('select_time')->execute();
462 6         36 my $time = $self->sth('select_time')->fetchall_arrayref();
463 6         24 $time = $time->[0][0];
464 6   50     170 return $time || 0;
465             }
466              
467             sub select_id {
468 19     19 1 1284 my ($self, $id) = @_;
469              
470 19         56 $self->sth('select_id')->execute($id);
471             # ID is primary index, so can only be one - fetch into first array
472             # element:
473 19         96 my ($player) = $self->sth('select_id')->fetchall_arrayref;
474 19 50       81 $player->[$self->column_idx('rank')] += 0 if (is_Rating($player->[$self->column_idx('rank')])); # numify ratings
475             return wantarray
476 19 50       278 ? @{$player->[0]}
  0         0  
477             : $player->[0];
478             }
479              
480             sub insert_player {
481 6     6 0 27 my ($self, @new) = @_;
482              
483 6 50       22 $new[$self->column_idx('id')] = $self->next_tmp_id(1) if (not $new[$self->column_idx('id')]);
484 6         21 $self->sth('insert_player')->execute(@new);
485             return wantarray
486             ? @new
487 6 50       52 : \@new;
488             }
489              
490             sub next_tmp_id {
491 5     5 1 823 my ($self, $used) = @_;
492              
493 5         21 $self->sth('select_next_tmp')->execute;
494 5         26 my $next_tmp = $self->sth('select_next_tmp')->fetchall_arrayref;
495 5         23 $next_tmp = $next_tmp->[0][0];
496 5   50     36 $next_tmp ||= 1;
497 5         32 while ($self->select_id("TMP$next_tmp")) {
498 0         0 $next_tmp++
499             }
500              
501 5 50       16 if ($used) { # is the caller planning on allocating this one?
502 5         18 $self->sth('update_next_tmp')->execute($next_tmp + 1);
503             }
504 5         87 return "TMP$next_tmp";
505             }
506              
507             # reap any child zombies from earlier update_from_AGA calls
508             sub reap {
509 0     0 1 0 my $kid;
510 0         0 my $reaped = 0;
511 0         0 do {
512 0         0 $kid = waitpid(-1, WNOHANG);
513 0 0       0 $reaped++ if ($kid > 0);
514             } while $kid > 0;
515 0         0 return $reaped;
516             }
517              
518             sub update_from_AGA {
519 0     0 1 0 my ($self) = @_;
520              
521 0         0 my $pid;
522 0 0       0 if ($self->background) {
523 0         0 $pid = fork;
524 0 0       0 die "fork failed: $!\n" if not defined $pid;
525             }
526 0 0       0 if ($pid) {
527             # parent process
528 0         0 return;
529             }
530              
531 0 0       0 if (not $self->{ua}) {
532 0         0 $self->{ua} = LWP::UserAgent->new;
533             }
534              
535 0         0 my $fname = $self->raw_filename;
536 0         0 my $url = $self->url;
537 0 0       0 $self->my_print("Starting $fname fetch from $url at ", scalar localtime, "\n") if ($self->verbose);
538 0         0 $self->{ua}->mirror($url, $fname);
539 0 0       0 $self->my_print("... fetch done at ", scalar localtime, "\n") if ($self->verbose);
540 0         0 $self->update_from_file($fname);
541              
542 0 0       0 exit if (defined $pid); # exit if this is a spawned child ($pid == 0)
543             }
544              
545             sub update_from_file {
546 4     4 1 6711 my ($self, $fh) = @_;
547              
548 4 50       25 if (not ref $fh) {
549 0         0 my $fname = $fh;
550 0         0 $fh = undef;
551 0 0       0 if (not open($fh, '<', $fname)) {
552 0         0 $self->my_print("Error opening $fname for reading: $!\n");
553 0         0 die;
554             }
555             }
556 4         21 $self->fh($fh);
557              
558 4         73 my $parser = Games::Go::AGA::Parse::TDList->new();
559 4         101 my $verbose = $self->verbose;
560 4 50       14 $self->my_print("Starting database update at ", scalar localtime, "\n") if ($verbose);
561 4         19 $self->db->do('BEGIN');
562 4         211 my $error_count = 0;
563 4         7 my $ii = 0;
564 4         21 my $ID = $self->column_idx('id');
565 4         8 while (1) {
566 22         30 $ii++;
567 22         79 my $line = $self->next_line;
568 22 100       72 last if (not defined $line);
569 18 100       44 next if (not $line);
570              
571 13 50       28 if ($verbose) {
572 0 0       0 $self->my_print('.') if ($ii % 1000 == 0);
573 0 0       0 $self->my_print("\n") if ($ii % 40000 == 0);
574             }
575             try { # in case a line crashes, print error but continue
576             #$self->my_print("parse $line") if ($verbose);
577 13     13   570 $parser->parse($line);
578 13         13181 my $update = $parser->as_array;
579 13 50 33     759 if ($update->[$self->column_idx('last_name')] or $update->[$self->column_idx('first_name')]) {
580 13         22 push @{$update}, $self->extra_columns_callback->($self, $update);
  13         58  
581 13 100       169 if ($update->[$ID]) {
582 8 50       46 if ($update->[$ID] =~ m/tmp/i) {
583 0         0 die "TMP IDs not allowed in TDList input";
584             }
585             }
586             else {
587 5         18 $self->sth('select_by_name')->execute($update->[$self->column_idx('last_name')], $update->[$self->column_idx('first_name')]);
588 5         25 my $players = $self->sth('select_by_name')->fetchall_arrayref;
589 5         11 for my $player (@{$players}) {
  5         15  
590 2 50       17 if ($player->[$ID] =~ m/tmp/i) {
591 2         6 $update->[$ID] = $player->[$ID]; # already in DB (hope it's the same guy!)
592             }
593             }
594 5 100       27 if (not $update->[$ID]) {
595 3         14 $update->[$ID] = $self->next_tmp_id(1);
596             }
597             }
598 13 100       49 if ($self->select_id($update->[$ID])) {
599             # ID is already in database, do an update
600             $self->sth('update_id')->execute(
601 7         23 @{$update}, # new values for all columns
  7         1158  
602             $update->[$ID], # player ID (for WHERE condition)
603             );
604             }
605             else {
606             # ID is not in database, insert new record
607 6         10 $self->insert_player(@{$update});
  6         25  
608             }
609             }
610             else {
611 0         0 die "no name parsed from $line";
612             }
613             }
614             catch {
615 0     0   0 $error_count++;
616 0         0 my $error = $_;
617 0         0 $self->my_print("Error at line $ii: $error");
618 13         184 };
619 13 50       491 if ($error_count >= $self->max_update_errors) {
620 0         0 $self->my_print("$error_count errors - aborting\n");
621 0         0 last;
622             }
623             }
624 4         30 $self->db->do('COMMIT'); # make sure we do this!
625 4         62646 $self->update_time(time);
626             }
627              
628             # file might not have lines. enforce lines here
629             sub next_line {
630 22     22 0 30 my ($self) = @_;
631              
632 22         32 my $offset = $self->{buf_offset};
633 22 100       72 if ($self->{buf_end} - $offset <= 160) {
634 13         34 $self->get_fh_chunk;
635 13         24 $offset = $self->{buf_offset};
636             }
637 22 100       64 return if ($offset >= $self->{buf_end});
638 18         26 my $eol_idx;
639 18 100       48 if ($self->{has_lines}) {
640 16         34 $eol_idx = index($self->{buf}, "\n", $offset);
641 16 50       42 if ($eol_idx < 0) {
642 0         0 die "no EOL"; # shouldn't happen
643             }
644             }
645             else {
646             # assume 80 characters per line
647 2         5 $eol_idx = $offset + 80;
648             # but not past the end of the buffer
649 2 50       11 $eol_idx = $self->{buf_end} if ($eol_idx > $self->{buf_end});
650 2         3 $eol_idx--;
651             }
652 18         25 my $len = $eol_idx - $offset;
653 18         53 my $line = substr $self->{buf}, $offset, $len;
654 18         81 $self->{buf_offset} += $len + 1;
655 18         47 return $line;
656             }
657              
658             sub fh {
659 21     21 0 37 my ($self, $new) = @_;
660              
661 21 100       80 if (@_ > 1) {
662 4         17 $self->{fh} = $new;
663 4         28 delete $self->{has_lines};
664 4 50 33     56 if ($new and ref $new) {
665 4         12 $self->{buf} = '';
666 4         15 $self->{buf_offset} = $self->{buf_end} = 0;
667 4         20 $self->get_fh_chunk;
668 4         21 $self->{has_lines} = (index($self->{buf}, "\n") >= 0);
669             }
670             }
671 21         155 return $self->{fh};
672             }
673              
674             sub get_fh_chunk {
675 17     17 0 27 my ($self) = @_;
676              
677             # how much is still left?
678 17         182 my $left = $self->{buf_end} - $self->{buf_offset};
679             # shift unused part of buf down to the beginning
680 17         46 substr($self->{buf}, 0, $self->{buf_offset}, '');
681             # read in a new chunk
682 17         85 my $new = read $self->fh, $self->{buf}, $BUF_MAX - $left, $left;
683 17 50       178 if (not defined $new) {
684 0         0 die "Read error: $!";
685             }
686 17         43 $self->{buf_end} = $left + $new;
687 17         40 $self->{buf_offset} = 0;
688             }
689              
690             # sql columns (without column types)
691             sub sql_columns {
692 1     1 1 3 my ($self, $joiner) = @_;
693              
694 1 50       5 $joiner = ', ' if (not defined $joiner);
695             return join($joiner,
696 1         12 map({ keys %{$_} }
  11         152  
  11         43  
697             @columns,
698             $self->extra_columns,
699             ),
700             );
701             }
702              
703             # sql columns with column types
704             sub sql_column_types {
705 1     1 1 2 my ($self, $joiner) = @_;
706              
707 1 50       3 $joiner = ', ' if (not defined $joiner);
708              
709             return join($joiner,
710 1         5 map({join ' ', each %{$_}}
  11         96  
  11         35  
711             @columns,
712             $self->extra_columns,
713             ),
714             );
715             }
716              
717             # '?, ' place-holder question marks for each column,
718             # appropriate for an UPDATE or INSERT query
719             sub sql_update_qs {
720 1     1 1 3 my ($self, $joiner) = @_;
721              
722 1 50       6 $joiner = ', ' if (not defined $joiner);
723              
724             return join($joiner,
725 1         7 map({ (keys(%{$_}))[0] . ' = ?' }
  11         127  
  11         44  
726             @columns,
727             $self->extra_columns,
728             ),
729             );
730             }
731              
732             # place-holder question marks for each column,
733             # appropriate for an INSERT query
734             sub sql_insert_qs {
735 1     1 1 5 my ($self, $joiner) = @_;
736              
737 1 50       6 $joiner = ', ' if (not defined $joiner);
738              
739             return join($joiner,
740 1         6 map({ '?' } # one question mark per column
  11         32  
741             @columns,
742             $self->extra_columns,
743             ),
744             );
745             }
746              
747             sub my_print {
748 0     0 1   my $self = shift;
749              
750 0           $self->print_cb->(@_);
751             }
752              
753             sub print_cb {
754 0     0 1   my ($self, $new) = @_;
755              
756 0 0         $self->{print_cb} = $new if (@_ > 1);
757 0   0 0     return $self->{print_cb} || sub { print @_ };
  0            
758             }
759              
760             1;
761              
762             __END__