File Coverage

blib/lib/Games/Go/AGA/TDListDB.pm
Criterion Covered Total %
statement 91 272 33.4
branch 17 114 14.9
condition 1 10 10.0
subroutine 25 53 47.1
pod 30 35 85.7
total 164 484 33.8


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             # COMPANY: LucidPort Technology, Inc.
6             # CREATED: 12/02/2010 08:51:22 AM PST
7             #===============================================================================
8              
9 1     1   883 use 5.010;
  1         16  
  1         36  
10 1     1   4 use strict;
  1         1  
  1         34  
11 1     1   11 use warnings;
  1         1  
  1         80  
12              
13             package Games::Go::AGA::TDListDB;
14 1     1   424 use parent 'Exporter';
  1         294  
  1         4  
15             our @EXPORT_OK = qw(
16             LAST_NAME
17             FIRST_NAME
18             ID
19             MEMBERSHIP
20             RANK
21             DATE
22             CLUB
23             STATE
24             EXTRA
25             );
26             our %EXPORT_TAGS = (all => \@EXPORT_OK);
27              
28 1     1   508 use open qw( :utf8 :std ); # UTF8 for all files and STDIO
  1         939  
  1         4  
29 1     1   627 use IO::Handle; # for autoflush
  1         5467  
  1         54  
30 1     1   7 use Carp;
  1         2  
  1         47  
31 1     1   1570 use DBI;
  1         16090  
  1         85  
32 1     1   594 use Readonly;
  1         2542  
  1         53  
33 1     1   5 use Try::Tiny;
  1         2  
  1         44  
34 1     1   5 use POSIX ":sys_wait_h";
  1         1  
  1         7  
35 1     1   2565 use LWP::UserAgent;
  1         43681  
  1         34  
36 1     1   494 use LWP::Protocol::https;
  1         66936  
  1         44  
37 1     1   513 use Games::Go::AGA::Parse::TDList;
  1         14849  
  1         38  
38 1     1   7 use Games::Go::AGA::Parse::Util qw( is_Rating );
  1         4  
  1         2959  
39              
40             our $VERSION = '0.026'; # VERSION
41              
42 0     0 1 0 sub LAST_NAME { 0 };
43 0     0 1 0 sub FIRST_NAME { 1 };
44 0     0 1 0 sub ID { 2 };
45 0     0 1 0 sub MEMBERSHIP { 3 };
46 0     0 1 0 sub RANK { 4 };
47 0     0 1 0 sub DATE { 5 };
48 0     0 1 0 sub CLUB { 6 };
49 0     0 1 0 sub STATE { 7 };
50 0     0 1 0 sub EXTRA { 8 };
51              
52             Readonly my @columns => (
53             {last_name => 'VARCHAR(256)', },
54             {first_name => 'VARCHAR(256)', },
55             {id => 'VARCHAR(256) NOT NULL PRIMARY KEY', },
56             {membership => 'VARCHAR(256)', },
57             {rank => 'VARCHAR(256)', },
58             {date => 'VARCHAR(256)', },
59             {club => 'VARCHAR(256)', },
60             {state => 'VARCHAR(256)', },
61             {extra => 'VARCHAR(256)', },
62             );
63              
64             my %pre_defaults = (
65             url => 'https://www.usgo.org/ratings/TDListN.txt',
66             dbdname => 'tdlistdb.sqlite',
67             table_name => 'tdlist',
68             extra_columns => [],
69             extra_columns_callback => sub { return () },
70             max_update_errors => 10,
71             raw_filename => 'TDList.txt',
72             verbose => 0,
73             );
74              
75             __PACKAGE__->run( @ARGV ) if not caller(); # modulino
76              
77             sub new {
78 1     1 1 797 my ($class, %args) = @_;
79              
80 1         2 my $self = {};
81 1   33     7 bless $self, (ref $class || $class);
82              
83 1         5 while (my ($key, $value) = each %pre_defaults) {
84 8 100       15 $value = delete $args{$key} if (exists $args{$key});
85 8         15 $self->$key($value);
86             }
87              
88 1         3 my $db = $self->db(delete $args{db});
89              
90 0         0 for my $key (keys %args) { # any leftovers?
91 0         0 $self->$key($args{$key});
92             }
93              
94             # SQL for finding players by name
95 0         0 $self->sth('select_by_name',
96             $db->prepare(
97             join('',
98             'SELECT * FROM ',
99             $self->table_name,
100             ' WHERE last_name = ?',
101             ' AND first_name = ?',
102             ),
103             ),
104             );
105              
106             # and a statement for inserting new players
107 0         0 $self->sth('insert_player',
108             $db->prepare(
109             join('',
110             'INSERT INTO ',
111             $self->table_name,
112             ' ( ',
113             $self->sql_columns,
114             ' ) ',
115             'VALUES ( ',
116             $self->sql_insert_qs,
117             ' )',
118             ),
119             ),
120             );
121              
122             # SQL for updating when player is already in DB
123 0         0 $self->sth('update_id',
124             $db->prepare(
125             join('',
126             'UPDATE ',
127             $self->table_name,
128             ' SET ',
129             $self->sql_update_qs,
130             ' WHERE id = ?',
131             ),
132             ),
133             );
134              
135             # SQL for finding IDs
136 0         0 $self->sth('select_id',
137             $db->prepare(
138             join('',
139             'SELECT * FROM ',
140             $self->table_name,
141             ' WHERE id = ?',
142             ),
143             ),
144             );
145              
146             # SQL for getting and setting DB update time
147 0         0 $self->sth('select_time',
148             $db->prepare(
149             join('',
150             'SELECT update_time FROM ',
151             $self->table_name_meta,
152             ' WHERE key = 1',
153             ),
154             ),
155             );
156 0         0 $self->sth('update_time',
157             $db->prepare(
158             join('',
159             'UPDATE ',
160             $self->table_name_meta,
161             ' SET update_time = ?',
162             ' WHERE key = 1',
163             ),
164             ),
165             );
166              
167             # SQL to get/set next_tmp marker
168 0         0 $self->sth('select_next_tmp',
169             $db->prepare(
170             join('',
171             'SELECT next_tmp_id FROM ',
172             $self->table_name_meta,
173             ' WHERE key = 1',
174             ),
175             ),
176             );
177 0         0 $self->sth('update_next_tmp',
178             $db->prepare(
179             join('',
180             'UPDATE ',
181             $self->table_name_meta,
182             ' SET next_tmp_id = ?',
183             ' WHERE key = 1',
184             ),
185             )
186             );
187              
188 0         0 $self->init(\%args); # in case any subclass needs initialization
189              
190             map {
191 0 0       0 if (not $self->can($_)) {
  0         0  
192 0         0 my $ref = ref $self;
193 0         0 confess("$ref can't '->$_'\n");
194             }
195 0         0 $self->$_($args{$_});
196             } keys %args;
197              
198 0         0 return $self;
199             }
200              
201             sub run {
202 0     0 0 0 my ($class) = @_;
203              
204 0         0 my $tdlist = $class->new();
205 0         0 my $filename = $tdlist->raw_filename;
206 0         0 my $dbfile = $tdlist->dbdname;
207 0         0 STDOUT->autoflush(1);
208              
209 0 0       0 if ($ARGV[0]) {
210 0 0       0 if ($ARGV[0] eq 'AGA') {
211 0         0 my $url = $tdlist->url;
212 0         0 print "Updating $dbfile from AGA ($url)\n";
213 0         0 $tdlist->update_from_AGA();
214 0         0 exit;
215             }
216 0 0       0 $filename = $ARGV[0] if (-f $ARGV[0]);
217             }
218 0         0 print "Updating $dbfile from file ($filename)\n";
219 0         0 $tdlist->update_from_file($filename);
220             }
221              
222             # stub for subclass to override
223             sub init {
224 0     0 0 0 my ($self) = @_;
225             }
226              
227             sub verbose {
228 1     1 0 1 my ($self, $new) = @_;
229              
230 1 50       2 if (@_ > 1) {
231 1         2 $self->{verbose} = $new;
232             }
233              
234 1         3 return $self->{verbose};
235             }
236              
237             sub raw_filename {
238 1     1 1 1 my ($self, $new) = @_;
239              
240 1 50       3 if (@_ > 1) {
241 1         2 $self->{raw_filename} = $new;
242             }
243              
244 1         3 return $self->{raw_filename};
245             }
246              
247             sub dbdname {
248 2     2 1 3 my ($self, $new) = @_;
249              
250 2 100       3 if (@_ > 1) {
251 1         2 $self->{dbdname} = $new;
252             }
253              
254 2         5 return $self->{dbdname};
255             }
256              
257             sub table_name {
258 1     1 1 1 my ($self, $new) = @_;
259              
260 1 50       4 if (@_ > 1) {
261 1         1 $self->{table_name} = $new;
262             }
263              
264 1         3 return quotemeta $self->{table_name};
265             }
266              
267             sub table_name_meta {
268 0     0 0 0 my ($self) = @_;
269              
270 0         0 return $self->table_name . '_meta';
271             }
272              
273             sub url {
274 1     1 1 1 my ($self, $new) = @_;
275              
276 1 50       3 if (@_ > 1) {
277 1         2 $self->{url} = $new;
278             }
279              
280 1         2 return $self->{url};
281             }
282              
283             sub background {
284 0     0 1 0 my ($self, $new) = @_;
285              
286 0 0       0 if (@_ > 1) {
287 0         0 $self->{background} = $new;
288             }
289              
290 0         0 return $self->{background};
291             }
292              
293             sub max_update_errors {
294 1     1 1 2 my ($self, $new) = @_;
295              
296 1 50       5 if (@_ > 1) {
297 1         1 $self->{max_update_errors} = $new;
298             }
299              
300 1         18 return $self->{max_update_errors};
301             }
302              
303             sub extra_columns_callback {
304 1     1 1 1 my ($self, @new) = @_;
305              
306 1 50       4 if (@_ > 1) {
307 1 50       4 if (ref $new[0] ne 'CODE') {
308 0         0 croak("Must set a code-ref in extra_columns_callback\n");
309             }
310 1         5 $self->{extra_columns_callback} = $new[0];
311             }
312 1         4 return $self->{extra_columns_callback};
313             }
314              
315             sub extra_columns {
316 1     1 1 2 my ($self, @new) = @_;
317              
318 1 50       2 if (@_ > 1) {
319 1 50       3 if (ref $new[0] eq 'ARRAY') {
320 1         2 $self->{extra_columns} = $new[0];
321             }
322             else {
323 0         0 $self->{extra_columns} = \@new;
324             }
325             }
326 1 50       4 return wantarray ? @{$self->{extra_columns}} : $self->{extra_columns};
  0         0  
327             }
328              
329             sub db {
330 1     1 1 1 my ($self, $new) = @_;
331              
332 1 50       9 if (@_ > 1) {
333 1 50       3 if (not $new) {
334 1 50       2 if (my $fname = $self->dbdname) {
335 1         10 $new = DBI->connect( # connect to your database, create if needed
336             "dbi:SQLite:dbname=$fname", # DSN: dbi, driver, database file
337             "", # no user
338             "", # no password
339             {
340             AutoCommit => 1,
341             RaiseError => 1, # complain if something goes wrong
342             },
343             )
344             }
345             else {
346 0           croak("No dbdname for SQLite file\n");
347             }
348             }
349 0           $self->{db} = $new;
350 0           $self->_db_schema(); # make sure table exists
351             }
352              
353 0           return $self->{db};
354             }
355              
356             # library of statement handles
357             sub sth {
358 0     0 1   my ($self, $name, $new) = @_;
359              
360 0 0         croak("Statement handle name is required\n") if (not $name);
361 0 0         if (@_ > 2) {
362 0           $self->{sth}{$name} = $new;
363             }
364              
365 0           my $sth = $self->{sth}{$name};
366 0 0         croak("No statement handle called '$name'\n") if (not $sth);
367              
368 0           return $sth;
369             }
370              
371             sub _db_schema {
372 0     0     my ($self) = @_;
373              
374 0           $self->db->do(
375             join('',
376             'CREATE TABLE IF NOT EXISTS ',
377             $self->table_name,
378             ' (',
379             $self->sql_column_types,
380             ' )',
381             ),
382             );
383              
384 0           $self->db->do(join '',
385             'CREATE TABLE IF NOT EXISTS ',
386             $self->table_name_meta,
387             ' (',
388             'key INTEGER PRIMARY KEY, ',
389             'update_time VARCHAR(12), ',
390             'next_tmp_id VARCHAR(12)',
391             ' )',
392             );
393              
394 0           $self->db->do(join '',
395             'INSERT OR IGNORE INTO ',
396             $self->table_name_meta,
397             ' (',
398             'key, ',
399             'update_time, ',
400             'next_tmp_id',
401             ' ) VALUES ( 1, 0, 1 )',
402             );
403             }
404              
405             sub update_time {
406 0     0 1   my ($self, $new) = @_;
407              
408 0 0         if (@_ > 1) {
409 0           $self->sth('update_time')->execute($new);
410             }
411 0           $self->sth('select_time')->execute();
412 0           my $time = $self->sth('select_time')->fetchall_arrayref();
413 0           $time = $time->[0][0];
414 0   0       return $time || 0;
415             }
416              
417             sub select_id {
418 0     0 1   my ($self, $id) = @_;
419              
420 0           $self->sth('select_id')->execute($id);
421             # ID is primary index, so can only be one - fetch into first array
422             # element:
423 0           my ($player) = $self->sth('select_id')->fetchall_arrayref;
424 0 0         $player->[RANK] += 0 if (is_Rating($player->[RANK])); # numify ratings
425             return wantarray
426 0 0         ? @{$player->[0]}
  0            
427             : $player->[0];
428             }
429              
430             sub insert_player {
431 0     0 0   my ($self, @new) = @_;
432              
433 0 0         $new[ID] = $self->next_tmp_id(1) if (not $new[ID]);
434 0           $self->sth('insert_player')->execute(@new);
435             return wantarray
436             ? @new
437 0 0         : \@new;
438             }
439              
440             sub next_tmp_id {
441 0     0 1   my ($self, $used) = @_;
442              
443 0           $self->sth('select_next_tmp')->execute;
444 0           my $next_tmp = $self->sth('select_next_tmp')->fetchall_arrayref;
445 0           $next_tmp = $next_tmp->[0][0];
446 0   0       $next_tmp ||= 1;
447 0           while ($self->select_id("TMP$next_tmp")) {
448 0           $next_tmp++
449             }
450              
451 0 0         if ($used) { # is the caller planning on allocating this one?
452 0           $self->sth('update_next_tmp')->execute($next_tmp + 1);
453             }
454 0           return "TMP$next_tmp";
455             }
456              
457             # reap any child zombies from earlier update_from_AGA calls
458             sub reap {
459 0     0 1   my $kid;
460 0           my $reaped = 0;
461 0           do {
462 0           $kid = waitpid(-1, WNOHANG);
463 0 0         $reaped++ if ($kid > 0);
464             } while $kid > 0;
465 0           return $reaped;
466             }
467              
468             sub update_from_AGA {
469 0     0 1   my ($self) = @_;
470              
471 0           my $pid;
472 0 0         if ($self->background) {
473 0           $pid = fork;
474 0 0         die "fork failed: $!\n" if not defined $pid;
475             }
476 0 0         if ($pid) {
477             # parent process
478 0           return;
479             }
480              
481 0 0         if (not $self->{ua}) {
482 0           $self->{ua} = LWP::UserAgent->new;
483             }
484              
485 0           my $fname = $self->raw_filename;
486 0 0         print "Starting $fname fetch at ", scalar localtime, "\n" if ($self->verbose);
487 0           $self->{ua}->mirror($self->url, $fname);
488 0 0         print "... fetch done at ", scalar localtime, "\n" if ($self->verbose);
489 0           my $fh;
490 0 0         open($fh, '<', $fname)
491             or croak("Error opening $fname for reading: $!\n");
492 0           $self->update_from_file($fh);
493              
494 0 0         exit if (defined $pid); # exit if this is a spawned child
495             }
496              
497             sub update_from_file {
498 0     0 1   my ($self, $fh) = @_;
499              
500 0 0         if (not ref $fh) {
501 0           my $fname = $fh;
502 0           $fh = undef;
503 0 0         if (not open($fh, '<', $fname)) {
504 0           croak("Error opening $fname for reading: $!\n");
505             }
506             }
507              
508 0           my $parser = Games::Go::AGA::Parse::TDList->new();
509 0           my $verbose = $self->verbose;
510 0 0         print "Starting database update at ", scalar localtime, "\n" if ($verbose);
511 0           $self->db->do('BEGIN');
512 0           my @errors;
513 0           my $ii = 0;
514 0           while (my $line = <$fh>) {
515 0 0         print '.' if (++$ii % 1000 == 0);
516 0 0         print "\n" if ($ii % 40000 == 0);
517             try { # in case a line crashes, print error but continue
518             #print "parse $line";
519 0     0     $parser->parse($line);
520 0           my $update = $parser->as_array;
521 0 0 0       if ($update->[LAST_NAME] or $update->[FIRST_NAME]) {
522 0           push @{$update}, $self->extra_columns_callback->($self, $update);
  0            
523 0 0         if ($update->[ID]) {
524 0 0         if ($update->[ID] =~ m/tmp/i) {
525 0           croak "TMP IDs not allowed in TDList input"
526             }
527             }
528             else {
529 0           $self->sth('select_by_name')->execute($update->[LAST_NAME], $update->[FIRST_NAME]);
530 0           my $players = $self->sth('select_by_name')->fetchall_arrayref;
531 0           for my $player (@{$players}) {
  0            
532 0 0         if ($player->[ID] =~ m/tmp/i) {
533 0           $update->[ID] = $player->[ID]; # already in DB (hope it's the same guy!)
534             }
535             }
536 0 0         if (not $update->[ID]) {
537 0           $update->[ID] = $self->next_tmp_id(1);
538             }
539             }
540 0 0         if ($self->select_id($update->[ID])) {
541             # ID is already in database, do an update
542 0           $self->sth('update_id')->execute(
543 0           @{$update}, # new values for all columns
544             $update->[ID], # player ID (for WHERE condition)
545             );
546             }
547             else {
548             # ID is not in database, insert new record
549 0           $self->insert_player(@{$update});
  0            
550             }
551             }
552             }
553             catch {
554 0     0     push @errors, $_;
555 0 0         print $_ if ($verbose);
556 0           };
557 0 0         if (@errors >= $self->max_update_errors) {
558 0           push @errors, "Too many errors - aborting\n";
559 0           last;
560             }
561             }
562 0           $self->db->do('COMMIT'); # make sure we do this!
563 0           $self->update_time(time);
564 0 0         if (@errors > 1) {
565 0           unshift @errors, scalar @errors . " errors during update:\n";
566             }
567 0 0         croak(join "\n", @errors) if(@errors);
568             }
569              
570             # sql columns (without column types)
571             sub sql_columns {
572 0     0 1   my ($self, $joiner) = @_;
573              
574 0 0         $joiner = ', ' if (@_ < 2);
575 0           return join($joiner,
576 0           map({ keys %{$_} }
  0            
577             @columns,
578             $self->extra_columns,
579             ),
580             );
581             }
582              
583             # sql columns with column types
584             sub sql_column_types {
585 0     0 1   my ($self, $joiner) = @_;
586              
587 0 0         $joiner = ', ' if (@_ < 2);
588              
589 0           return join($joiner,
590 0           map({join ' ', each %{$_}}
  0            
591             @columns,
592             $self->extra_columns,
593             ),
594             );
595             }
596              
597             # '?, ' place-holder question marks for each column,
598             # appropriate for an UPDATE or INSERT query
599             sub sql_update_qs {
600 0     0 1   my ($self, $joiner) = @_;
601              
602 0 0         $joiner = ', ' if (@_ < 2);
603              
604 0           return join($joiner,
605 0           map({ (keys(%{$_}))[0] . ' = ?' }
  0            
606             @columns,
607             $self->extra_columns,
608             ),
609             );
610             }
611              
612             # 'column = ?, ' place-holder question marks for each column,
613             # appropriate for a INSERT query
614             sub sql_insert_qs {
615 0     0 1   my ($self, $joiner) = @_;
616              
617 0 0         $joiner = ', ' if (@_ < 2);
618              
619 0           return join($joiner,
620 0           map({ '?' } # one question mark per column
621             @columns,
622             $self->extra_columns,
623             ),
624             );
625             }
626              
627             1;
628              
629             __END__