File Coverage

blib/lib/Games/Go/AGA/TDListDB.pm
Criterion Covered Total %
statement 209 274 76.2
branch 57 116 49.1
condition 5 10 50.0
subroutine 44 53 83.0
pod 30 35 85.7
total 345 488 70.7


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   22644 use 5.010;
  1         3  
  1         38  
10 1     1   5 use strict;
  1         2  
  1         32  
11 1     1   19 use warnings;
  1         1  
  1         47  
12              
13             package Games::Go::AGA::TDListDB;
14 1     1   518 use parent 'Exporter';
  1         311  
  1         6  
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   541 use open qw( :utf8 :std ); # UTF8 for all files and STDIO
  1         924  
  1         5  
29 1     1   641 use IO::Handle; # for autoflush
  1         5121  
  1         47  
30 1     1   7 use Carp;
  1         1  
  1         44  
31 1     1   5 use DBI;
  1         2  
  1         27  
32 1     1   594 use Readonly;
  1         2247  
  1         48  
33 1     1   6 use Try::Tiny;
  1         1  
  1         43  
34 1     1   4 use POSIX ":sys_wait_h";
  1         1  
  1         8  
35 1     1   830 use LWP::UserAgent;
  1         38441  
  1         61  
36 1     1   638 use LWP::Protocol::https;
  1         71513  
  1         38  
37 1     1   532 use Games::Go::AGA::Parse::TDList;
  1         14064  
  1         40  
38 1     1   9 use Games::Go::AGA::Parse::Util qw( is_Rating );
  1         1  
  1         2609  
39              
40             our $VERSION = '0.032'; # VERSION
41              
42 23     23 1 91 sub LAST_NAME { 0 };
43 10     10 1 164 sub FIRST_NAME { 1 };
44 61     61 1 2183 sub ID { 2 };
45 0     0 1 0 sub MEMBERSHIP { 3 };
46 19     19 1 80 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 545 my ($class, %args) = @_;
79              
80 1         2 my $self = {};
81 1   33     13 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         14 $self->$key($value);
86             }
87              
88 1         4 my $db = $self->db(delete $args{db});
89              
90 1         20 for my $key (keys %args) { # any leftovers?
91 0         0 $self->$key($args{$key});
92             }
93              
94             # SQL for finding players by name
95 1         9 $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 1         6 $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 1         9 $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 1         8 $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 1         7 $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 1         6 $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 1         4 $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 1         5 $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 1         6 $self->init(\%args); # in case any subclass needs initialization
189              
190             map {
191 1 0       2 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 1         6 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 1     1 0 2 my ($self) = @_;
225             }
226              
227             sub verbose {
228 5     5 0 12 my ($self, $new) = @_;
229              
230 5 100       21 if (@_ > 1) {
231 1         1 $self->{verbose} = $new;
232             }
233              
234 5         16 return $self->{verbose};
235             }
236              
237             sub raw_filename {
238 1     1 1 2 my ($self, $new) = @_;
239              
240 1 50       3 if (@_ > 1) {
241 1         6 $self->{raw_filename} = $new;
242             }
243              
244 1         5 return $self->{raw_filename};
245             }
246              
247             sub dbdname {
248 2     2 1 18 my ($self, $new) = @_;
249              
250 2 100       5 if (@_ > 1) {
251 1         2 $self->{dbdname} = $new;
252             }
253              
254 2         6 return $self->{dbdname};
255             }
256              
257             sub table_name {
258 12     12 1 22 my ($self, $new) = @_;
259              
260 12 100       30 if (@_ > 1) {
261 1         1 $self->{table_name} = $new;
262             }
263              
264 12         127 return quotemeta $self->{table_name};
265             }
266              
267             sub table_name_meta {
268 6     6 0 10 my ($self) = @_;
269              
270 6         16 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         1 $self->{url} = $new;
278             }
279              
280 1         2 return $self->{url};
281             }
282              
283             sub background {
284 2     2 1 5 my ($self, $new) = @_;
285              
286 2 100       9 if (@_ > 1) {
287 1         5 $self->{background} = $new;
288             }
289              
290 2         6 return $self->{background};
291             }
292              
293             sub max_update_errors {
294 19     19 1 27 my ($self, $new) = @_;
295              
296 19 100       46 if (@_ > 1) {
297 1         2 $self->{max_update_errors} = $new;
298             }
299              
300 19         134 return $self->{max_update_errors};
301             }
302              
303             sub extra_columns_callback {
304 14     14 1 24 my ($self, @new) = @_;
305              
306 14 100       29 if (@_ > 1) {
307 1 50       3 if (ref $new[0] ne 'CODE') {
308 0         0 croak("Must set a code-ref in extra_columns_callback\n");
309             }
310 1         2 $self->{extra_columns_callback} = $new[0];
311             }
312 14         54 return $self->{extra_columns_callback};
313             }
314              
315             sub extra_columns {
316 5     5 1 46 my ($self, @new) = @_;
317              
318 5 100       13 if (@_ > 1) {
319 1 50       4 if (ref $new[0] eq 'ARRAY') {
320 1         1 $self->{extra_columns} = $new[0];
321             }
322             else {
323 0         0 $self->{extra_columns} = \@new;
324             }
325             }
326 5 100       14 return wantarray ? @{$self->{extra_columns}} : $self->{extra_columns};
  4         15  
327             }
328              
329             sub db {
330 13     13 1 860 my ($self, $new) = @_;
331              
332 13 100       46 if (@_ > 1) {
333 1 50       3 if (not $new) {
334 1 50       10 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         0 croak("No dbdname for SQLite file\n");
347             }
348             }
349 1         1494 $self->{db} = $new;
350 1         4 $self->_db_schema(); # make sure table exists
351             }
352              
353 13         5228 return $self->{db};
354             }
355              
356             # library of statement handles
357             sub sth {
358 104     104 1 830 my ($self, $name, $new) = @_;
359              
360 104 50       198 croak("Statement handle name is required\n") if (not $name);
361 104 100       206 if (@_ > 2) {
362 8         26 $self->{sth}{$name} = $new;
363             }
364              
365 104         191 my $sth = $self->{sth}{$name};
366 104 50       194 croak("No statement handle called '$name'\n") if (not $sth);
367              
368 104         36374 return $sth;
369             }
370              
371             sub _db_schema {
372 1     1   2 my ($self) = @_;
373              
374 1         6 $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 1         17158 $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 1         7655 $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 6     6 1 21 my ($self, $new) = @_;
407              
408 6 100       32 if (@_ > 1) {
409 4         21 $self->sth('update_time')->execute($new);
410             }
411 6         48 $self->sth('select_time')->execute();
412 6         26 my $time = $self->sth('select_time')->fetchall_arrayref();
413 6         17 $time = $time->[0][0];
414 6   50     56 return $time || 0;
415             }
416              
417             sub select_id {
418 19     19 1 962 my ($self, $id) = @_;
419              
420 19         38 $self->sth('select_id')->execute($id);
421             # ID is primary index, so can only be one - fetch into first array
422             # element:
423 19         55 my ($player) = $self->sth('select_id')->fetchall_arrayref;
424 19 50       45 $player->[RANK] += 0 if (is_Rating($player->[RANK])); # numify ratings
425             return wantarray
426 19 50       193 ? @{$player->[0]}
  0         0  
427             : $player->[0];
428             }
429              
430             sub insert_player {
431 6     6 0 15 my ($self, @new) = @_;
432              
433 6 50       11 $new[ID] = $self->next_tmp_id(1) if (not $new[ID]);
434 6         9 $self->sth('insert_player')->execute(@new);
435             return wantarray
436             ? @new
437 6 50       29 : \@new;
438             }
439              
440             sub next_tmp_id {
441 5     5 1 1157 my ($self, $used) = @_;
442              
443 5         54 $self->sth('select_next_tmp')->execute;
444 5         16 my $next_tmp = $self->sth('select_next_tmp')->fetchall_arrayref;
445 5         11 $next_tmp = $next_tmp->[0][0];
446 5   50     19 $next_tmp ||= 1;
447 5         18 while ($self->select_id("TMP$next_tmp")) {
448 0         0 $next_tmp++
449             }
450              
451 5 50       16 if ($used) { # is the caller planning on allocating this one?
452 5         14 $self->sth('update_next_tmp')->execute($next_tmp + 1);
453             }
454 5         71 return "TMP$next_tmp";
455             }
456              
457             # reap any child zombies from earlier update_from_AGA calls
458             sub reap {
459 0     0 1 0 my $kid;
460 0         0 my $reaped = 0;
461 0         0 do {
462 0         0 $kid = waitpid(-1, WNOHANG);
463 0 0       0 $reaped++ if ($kid > 0);
464             } while $kid > 0;
465 0         0 return $reaped;
466             }
467              
468             sub update_from_AGA {
469 0     0 1 0 my ($self) = @_;
470              
471 0         0 my $pid;
472 0 0       0 if ($self->background) {
473 0         0 $pid = fork;
474 0 0       0 die "fork failed: $!\n" if not defined $pid;
475             }
476 0 0       0 if ($pid) {
477             # parent process
478 0         0 return;
479             }
480              
481 0 0       0 if (not $self->{ua}) {
482 0         0 $self->{ua} = LWP::UserAgent->new;
483             }
484              
485 0         0 my $fname = $self->raw_filename;
486 0 0       0 print "Starting $fname fetch at ", scalar localtime, "\n" if ($self->verbose);
487 0         0 $self->{ua}->mirror($self->url, $fname);
488 0 0       0 print "... fetch done at ", scalar localtime, "\n" if ($self->verbose);
489 0         0 my $fh;
490 0 0       0 open($fh, '<', $fname)
491             or croak("Error opening $fname for reading: $!\n");
492 0         0 $self->update_from_file($fh);
493 0         0 close $fh;
494              
495 0 0       0 exit if (defined $pid); # exit if this is a spawned child ($pid == 0)
496             }
497              
498             sub update_from_file {
499 4     4 1 5420 my ($self, $fh) = @_;
500              
501 4 50       24 if (not ref $fh) {
502 0         0 my $fname = $fh;
503 0         0 $fh = undef;
504 0 0       0 if (not open($fh, '<', $fname)) {
505 0         0 croak("Error opening $fname for reading: $!\n");
506             }
507             }
508              
509 4         59 my $parser = Games::Go::AGA::Parse::TDList->new();
510 4         98 my $verbose = $self->verbose;
511 4 50       21 print "Starting database update at ", scalar localtime, "\n" if ($verbose);
512 4         19 $self->db->do('BEGIN');
513 4         601 my @errors;
514 4         12 my $ii = 0;
515 4         38 while (my $line = <$fh>) {
516 18 50       41 if ($verbose) {
517 0 0       0 print '.' if (++$ii % 1000 == 0);
518 0 0       0 print "\n" if ($ii % 40000 == 0);
519             }
520             try { # in case a line crashes, print error but continue
521             #print "parse $line" if ($verbose);
522 18     18   524 $parser->parse($line);
523 18         10151 my $update = $parser->as_array;
524 18 100 66     619 if ($update->[LAST_NAME] or $update->[FIRST_NAME]) {
525 13         13 push @{$update}, $self->extra_columns_callback->($self, $update);
  13         34  
526 13 100       111 if ($update->[ID]) {
527 8 50       11 if ($update->[ID] =~ m/tmp/i) {
528 0         0 croak "TMP IDs not allowed in TDList input"
529             }
530             }
531             else {
532 5         10 $self->sth('select_by_name')->execute($update->[LAST_NAME], $update->[FIRST_NAME]);
533 5         12 my $players = $self->sth('select_by_name')->fetchall_arrayref;
534 5         7 for my $player (@{$players}) {
  5         8  
535 2 50       4 if ($player->[ID] =~ m/tmp/i) {
536 2         10 $update->[ID] = $player->[ID]; # already in DB (hope it's the same guy!)
537             }
538             }
539 5 100       9 if (not $update->[ID]) {
540 3         7 $update->[ID] = $self->next_tmp_id(1);
541             }
542             }
543 13 100       21 if ($self->select_id($update->[ID])) {
544             # ID is already in database, do an update
545 7         16 $self->sth('update_id')->execute(
546 7         15 @{$update}, # new values for all columns
547             $update->[ID], # player ID (for WHERE condition)
548             );
549             }
550             else {
551             # ID is not in database, insert new record
552 6         7 $self->insert_player(@{$update});
  6         15  
553             }
554             }
555             }
556             catch {
557 0     0   0 push @errors, $_;
558 0 0       0 print $_ if ($verbose);
559 18         161 };
560 18 50       437 if (@errors >= $self->max_update_errors) {
561 0         0 push @errors, "Too many errors - aborting\n";
562 0         0 last;
563             }
564             }
565 4         13 $self->db->do('COMMIT'); # make sure we do this!
566 4         27274 $self->update_time(time);
567 4 50       85 if (@errors > 1) {
568 0         0 unshift @errors, scalar @errors . " errors during update:\n";
569             }
570 4 50       140 croak(join "\n", @errors) if(@errors);
571             }
572              
573             # sql columns (without column types)
574             sub sql_columns {
575 1     1 1 4 my ($self, $joiner) = @_;
576              
577 1 50       7 $joiner = ', ' if (not defined $joiner);
578 11         38 return join($joiner,
579 1         13 map({ keys %{$_} }
  11         138  
580             @columns,
581             $self->extra_columns,
582             ),
583             );
584             }
585              
586             # sql columns with column types
587             sub sql_column_types {
588 1     1 1 2 my ($self, $joiner) = @_;
589              
590 1 50       3 $joiner = ', ' if (not defined $joiner);
591              
592 11         25 return join($joiner,
593 1         5 map({join ' ', each %{$_}}
  11         94  
594             @columns,
595             $self->extra_columns,
596             ),
597             );
598             }
599              
600             # '?, ' place-holder question marks for each column,
601             # appropriate for an UPDATE or INSERT query
602             sub sql_update_qs {
603 1     1 1 3 my ($self, $joiner) = @_;
604              
605 1 50       6 $joiner = ', ' if (not defined $joiner);
606              
607 11         34 return join($joiner,
608 1         5 map({ (keys(%{$_}))[0] . ' = ?' }
  11         104  
609             @columns,
610             $self->extra_columns,
611             ),
612             );
613             }
614              
615             # place-holder question marks for each column,
616             # appropriate for an INSERT query
617             sub sql_insert_qs {
618 1     1 1 3 my ($self, $joiner) = @_;
619              
620 1 50       7 $joiner = ', ' if (not defined $joiner);
621              
622 11         25 return join($joiner,
623 1         5 map({ '?' } # one question mark per column
624             @columns,
625             $self->extra_columns,
626             ),
627             );
628             }
629              
630             1;
631              
632             __END__