chiark / gitweb /
website: new client has implications for devel page too
[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 NATURAL LEFT JOIN commods WHERE commodname IS NULL;
192  SELECT * FROM $bs NATURAL LEFT JOIN islands WHERE islandname IS NULL;
193  SELECT * FROM $bs LEFT JOIN STALLS USING (stallid) WHERE stallname IS NULL;
194
195  # Every buy/sell must be part of an upload:
196  SELECT * FROM $bs NATURAL LEFT JOIN uploads WHERE timestamp IS NULL;
197
198  # The islandid in stalls must be the same as the islandid in buy/sell:
199  SELECT * FROM $bs JOIN stalls USING (stallid)
200         WHERE $bs.islandid != stalls.islandid;
201
202 END
203     }
204
205     nooutput(<<END);
206
207  # Every stall and upload must refer to an island:
208  SELECT * FROM stalls NATURAL LEFT JOIN islands WHERE islandname IS NULL;
209  SELECT * FROM uploads NATURAL LEFT JOIN islands WHERE islandname IS NULL;
210
211 END
212     if ($full) {
213         foreach my $end (qw(aiid biid)) {
214             foreach my $tab (qw(dists routes)) {
215                 nooutput(<<END);
216
217  # Every row in dists and routes must refer to two existing rows in islands:
218  SELECT * FROM $tab d LEFT JOIN islands ON d.$end=islandid
219         WHERE islandname IS NULL;
220
221 END
222             }
223         }
224         nooutput(<<END);
225
226  # Every pair of islands must have an entry in dists:
227  SELECT * FROM islands ia JOIN islands ib LEFT JOIN dists
228         ON ia.islandid=aiid and ib.islandid=biid
229         WHERE dist IS NULL;
230
231  # Every commod must refers to a commodclass and vice versa:
232  SELECT * FROM commods NATURAL LEFT JOIN commodclasses
233         WHERE commodclass IS NULL;
234  SELECT * FROM commodclasses NATURAL LEFT JOIN commods
235         WHERE commodname IS NULL;
236
237  # Ordvals which are not commodclass ordvals are unique:
238  SELECT ordval,count(*),commodname,commodid,posinclass
239         FROM commods
240         WHERE posinclass > 0
241         GROUP BY ordval
242         HAVING count(*) > 1;
243
244  # For every class, posinclass is dense from 1 to maxposinclass,
245  # apart from the commods for which it is zero.
246  SELECT commodclass,commodclassid,posinclass,count(*)
247         FROM commods NATURAL JOIN commodclasses
248         WHERE posinclass > 0
249         GROUP BY commodclassid,posinclass
250         HAVING count(*) > 1;
251  SELECT commodclass,commodclassid,count(*)
252         FROM commods NATURAL JOIN commodclasses
253         WHERE posinclass > 0
254         GROUP BY commodclassid
255         HAVING count(*) != maxposinclass;
256  SELECT *
257         FROM commods NATURAL JOIN commodclasses
258         WHERE posinclass < 0 OR posinclass > maxposinclass;
259
260 END
261     }
262 }
263
264 sub db_chkcommit ($) {
265     my ($full) = @_;
266     db_check_referential_integrity($full);
267     $dbh->commit();
268 }
269
270 1;