File indexing completed on 2024-04-21 07:49:03

0001 # -*- coding: utf-8 -*-
0002 
0003 """
0004 Copyright (C) 2008-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de>
0005 
0006 partially based on C++ code from:
0007  - Copyright (C) 2006 Mauricio Piacentini <mauricio@tabuleiro.com>
0008 
0009 SPDX-License-Identifier: GPL-2.0
0010 
0011 """
0012 
0013 import os
0014 import traceback
0015 import time
0016 import datetime
0017 import random
0018 from collections import defaultdict
0019 import sqlite3
0020 
0021 from mi18n import i18n, i18ncE
0022 from util import Duration
0023 from log import logInfo, logWarning, logException, logError, logDebug, id4
0024 from common import IntDict, Options, Internal, Debug, appdataDir
0025 
0026 class QueryException(Exception):
0027 
0028     """as the name says"""
0029 
0030     def __init__(self, msg):
0031         Exception.__init__(self, msg)
0032 
0033 
0034 class DBCursor(sqlite3.Cursor):
0035 
0036     """logging wrapper"""
0037 
0038     # pylint: disable=no-member
0039 
0040     def __init__(self, dbHandle):
0041         sqlite3.Cursor.__init__(self, dbHandle)
0042         self.statement = None
0043         self.parameters = None
0044         self.failure = None
0045 
0046     def execute(self, statement, parameters=None,
0047                 silent=False, failSilent=False, mayFail=False):
0048         """logging wrapper, returning all selected data"""
0049         # pylint: disable=too-many-branches
0050         self.statement = statement
0051         self.parameters = parameters
0052         if not silent:
0053             logDebug(str(self))
0054         try:
0055             for _ in range(10):
0056                 try:
0057                     with Duration(statement, 60.0 if Debug.neutral else 2.0):
0058                         if isinstance(parameters, list):
0059                             sqlite3.Cursor.executemany(
0060                                 self, statement, parameters)
0061                         elif parameters:
0062                             sqlite3.Cursor.execute(self, statement, parameters)
0063                         else:
0064                             sqlite3.Cursor.execute(self, statement)
0065                     break
0066                 except sqlite3.OperationalError as exc:
0067                     logDebug(
0068                         '{} failed after {} tries:{}'.format(self, _, ' '.join(exc.args)))
0069                     time.sleep(1)
0070                 else:
0071                     break
0072             else:
0073                 raise sqlite3.OperationalError(
0074                     'Failed after 10 tries:{}'.format(self))
0075             self.failure = None
0076         except sqlite3.Error as exc:
0077             self.failure = exc
0078             msg = 'ERROR in %s: %s for %s' % (
0079                 self.connection.path,
0080                 exc.message if hasattr(exc, 'message') else '',
0081                 self)
0082             if mayFail:
0083                 if not failSilent:
0084                     logDebug(msg)
0085             else:
0086                 if not failSilent:
0087                     logError(msg)
0088                 raise QueryException(msg) from exc
0089             return
0090 
0091     def __str__(self):
0092         """the statement"""
0093         if self.parameters is not None:
0094             return "{cmd} [{args}]".format(cmd=self.statement, args=self.parameters)
0095         return self.statement
0096 
0097 
0098 class DBHandle(sqlite3.Connection):
0099 
0100     """a handle with our preferred configuration"""
0101 
0102     # pylint: disable=no-member
0103 
0104     def __init__(self, path: str):
0105         assert Internal.db is None, id(self)
0106         Internal.db = self
0107         self.inTransaction = None
0108         self.path = path
0109         self.identifier = None
0110         try:
0111             sqlite3.Connection.__init__(self, self.path, timeout=10.0)
0112         except sqlite3.Error as exc:
0113             if hasattr(exc, 'message'):
0114                 msg = exc.message
0115             elif hasattr(exc, 'args'):
0116                 msg = ' '.join(exc.args)
0117             else:
0118                 msg = ''
0119             logException('opening %s: %s' % (self.path, msg))
0120         if self.hasTable('general'):
0121             cursor = self.cursor()
0122             cursor.execute('select ident from general')
0123             self.identifier = cursor.fetchone()[0]
0124         if Debug.sql:
0125             logDebug('Opened %s with identifier %s' % (
0126                 self.path, self.identifier))
0127 
0128     def __enter__(self):
0129         self.inTransaction = datetime.datetime.now()
0130         if Debug.sql:
0131             logDebug('starting transaction')
0132         return sqlite3.Connection.__enter__(self)
0133 
0134     def __exit__(self, *args):
0135         self.inTransaction = None
0136         sqlite3.Connection.__exit__(self, *args)
0137         if Debug.sql:
0138             logDebug('finished transaction')
0139 
0140     @staticmethod
0141     def dbPath():
0142         """
0143         The path for the data base.
0144 
0145         @return: The full path for kajonggserver.db or kajongg.db.
0146         @rtype: C{str}
0147         """
0148         name = 'kajonggserver' if Internal.isServer else 'kajongg'
0149         name += '3.db'
0150         return Options.dbPath if Options.dbPath else os.path.join(appdataDir(), name)
0151 
0152     @property
0153     def name(self):
0154         """get name for log messages. Readonly."""
0155         stack = [x[2] for x in traceback.extract_stack()]
0156         name = stack[-3]
0157         if name in ('__exit__', '__init__'):
0158             name = stack[-4]
0159         return '%s on %s (%x)' % (name, self.path, id4(self))
0160 
0161     def commit(self, silent=None):
0162         """commit and log it"""
0163         try:
0164             sqlite3.Connection.commit(self)
0165         except sqlite3.Error as exc:
0166             if not silent:
0167                 logWarning(
0168                     '%s cannot commit: %s :' %
0169                     (self.name, exc.message))
0170 
0171     def rollback(self, silent=None):
0172         """rollback and log it"""
0173         try:
0174             sqlite3.Connection.rollback(self)
0175             if not silent and Debug.sql:
0176                 logDebug('%x rollbacked transaction' % id(self))
0177         except sqlite3.Error as exc:
0178             logWarning('%s cannot rollback: %s' % (self.name, exc.message))
0179 
0180     def close(self, silent=False):
0181         """just for logging"""
0182         if not silent and (Debug.sql or Debug.quit):
0183             if self is Internal.db:
0184                 logDebug('Closing Internal.db: %s' % self.path)
0185             else:
0186                 logDebug('Closing DBHandle %s: %s' % (self, self.path))
0187         if self is Internal.db:
0188             Internal.db = None
0189         try:
0190             self.commit(silent=True)
0191         except sqlite3.Error:
0192             self.rollback()
0193         try:
0194             sqlite3.Connection.close(self)
0195         except sqlite3.Error as exc:
0196             logDebug(exc)
0197 
0198     @staticmethod
0199     def hasTable(table):
0200         """does the table contain table?"""
0201         return len(Query('SELECT name FROM sqlite_master WHERE type="table" AND name="%s"' % table).records) > 0
0202 
0203     def tableHasField(self, table, field):
0204         """does the table contain a column named field?"""
0205         cursor = self.cursor()
0206         cursor.execute('select * from %s' % table)
0207         return any(x[0] == field for x in cursor.description)
0208 
0209 
0210 class Query:
0211 
0212     """a wrapper arout python sqlite3, adding logging and some exception handling.
0213     For selecting queries we fill a list with ALL records which is never much.
0214     Every record is a list of all fields. q.records[0][1] is record 0, field 1.
0215     """
0216 
0217     localServerName = i18ncE(
0218         'kajongg name for local game server',
0219         'Local Game')
0220 
0221     def __init__(self, statement, args=None,
0222                  silent=False, mayFail=False, failSilent=False):
0223         """we take one sql statement.
0224         Do prepared queries by passing the parameters in args.
0225         If args is a list of lists, execute the prepared query for every sublist.
0226         Use Internal.db for db access.
0227         Else if the default dbHandle (Internal.db) is defined, use it."""
0228         # pylint: disable=too-many-branches
0229         silent |= not Debug.sql
0230         self.msg = None
0231         self.records = []
0232         self.statement = statement
0233         self.args = args
0234         if Internal.db:
0235             self.cursor = Internal.db.cursor(
0236                 DBCursor)  # pylint: disable=no-member
0237             self.cursor.execute(
0238                 statement,
0239                 args,
0240                 silent=silent,
0241                 mayFail=mayFail,
0242                 failSilent=failSilent)
0243             self.failure = self.cursor.failure
0244             self.records = list(self.cursor.fetchall())
0245             if not Internal.db.inTransaction:
0246                 Internal.db.commit()
0247         else:
0248             # may happen at shutdown
0249             self.cursor = None
0250             self.failure = None
0251             self.records = list()
0252         if self.records and Debug.sql:
0253             logDebug('result set:{}'.format(self.records))
0254 
0255     def __str__(self):
0256         return '{} {}'.format(self.statement,
0257                               'args=' + ','.join(str(x) for x in self.args) if self.args else '')
0258 
0259     def rowcount(self):
0260         """how many rows were affected?"""
0261         return self.cursor.rowcount if self.cursor else 0
0262 
0263 
0264 def initDb():
0265     """open the db, create or update it if needed.
0266     sets Internal.db."""
0267     PrepareDB(DBHandle.dbPath())  # create or upgrade
0268     DBHandle(DBHandle.dbPath())
0269 # if not Internal.db = DBHandle.default:  # had to create it. Close and reopen
0270 #        Internal.db = DBHandle()
0271 #        assert Internal.db = DBHandle.default
0272 #    except sqlite3.Error as exc:
0273 #        logException(exc)
0274 #        return False
0275     return True
0276 
0277 
0278 class PrepareDB:
0279 
0280     """create or upgrade DB if needed"""
0281     schema = {}
0282     schema['player'] = """
0283         id INTEGER PRIMARY KEY,
0284         name TEXT unique"""
0285     schema['game'] = """
0286             id integer primary key,
0287             seed text,
0288             autoplay integer default 0,
0289             starttime text default current_timestamp,
0290             endtime text,
0291             ruleset integer references ruleset(id),
0292             p0 integer constraint fk_p0 references player(id),
0293             p1 integer constraint fk_p1 references player(id),
0294             p2 integer constraint fk_p2 references player(id),
0295             p3 integer constraint fk_p3 references player(id)"""
0296     schema['score'] = """
0297             game integer constraint fk_game references game(id),
0298             hand integer,
0299             data text,
0300             manualrules text,
0301             rotated integer,
0302             notrotated integer,
0303             player integer constraint fk_player references player(id),
0304             scoretime text,
0305             won integer,
0306             penalty integer default 0,
0307             prevailing text,
0308             wind text,
0309             points integer,
0310             payments integer,
0311             balance integer"""
0312     schema['ruleset'] = """
0313             id integer primary key,
0314             name text,
0315             hash text,
0316             description text"""
0317     schema['rule'] = """
0318             ruleset integer,
0319             list integer,
0320             position integer,
0321             name text,
0322             definition text,
0323             points text,
0324             doubles text,
0325             limits text,
0326             parameter text,
0327             primary key(ruleset,list,position)"""
0328     schema['server'] = """
0329                 url text,
0330                 lastname text,
0331                 lasttime text,
0332                 lastruleset integer,
0333                 primary key(url)"""
0334     schema['passwords'] = """
0335                 url text,
0336                 player integer,
0337                 password text"""
0338     schema['general'] = """
0339                 ident text,
0340                 schemaversion text"""
0341 
0342     def __init__(self, path):
0343         self.path = path
0344         if not os.path.exists(path):
0345             self.__create()
0346         else:
0347             self.__upgrade()
0348 
0349     def __create(self):
0350         """create a brand new kajongg database"""
0351         tmpPath = '%s.new.%d' % (self.path, os.getpid())
0352         Internal.db = DBHandle(tmpPath)
0353         try:
0354             with Internal.db:
0355                 self.createTables()
0356                 self.__generateDbIdent()
0357                 Query(
0358                     'UPDATE general SET schemaversion=?', (Internal.defaultPort,))
0359         finally:
0360             Internal.db.close(silent=True)
0361         if os.path.exists(self.path):
0362             # somebody was faster
0363             os.remove(tmpPath)
0364         else:
0365             os.rename(tmpPath, self.path)
0366 
0367     @staticmethod
0368     def __currentVersion():
0369         """
0370         Get current version of DB schema as a comparable string.
0371 
0372         @returns: The current version from the database.
0373         @rtype: C{str}
0374         """
0375         if Internal.db.tableHasField('general', 'schemaversion'):
0376             return Query('select schemaversion from general').records[0][0]
0377         return '1.1.1'
0378 
0379     def __upgrade(self):
0380         """upgrade the structure of an existing kajongg database"""
0381         try:
0382             Internal.db = DBHandle(self.path)
0383             allVersions = list(['4.13.0', '8300', '8301'])
0384             assert allVersions[-1] == str(Internal.defaultPort), '{} != {}'.format(
0385                 allVersions[-1], str(Internal.defaultPort))
0386             # skip versions before current db versions:
0387             currentVersion = self.__currentVersion()
0388             while allVersions and allVersions[0] <= currentVersion:
0389                 allVersions = allVersions[1:]
0390             for version in allVersions:
0391                 currentVersion = self.__currentVersion()
0392                 with Internal.db:  # transaction
0393                     updateMethodName = 'updateToVersion{}'.format(version.replace('.', '_'))
0394                     if hasattr(self, updateMethodName):
0395                         getattr(self, updateMethodName)()
0396                     Query('UPDATE general SET schemaversion=?', (version,))
0397                 logInfo(i18n('Database %1 updated from schema %2 to %3',
0398                              Internal.db.path, currentVersion, version), showDialog=True)
0399         except sqlite3.Error as exc:
0400             logException('opening %s: %s' % (self.path, exc.message))
0401         finally:
0402             Internal.db.close(silent=True)
0403 
0404     def updateToVersion4_13_0(self):
0405         """as the name says"""
0406         self.__upgradeFromLegacy()
0407         # add general.schemaversion
0408         Query('ALTER TABLE general add schemaversion text')
0409         Query('UPDATE general set schemaversion="001.001.001"')
0410         # this makes finding suspended games much faster in the presence
0411         # of many test games (with autoplay=1)
0412         self.createIndex('idxautoplay', 'game(autoplay)')
0413 
0414     @classmethod
0415     def sqlForCreateTable(cls, table):
0416         """the SQL command for creating 'table'"""
0417         return "create table %s(%s)" % (table, cls.schema[table])
0418 
0419     @classmethod
0420     def createTable(cls, table):
0421         """create a single table using the predefined schema"""
0422         if not Internal.db.hasTable(table):
0423             Query(cls.sqlForCreateTable(table))
0424 
0425     @classmethod
0426     def createTables(cls):
0427         """creates empty tables"""
0428         for table in ['player', 'game', 'score', 'ruleset', 'rule', 'general']:
0429             cls.createTable(table)
0430         cls.createIndex('idxgame', 'score(game)')
0431         # this makes finding suspended games much faster in the presence
0432         # of many test games (with autoplay=1)
0433         cls.createIndex('idxautoplay', 'game(autoplay)')
0434 
0435         if Internal.isServer:
0436             Query('ALTER TABLE player add password text')
0437         else:
0438             cls.createTable('passwords')
0439             cls.createTable('server')
0440 
0441     @staticmethod
0442     def createIndex(name, cmd):
0443         """only try to create it if it does not yet exist. Do not use create if not exists because
0444         we want debug output only if we really create the index"""
0445         if not Query(
0446                 "select 1 from sqlite_master where type='index' and name=?", (
0447                     name,),
0448                 silent=True).records:
0449             Query("create index %s on %s" % (name, cmd))
0450 
0451     def cleanPlayerTable(self):
0452         """remove now unneeded columns host, password and make names unique"""
0453         playerCounts = IntDict()
0454         names = {}
0455         keep = {}
0456         for nameId, name in Query('select id,name from player').records:
0457             playerCounts[name] += 1
0458             names[int(nameId)] = name
0459         for name, counter in defaultdict.items(playerCounts):
0460             nameIds = [x[0] for x in names.items() if x[1] == name]
0461             keepId = nameIds[0]
0462             keep[keepId] = name
0463             if counter > 1:
0464                 for nameId in nameIds[1:]:
0465                     Query(
0466                         'update score set player=%d where player=%d' %
0467                         (keepId, nameId))
0468                     Query(
0469                         'update game set p0=%d where p0=%d' %
0470                         (keepId, nameId))
0471                     Query(
0472                         'update game set p1=%d where p1=%d' %
0473                         (keepId, nameId))
0474                     Query(
0475                         'update game set p2=%d where p2=%d' %
0476                         (keepId, nameId))
0477                     Query(
0478                         'update game set p3=%d where p3=%d' %
0479                         (keepId, nameId))
0480                     Query('delete from player where id=%d' % nameId)
0481         Query('drop table player')
0482         self.createTable('player')
0483         for nameId, name in keep.items():
0484             Query('insert into player(id,name) values(?,?)', (nameId, name))
0485 
0486     @classmethod
0487     def removeGameServer(cls):
0488         """drops column server from table game. Sqlite3 cannot drop columns"""
0489         Query('create table gameback(%s)' % cls.schema['game'])
0490         Query('insert into gameback '
0491               'select id,seed,autoplay,starttime,endtime,ruleset,p0,p1,p2,p3 from game')
0492         Query('drop table game')
0493         Query('create table game(%s)' % cls.schema['game'])
0494         Query('insert into game '
0495               'select id,seed,autoplay,starttime,endtime,ruleset,p0,p1,p2,p3 from gameback')
0496         Query('drop table gameback')
0497 
0498     def removeUsedRuleset(self):
0499         """eliminate usedruleset and usedrule"""
0500         if Internal.db.hasTable('usedruleset'):
0501             if Internal.db.hasTable('ruleset'):
0502                 Query('UPDATE ruleset set id=-id where id>0')
0503                 Query(
0504                     'INSERT OR IGNORE INTO usedruleset SELECT * FROM ruleset')
0505                 Query('DROP TABLE ruleset')
0506             Query('ALTER TABLE usedruleset RENAME TO ruleset')
0507         if Internal.db.hasTable('usedrule'):
0508             if Internal.db.hasTable('rule'):
0509                 Query('UPDATE rule set ruleset=-ruleset where ruleset>0')
0510                 Query('INSERT OR IGNORE INTO usedrule SELECT * FROM rule')
0511                 Query('DROP TABLE rule')
0512             Query('ALTER TABLE usedrule RENAME TO rule')
0513         query = Query("select count(1) from sqlite_master "
0514                       "where type='table' and tbl_name='ruleset' and sql like '%name text unique,%'", silent=True)
0515         if int(query.records[0][0]):
0516             # make name non-unique. Needed for used rulesets: Content may change with identical name
0517             # and we now have both ruleset templates and copies of used
0518             # rulesets in the same table
0519             for statement in list([
0520                     'create table temp(%s)' % self.schema['ruleset'],
0521                     'insert into temp select id,name,hash,description from ruleset',
0522                     'drop table ruleset',
0523                     self.sqlForCreateTable('ruleset'),
0524                     'insert into ruleset select * from temp',
0525                     'drop table temp']):
0526                 Query(statement)
0527         query = Query("select count(1) from sqlite_master "
0528                       "where type='table' and tbl_name='rule' and sql like '%unique (ruleset,name)%'", silent=True)
0529         if int(query.records[0][0]):
0530             # make ruleset,name non-unique
0531             for statement in list([
0532                     'create table temp(%s)' % self.schema['rule'],
0533                     'insert into temp select * from rule',
0534                     'drop table rule',
0535                     self.sqlForCreateTable('rule'),
0536                     'insert into rule select * from temp',
0537                     'drop table temp']):
0538                 Query(statement)
0539 
0540     def __upgradeFromLegacy(self):
0541         """upgrade versions prior to 4.13.0"""
0542         self.createIndex('idxgame', 'score(game)')
0543         if not Internal.db.tableHasField('game', 'autoplay'):
0544             Query('ALTER TABLE game add autoplay integer default 0')
0545         if not Internal.db.tableHasField('score', 'penalty'):
0546             Query('ALTER TABLE score add penalty integer default 0')
0547             Query("UPDATE score SET penalty=1 WHERE manualrules LIKE "
0548                   "'False Naming%' OR manualrules LIKE 'False Decl%'")
0549         if Internal.db.tableHasField('player', 'host'):
0550             self.cleanPlayerTable()
0551         if Internal.isServer:
0552             if not Internal.db.tableHasField('player', 'password'):
0553                 Query('ALTER TABLE player add password text')
0554         else:
0555             self.createTable('passwords')
0556             if not Internal.db.tableHasField('server', 'lastruleset'):
0557                 Query('alter table server add lastruleset integer')
0558         if Internal.db.tableHasField('game', 'server'):
0559             self.removeGameServer()
0560         if not Internal.db.tableHasField('score', 'notrotated'):
0561             Query('ALTER TABLE score add notrotated integer default 0')
0562         self.removeUsedRuleset()
0563 
0564     @staticmethod
0565     def __generateDbIdent():
0566         """make sure the database has a unique ident and get it"""
0567         records = Query('select ident from general').records
0568         assert len(records) < 2
0569         if not records:
0570             dbIdent = str(random.randrange(100000000000))
0571             Query("INSERT INTO general(ident) values(?)", (dbIdent,))
0572             if Debug.sql:
0573                 logDebug(
0574                     'generated new dbIdent %s for %s' %
0575                     (dbIdent, Internal.db.path))