1 # This is part of ypp-sc-tools, a set of third-party tools for assisting
2 # players of Yohoho Puzzle Pirates.
4 # Copyright (C) 2009 Ian Jackson <ijackson@chiark.greenend.org.uk>
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.
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.
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/>.
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.
23 package CommodsDatabase;
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
30 # [ db_onconflict(sub { .... }) ] essential if just dieing is not OK
31 # $dbh->do(...), $dbh->prepare(...), db_doall("stmt;stmt;"), etc.
44 our ($VERSION, @ISA, @EXPORT, @EXPORT_OK, %EXPORT_TAGS);
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);
57 sub dbr_filename ($$) {
58 my ($datadir,$oceanname) = @_;
59 return "$datadir/OCEAN-$oceanname.db";
61 sub dbr_connect ($$) {
62 my ($datadir,$ocean) = @_;
63 return db_connect_core(dbr_filename($datadir,$ocean));
66 sub db_connect_core ($) {
68 my $opts = { AutoCommit=>0,
69 RaiseError=>1, ShowErrorStatement=>1,
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#
87 (access $fn, POSIX::W_OK) ? 0 :
90 die "$fn access(,W_OK) $!";
92 $opts->{sqlite_open_flags} = DBD::SQLite::OPEN_READONLY;
93 $opts->{AutoCommit}=1;
96 my $h= DBI->connect("dbi:SQLite:$fn",'','',$opts)
97 or die "$fn $DBI::errstr ?";
99 # default timeout is 30s which is plenty
105 sub db_setocean ($) {
106 my ($oceanname) = @_;
107 $dbfn= dbr_filename('.',$oceanname);
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.
128 my $lockfn= "Writer.lock";
129 $writerlockh= new IO::File "$lockfn", "w" or die "$lockfn $!";
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.
136 my $r= fcntl($writerlockh, F_SETLKW, $flockall);
137 $r or die "$lockfn fcntl $!";
141 $dbh= db_connect_core($dbfn);
145 foreach my $cmd (split /\;/, $_[0]) {
146 $dbh->do("$cmd;") if $cmd =~ m/\S/;
150 #---------- table dump helper ----------
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";
158 sub dumptab_row_hashref ($$$$) {
159 my ($fh,$w,$cols,$row) = @_;
160 printf $fh "|%-$w.${w}s",
161 (defined $row->{$_} ? $row->{$_} : 'NULL')
166 #---------- referential integrity constraints ----------
168 # SQLite doesn't support foreign key constraints so we do it by steam:
174 foreach my $stmt (split /\;/, $stmts) {
175 next unless $stmt =~ /\S/;
178 $stmt =~ s/^([ \t]*\#.*)$/ $etxt .= $1."\n"; ''; /mge;
179 $etxt= $letxt unless length $etxt;
182 $stmt =~ s/^\s+//; $stmt =~ s/\s+$//;
183 my $sth= $dbh->prepare($stmt);
187 my @cols= @{ $sth->{NAME_lc} };
189 while ($row= $sth->fetchrow_hashref) {
191 print STDERR "REFERENTIAL INTEGRITY ERROR\n";
192 print STDERR "\n$etxt\n $stmt\n\n";
193 dumptab_head(\*STDERR,$w,\@cols);
195 if ($ecount>5) { print STDERR "...\n"; last; }
196 dumptab_row_hashref(\*STDERR,$w,\@cols,$row);
203 die "REFERENTIAL INTEGRITY ERRORS $ekindcount\n"
207 sub db_check_referential_integrity ($) {
209 # non-full is done only for market data updates; it avoids
210 # detecting errors which are essentially missing metadata and
213 foreach my $bs (qw(buy sell)) {
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;
222 # Every buy/sell must be part of an upload:
223 SELECT * FROM $bs LEFT JOIN uploads USING (islandid) WHERE timestamp IS NULL;
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;
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;
242 foreach my $end (qw(aiid biid)) {
243 foreach my $tab (qw(dists routes)) {
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;
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
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;
266 # Ordvals which are not commodclass ordvals are unique:
267 SELECT ordval,count(*),commodname,commodid,posinclass
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)
278 GROUP BY commodclassid,posinclass
280 SELECT commodclass,commodclassid,count(*)
281 FROM commods JOIN commodclasses USING (commodclassid)
283 GROUP BY commodclassid
284 HAVING count(*) != maxposinclass;
286 FROM commods JOIN commodclasses USING (commodclassid)
287 WHERE posinclass < 0 OR posinclass > maxposinclass;
293 sub db_chkcommit ($) {
295 db_check_referential_integrity($full);