chiark / gitweb /
ypp-chatlog-alerter: when replacing, always delete the row and shuffle others up
[ypp-sc-tools.db-live.git] / yarrg / CommodsDatabase.pm
1 # This is part of ypp-sc-tools, a set of third-party tools for assisting
2 # players of Yohoho Puzzle Pirates.
3 #
4 # Copyright (C) 2009 Ian Jackson <ijackson@chiark.greenend.org.uk>
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18 #
19 # Yohoho and Puzzle Pirates are probably trademarks of Three Rings and
20 # are used without permission.  This program is not endorsed or
21 # sponsored by Three Rings.
22
23 package CommodsDatabase;
24
25 # Valid calling sequences:
26 #    db_setocean('Midnight')
27 #  [ db_filename() => 'OCEAN-Midnight.db'  also OK at any later time ]
28 #  [ db_writer() ]                         helpful but not essential
29 #    db_connect()
30 #  [ db_onconflict(sub { .... }) ]         essential if just dieing is not OK
31 #    $dbh->do(...), $dbh->prepare(...), db_doall("stmt;stmt;"), etc.
32
33 use strict;
34 use warnings;
35
36 use DBI;
37 use POSIX;
38
39 use Commods;
40
41 BEGIN {
42     use Exporter ();
43     our ($VERSION, @ISA, @EXPORT, @EXPORT_OK, %EXPORT_TAGS);
44     $VERSION     = 1.00;
45     @ISA         = qw(Exporter);
46     @EXPORT      = qw(&db_setocean &db_writer &db_connect $dbh
47                       &db_filename &db_doall &db_onconflict
48                       &dbr_filename &dbr_connect &db_connect_core
49                       &dumptab_head &dumptab_row_hashref
50                       &db_chkcommit &db_check_referential_integrity);
51     %EXPORT_TAGS = ( );
52
53     @EXPORT_OK   = qw();
54 }
55
56 sub dbr_filename ($$) {
57     my ($datadir,$oceanname) = @_;
58     return "$datadir/OCEAN-$oceanname.db";
59 }
60 sub dbr_connect ($$) {
61     my ($datadir,$ocean) = @_;
62     return db_connect_core(dbr_filename($datadir,$ocean));
63 }
64
65 sub db_connect_core ($) {
66     my ($fn)= @_;
67     my $h= DBI->connect("dbi:SQLite:$fn",'','',
68                        { AutoCommit=>0,
69                          RaiseError=>1, ShowErrorStatement=>1,
70                          unicode=>1 })
71         or die "$fn $DBI::errstr ?";
72     return $h;
73     # default timeout is 30s which is plenty
74 }
75
76 our $dbfn;
77 our $dbh;
78
79 sub db_setocean ($) {
80     my ($oceanname) = @_;
81     $dbfn= dbr_filename('.',$oceanname);
82 }
83 sub db_filename () {
84     return $dbfn;
85 }
86
87 sub db_onconflict (&) {
88     my ($conflictproc) = @_;
89     $dbh->{HandleError}= sub {
90         my ($emsg,$dbh,$val1) = @_;
91         my $native_ecode= $dbh->err();
92         &$conflictproc($emsg) if grep { $_ == $native_ecode } qw(5 6);
93         # 5==SQLITE_BUSY, 6==SQLITE_LOCKED according to the SQLite3
94         # API documentation, .../capi3ref.html#extended-result-codes.
95         return 0; # RaiseError happens next.
96     };
97 }
98
99 our $writerlockh;
100
101 sub db_writer () {
102     my $lockfn= "Writer.lock";
103     $writerlockh= new IO::File "$lockfn", "w" or die "$lockfn $!";
104
105     my $flockall= pack 's!s!LLLLLL', F_WRLCK, SEEK_SET, 0,0,0,0,0,0;
106     # should work everywhere to lock the whole file, provided that
107     # l_type and l_whence are `short int' and come first in that order,
108     # and that start, len and pid are no more than 64 bits each.
109
110     my $r= fcntl($writerlockh, F_SETLKW, $flockall);
111     $r or die "$lockfn fcntl $!";
112 }
113
114 sub db_connect () {
115     $dbh= db_connect_core($dbfn);
116 }
117
118 sub db_doall ($) {
119     foreach my $cmd (split /\;/, $_[0]) {
120         $dbh->do("$cmd;") if $cmd =~ m/\S/;
121     }
122 }
123
124 #---------- table dump helper ----------
125
126 sub dumptab_head ($$$) {
127     my ($fh,$w,$cols) = @_;
128     printf $fh "|%-${w}s", $_ foreach @$cols;  print $fh "|\n";
129     print $fh "+",('-'x$w)  foreach @$cols;    print $fh "+\n";
130 }
131
132 sub dumptab_row_hashref ($$$$) {
133     my ($fh,$w,$cols,$row) = @_;
134     printf $fh "|%-$w.${w}s",
135            (defined $row->{$_} ? $row->{$_} : 'NULL')
136         foreach @$cols;
137     print $fh "\n";
138 }
139
140 #---------- referential integrity constraints ----------
141
142 # SQLite doesn't support foreign key constraints so we do it by steam:
143
144 sub nooutput ($) {
145     my ($stmts) = @_;
146     my $ekindcount= 0;
147     my $letxt= '';
148     foreach my $stmt (split /\;/, $stmts) {
149         next unless $stmt =~ /\S/;
150
151         my $etxt= '';
152         $stmt =~ s/^([ \t]*\#.*)$/ $etxt .= $1."\n"; ''; /mge;
153         $etxt= $letxt unless length $etxt;
154         $letxt= $etxt;
155         
156         $stmt =~ s/^\s+//; $stmt =~ s/\s+$//;
157         my $sth= $dbh->prepare($stmt);
158         $sth->execute();
159         my $row;
160         my $ecount= 0;
161         my @cols= @{ $sth->{NAME_lc} };
162         my $w= 11;
163         while ($row= $sth->fetchrow_hashref) {
164             if (!$ecount++) {
165                 print STDERR "REFERENTIAL INTEGRITY ERROR\n";
166                 print STDERR "\n$etxt\n $stmt\n\n";
167                 dumptab_head(\*STDERR,$w,\@cols);
168             }
169             if ($ecount>5) { print STDERR "...\n"; last; }
170             dumptab_row_hashref(\*STDERR,$w,\@cols,$row);
171         }
172         next unless $ecount;
173         
174         $ekindcount++;
175         print STDERR "\n\n";
176     }
177     die "REFERENTIAL INTEGRITY ERRORS $ekindcount\n"
178         if $ekindcount;
179 }
180
181 sub db_check_referential_integrity ($) {
182     my ($full) = @_;
183     # non-full is done only for market data updates; it avoids
184     # detecting errors which are essentially missing metadata and
185     # old schemas, etc.
186
187     foreach my $bs (qw(buy sell)) {
188         nooutput(<<END);
189
190  # Every buy/sell must refer to an entry in commods, islands, and stalls:
191  SELECT * FROM $bs LEFT JOIN commods USING (commodid) WHERE commodname IS NULL;
192  SELECT * FROM $bs LEFT JOIN islands USING (islandid) WHERE islandname IS NULL;
193  SELECT * FROM $bs LEFT JOIN stalls USING (stallid, islandid)
194                                                       WHERE stallname IS NULL;
195
196  # Every buy/sell must be part of an upload:
197  SELECT * FROM $bs LEFT JOIN uploads USING (islandid) WHERE timestamp IS NULL;
198
199  # The islandid in stalls must be the same as the islandid in buy/sell:
200  SELECT * FROM $bs JOIN stalls USING (stallid)
201         WHERE $bs.islandid != stalls.islandid;
202
203 END
204     }
205
206     nooutput(<<END);
207
208  # Every stall and upload must refer to an island:
209  SELECT * FROM stalls LEFT JOIN islands USING (islandid)
210                                         WHERE islandname IS NULL;
211  SELECT * FROM uploads LEFT JOIN islands USING (islandid)
212                                          WHERE islandname IS NULL;
213
214 END
215     if ($full) {
216         foreach my $end (qw(aiid biid)) {
217             foreach my $tab (qw(dists routes)) {
218                 nooutput(<<END);
219
220  # Every row in dists and routes must refer to two existing rows in islands:
221  SELECT * FROM $tab d LEFT JOIN islands ON d.$end=islandid
222         WHERE islandname IS NULL;
223
224 END
225             }
226         }
227         nooutput(<<END);
228
229  # Every pair of islands must have an entry in dists:
230  SELECT * FROM islands ia JOIN islands ib LEFT JOIN dists
231         ON ia.islandid=aiid and ib.islandid=biid
232         WHERE dist IS NULL;
233
234  # Every commod must refers to a commodclass and vice versa:
235  SELECT * FROM commods LEFT JOIN commodclasses USING (commodclassid)
236         WHERE commodclass IS NULL;
237  SELECT * FROM commodclasses LEFT JOIN commods USING (commodclassid)
238         WHERE commodname IS NULL;
239
240  # Ordvals which are not commodclass ordvals are unique:
241  SELECT ordval,count(*),commodname,commodid,posinclass
242         FROM commods
243         WHERE posinclass > 0
244         GROUP BY ordval
245         HAVING count(*) > 1;
246
247  # For every class, posinclass is dense from 1 to maxposinclass,
248  # apart from the commods for which it is zero.
249  SELECT commodclass,commodclassid,posinclass,count(*)
250         FROM commods JOIN commodclasses USING (commodclassid)
251         WHERE posinclass > 0
252         GROUP BY commodclassid,posinclass
253         HAVING count(*) > 1;
254  SELECT commodclass,commodclassid,count(*)
255         FROM commods JOIN commodclasses USING (commodclassid)
256         WHERE posinclass > 0
257         GROUP BY commodclassid
258         HAVING count(*) != maxposinclass;
259  SELECT *
260         FROM commods JOIN commodclasses USING (commodclassid)
261         WHERE posinclass < 0 OR posinclass > maxposinclass;
262
263 END
264     }
265 }
266
267 sub db_chkcommit ($) {
268     my ($full) = @_;
269     db_check_referential_integrity($full);
270     $dbh->commit();
271 }
272
273 1;