chiark / gitweb /
CommodsDatabase: Work around problems with readonly db
[ypp-sc-tools.main.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 use DBD::SQLite;
39
40 use Commods;
41
42 BEGIN {
43     use Exporter ();
44     our ($VERSION, @ISA, @EXPORT, @EXPORT_OK, %EXPORT_TAGS);
45     $VERSION     = 1.00;
46     @ISA         = qw(Exporter);
47     @EXPORT      = qw(&db_setocean &db_writer &db_connect $dbh
48                       &db_filename &db_doall &db_onconflict
49                       &dbr_filename &dbr_connect &db_connect_core
50                       &dumptab_head &dumptab_row_hashref
51                       &db_chkcommit &db_check_referential_integrity);
52     %EXPORT_TAGS = ( );
53
54     @EXPORT_OK   = qw();
55 }
56
57 sub dbr_filename ($$) {
58     my ($datadir,$oceanname) = @_;
59     return "$datadir/OCEAN-$oceanname.db";
60 }
61 sub dbr_connect ($$) {
62     my ($datadir,$ocean) = @_;
63     return db_connect_core(dbr_filename($datadir,$ocean));
64 }
65
66 sub db_connect_core ($) {
67     my ($fn)= @_;
68     my $opts = { AutoCommit=>0,
69                  RaiseError=>1, ShowErrorStatement=>1,
70                  sqlite_unicode=>1 };
71
72     # DBI now wants to start a transaction whenever we even say
73     # SELECT.  But this doesn't work if the DB is readonly.  We can
74     # work around this by setting autocommit, in which case there is
75     # no need for a transaction for read-only db commands.  Autocommit
76     # is (obviously) safe with readonly operations.  But callers in
77     # yarrg do not specify to us whether they intend to write.  So we
78     # decide, by looking at the file mode.  And as belt-and-braces we
79     # set sqlite's own readonly flag as well.
80     # http://stackoverflow.com/questions/30082008/attempt-to-write-a-readonly-database-but-im-not
81     # http://stackoverflow.com/questions/35208727/can-sqlite-db-files-be-made-read-only
82     # http://cpansearch.perl.org/src/ISHIGAKI/DBD-SQLite-1.39/Changes
83     # (see entry for 1.38_01)
84     # http://stackoverflow.com/questions/17793672/perl-dbi-treats-setting-sqlite-db-cache-size-as-a-write-operation-when-subclassi
85     # https://rt.cpan.org/Public/Bug/Display.html?id=56444#
86     my $readonly =
87         (access $fn, POSIX::W_OK) ? 0 :
88         ($! == EACCES) ? 1 :
89         ($! == ENOENT) ? 0 :
90         die "$fn access(,W_OK) $!";
91     if ($readonly) {
92         $opts->{sqlite_open_flags} = DBD::SQLite::OPEN_READONLY;
93         $opts->{AutoCommit}=1;
94     }
95
96     my $h= DBI->connect("dbi:SQLite:$fn",'','',$opts)
97         or die "$fn $DBI::errstr ?";
98     return $h;
99     # default timeout is 30s which is plenty
100 }
101
102 our $dbfn;
103 our $dbh;
104
105 sub db_setocean ($) {
106     my ($oceanname) = @_;
107     $dbfn= dbr_filename('.',$oceanname);
108 }
109 sub db_filename () {
110     return $dbfn;
111 }
112
113 sub db_onconflict (&) {
114     my ($conflictproc) = @_;
115     $dbh->{HandleError}= sub {
116         my ($emsg,$dbh,$val1) = @_;
117         my $native_ecode= $dbh->err();
118         &$conflictproc($emsg) if grep { $_ == $native_ecode } qw(5 6);
119         # 5==SQLITE_BUSY, 6==SQLITE_LOCKED according to the SQLite3
120         # API documentation, .../capi3ref.html#extended-result-codes.
121         return 0; # RaiseError happens next.
122     };
123 }
124
125 our $writerlockh;
126
127 sub db_writer () {
128     my $lockfn= "Writer.lock";
129     $writerlockh= new IO::File "$lockfn", "w" or die "$lockfn $!";
130
131     my $flockall= pack 's!s!LLLLLL', F_WRLCK, SEEK_SET, 0,0,0,0,0,0;
132     # should work everywhere to lock the whole file, provided that
133     # l_type and l_whence are `short int' and come first in that order,
134     # and that start, len and pid are no more than 64 bits each.
135
136     my $r= fcntl($writerlockh, F_SETLKW, $flockall);
137     $r or die "$lockfn fcntl $!";
138 }
139
140 sub db_connect () {
141     $dbh= db_connect_core($dbfn);
142 }
143
144 sub db_doall ($) {
145     foreach my $cmd (split /\;/, $_[0]) {
146         $dbh->do("$cmd;") if $cmd =~ m/\S/;
147     }
148 }
149
150 #---------- table dump helper ----------
151
152 sub dumptab_head ($$$) {
153     my ($fh,$w,$cols) = @_;
154     printf $fh "|%-${w}s", $_ foreach @$cols;  print $fh "|\n";
155     print $fh "+",('-'x$w)  foreach @$cols;    print $fh "+\n";
156 }
157
158 sub dumptab_row_hashref ($$$$) {
159     my ($fh,$w,$cols,$row) = @_;
160     printf $fh "|%-$w.${w}s",
161            (defined $row->{$_} ? $row->{$_} : 'NULL')
162         foreach @$cols;
163     print $fh "\n";
164 }
165
166 #---------- referential integrity constraints ----------
167
168 # SQLite doesn't support foreign key constraints so we do it by steam:
169
170 sub nooutput ($) {
171     my ($stmts) = @_;
172     my $ekindcount= 0;
173     my $letxt= '';
174     foreach my $stmt (split /\;/, $stmts) {
175         next unless $stmt =~ /\S/;
176
177         my $etxt= '';
178         $stmt =~ s/^([ \t]*\#.*)$/ $etxt .= $1."\n"; ''; /mge;
179         $etxt= $letxt unless length $etxt;
180         $letxt= $etxt;
181         
182         $stmt =~ s/^\s+//; $stmt =~ s/\s+$//;
183         my $sth= $dbh->prepare($stmt);
184         $sth->execute();
185         my $row;
186         my $ecount= 0;
187         my @cols= @{ $sth->{NAME_lc} };
188         my $w= 11;
189         while ($row= $sth->fetchrow_hashref) {
190             if (!$ecount++) {
191                 print STDERR "REFERENTIAL INTEGRITY ERROR\n";
192                 print STDERR "\n$etxt\n $stmt\n\n";
193                 dumptab_head(\*STDERR,$w,\@cols);
194             }
195             if ($ecount>5) { print STDERR "...\n"; last; }
196             dumptab_row_hashref(\*STDERR,$w,\@cols,$row);
197         }
198         next unless $ecount;
199         
200         $ekindcount++;
201         print STDERR "\n\n";
202     }
203     die "REFERENTIAL INTEGRITY ERRORS $ekindcount\n"
204         if $ekindcount;
205 }
206
207 sub db_check_referential_integrity ($) {
208     my ($full) = @_;
209     # non-full is done only for market data updates; it avoids
210     # detecting errors which are essentially missing metadata and
211     # old schemas, etc.
212
213     foreach my $bs (qw(buy sell)) {
214         nooutput(<<END);
215
216  # Every buy/sell must refer to an entry in commods, islands, and stalls:
217  SELECT * FROM $bs LEFT JOIN commods USING (commodid) WHERE commodname IS NULL;
218  SELECT * FROM $bs LEFT JOIN islands USING (islandid) WHERE islandname IS NULL;
219  SELECT * FROM $bs LEFT JOIN stalls USING (stallid, islandid)
220                                                       WHERE stallname IS NULL;
221
222  # Every buy/sell must be part of an upload:
223  SELECT * FROM $bs LEFT JOIN uploads USING (islandid) WHERE timestamp IS NULL;
224
225  # The islandid in stalls must be the same as the islandid in buy/sell:
226  SELECT * FROM $bs JOIN stalls USING (stallid)
227         WHERE $bs.islandid != stalls.islandid;
228
229 END
230     }
231
232     nooutput(<<END);
233
234  # Every stall and upload must refer to an island:
235  SELECT * FROM stalls LEFT JOIN islands USING (islandid)
236                                         WHERE islandname IS NULL;
237  SELECT * FROM uploads LEFT JOIN islands USING (islandid)
238                                          WHERE islandname IS NULL;
239
240 END
241     if ($full) {
242         foreach my $end (qw(aiid biid)) {
243             foreach my $tab (qw(dists routes)) {
244                 nooutput(<<END);
245
246  # Every row in dists and routes must refer to two existing rows in islands:
247  SELECT * FROM $tab d LEFT JOIN islands ON d.$end=islandid
248         WHERE islandname IS NULL;
249
250 END
251             }
252         }
253         nooutput(<<END);
254
255  # Every pair of islands must have an entry in dists:
256  SELECT * FROM islands ia JOIN islands ib LEFT JOIN dists
257         ON ia.islandid=aiid and ib.islandid=biid
258         WHERE dist IS NULL;
259
260  # Every commod must refers to a commodclass and vice versa:
261  SELECT * FROM commods LEFT JOIN commodclasses USING (commodclassid)
262         WHERE commodclass IS NULL;
263  SELECT * FROM commodclasses LEFT JOIN commods USING (commodclassid)
264         WHERE commodname IS NULL;
265
266  # Ordvals which are not commodclass ordvals are unique:
267  SELECT ordval,count(*),commodname,commodid,posinclass
268         FROM commods
269         WHERE posinclass > 0
270         GROUP BY ordval
271         HAVING count(*) > 1;
272
273  # For every class, posinclass is dense from 1 to maxposinclass,
274  # apart from the commods for which it is zero.
275  SELECT commodclass,commodclassid,posinclass,count(*)
276         FROM commods JOIN commodclasses USING (commodclassid)
277         WHERE posinclass > 0
278         GROUP BY commodclassid,posinclass
279         HAVING count(*) > 1;
280  SELECT commodclass,commodclassid,count(*)
281         FROM commods JOIN commodclasses USING (commodclassid)
282         WHERE posinclass > 0
283         GROUP BY commodclassid
284         HAVING count(*) != maxposinclass;
285  SELECT *
286         FROM commods JOIN commodclasses USING (commodclassid)
287         WHERE posinclass < 0 OR posinclass > maxposinclass;
288
289 END
290     }
291 }
292
293 sub db_chkcommit ($) {
294     my ($full) = @_;
295     db_check_referential_integrity($full);
296     $dbh->commit();
297 }
298
299 1;