openlp/openlp/core/lib/db.py

476 lines
20 KiB
Python
Raw Normal View History

2010-05-28 00:26:49 +00:00
# -*- coding: utf-8 -*-
2012-12-28 22:06:43 +00:00
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
2010-05-28 00:26:49 +00:00
###############################################################################
# OpenLP - Open Source Lyrics Projection #
# --------------------------------------------------------------------------- #
2015-12-31 22:46:06 +00:00
# Copyright (c) 2008-2016 OpenLP Developers #
2010-05-28 00:26:49 +00:00
# --------------------------------------------------------------------------- #
# This program is free software; you can redistribute it and/or modify it #
# under the terms of the GNU General Public License as published by the Free #
# Software Foundation; version 2 of the License. #
# #
# This program is distributed in the hope that it will be useful, but WITHOUT #
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
# more details. #
# #
# You should have received a copy of the GNU General Public License along #
# with this program; if not, write to the Free Software Foundation, Inc., 59 #
# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
###############################################################################
2012-10-21 13:16:22 +00:00
"""
The :mod:`db` module provides the core database functionality for OpenLP
"""
2010-06-12 23:00:14 +00:00
import logging
2010-06-18 01:26:01 +00:00
import os
2013-08-31 18:17:38 +00:00
from urllib.parse import quote_plus as urlquote
2011-08-25 09:02:59 +00:00
from sqlalchemy import Table, MetaData, Column, types, create_engine
2012-12-28 22:06:43 +00:00
from sqlalchemy.exc import SQLAlchemyError, InvalidRequestError, DBAPIError, OperationalError
2011-08-25 09:02:59 +00:00
from sqlalchemy.orm import scoped_session, sessionmaker, mapper
2011-05-27 07:54:49 +00:00
from sqlalchemy.pool import NullPool
from alembic.migration import MigrationContext
from alembic.operations import Operations
2013-10-13 21:07:28 +00:00
from openlp.core.common import AppLocation, Settings, translate
from openlp.core.lib.ui import critical_error_message_box
2013-10-13 13:51:13 +00:00
from openlp.core.utils import delete_file
2010-06-12 23:00:14 +00:00
log = logging.getLogger(__name__)
2013-02-01 19:58:18 +00:00
2014-10-06 19:10:03 +00:00
def init_db(url, auto_flush=True, auto_commit=False, base=None):
"""
Initialise and return the session and metadata for a database
2014-03-17 19:05:55 +00:00
:param url: The database to initialise connection with
:param auto_flush: Sets the flushing behaviour of the session
:param auto_commit: Sets the commit behaviour of the session
2014-10-06 19:10:03 +00:00
:param base: If using declarative, the base class to bind with
"""
2011-05-27 07:54:49 +00:00
engine = create_engine(url, poolclass=NullPool)
2014-10-06 19:10:03 +00:00
if base is None:
metadata = MetaData(bind=engine)
else:
base.metadata.bind = engine
metadata = None
2013-02-04 21:39:44 +00:00
session = scoped_session(sessionmaker(autoflush=auto_flush, autocommit=auto_commit, bind=engine))
return session, metadata
2010-05-28 00:26:49 +00:00
2011-08-25 09:02:59 +00:00
2015-01-31 21:52:02 +00:00
def get_db_path(plugin_name, db_file_name=None):
"""
Create a path to a database from the plugin name and database name
:param plugin_name: Name of plugin
:param db_file_name: File name of database
:return: The path to the database as type str
"""
if db_file_name is None:
return 'sqlite:///%s/%s.sqlite' % (AppLocation.get_section_data_path(plugin_name), plugin_name)
else:
return 'sqlite:///%s/%s' % (AppLocation.get_section_data_path(plugin_name), db_file_name)
def handle_db_error(plugin_name, db_file_name):
"""
Log and report to the user that a database cannot be loaded
:param plugin_name: Name of plugin
:param db_file_name: File name of database
:return: None
"""
db_path = get_db_path(plugin_name, db_file_name)
log.exception('Error loading database: %s', db_path)
critical_error_message_box(translate('OpenLP.Manager', 'Database Error'),
translate('OpenLP.Manager', 'OpenLP cannot load your database.\n\nDatabase: %s')
% db_path)
2014-10-06 19:10:03 +00:00
def init_url(plugin_name, db_file_name=None):
"""
Return the database URL.
:param plugin_name: The name of the plugin for the database creation.
:param db_file_name: The database file name. Defaults to None resulting in the plugin_name being used.
"""
settings = Settings()
settings.beginGroup(plugin_name)
db_type = settings.value('db type')
if db_type == 'sqlite':
2015-01-31 21:52:02 +00:00
db_url = get_db_path(plugin_name, db_file_name)
2014-10-06 19:10:03 +00:00
else:
db_url = '%s://%s:%s@%s/%s' % (db_type, urlquote(settings.value('db username')),
urlquote(settings.value('db password')),
urlquote(settings.value('db hostname')),
urlquote(settings.value('db database')))
settings.endGroup()
return db_url
def get_upgrade_op(session):
"""
Create a migration context and an operations object for performing upgrades.
2014-03-17 19:05:55 +00:00
:param session: The SQLAlchemy session object.
"""
context = MigrationContext.configure(session.bind.connect())
return Operations(context)
2011-08-25 09:02:59 +00:00
def upgrade_db(url, upgrade):
"""
Upgrade a database.
2014-03-11 19:01:09 +00:00
:param url: The url of the database to upgrade.
:param upgrade: The python module that contains the upgrade instructions.
2011-08-25 09:02:59 +00:00
"""
session, metadata = init_db(url)
2011-08-26 19:09:25 +00:00
class Metadata(BaseModel):
"""
Provides a class for the metadata table.
"""
pass
2014-03-18 21:03:53 +00:00
metadata_table = Table(
'metadata', metadata,
Column('key', types.Unicode(64), primary_key=True),
Column('value', types.UnicodeText(), default=None)
)
2011-08-25 09:02:59 +00:00
metadata_table.create(checkfirst=True)
mapper(Metadata, metadata_table)
2013-08-31 18:17:38 +00:00
version_meta = session.query(Metadata).get('version')
2011-08-25 09:02:59 +00:00
if version_meta is None:
# Tables have just been created - fill the version field with the most recent version
2015-02-18 21:13:12 +00:00
if session.query(Metadata).get('dbversion'):
version = 0
else:
version = upgrade.__version__
version_meta = Metadata.populate(key='version', value=version)
session.add(version_meta)
2015-02-18 21:13:12 +00:00
session.commit()
2011-08-25 09:02:59 +00:00
else:
version = int(version_meta.value)
if version > upgrade.__version__:
return version, upgrade.__version__
2011-08-25 09:02:59 +00:00
version += 1
try:
2013-08-31 18:17:38 +00:00
while hasattr(upgrade, 'upgrade_%d' % version):
log.debug('Running upgrade_%d', version)
2011-08-27 18:43:05 +00:00
try:
2013-08-31 18:17:38 +00:00
upgrade_func = getattr(upgrade, 'upgrade_%d' % version)
upgrade_func(session, metadata)
session.commit()
# Update the version number AFTER a commit so that we are sure the previous transaction happened
2013-08-31 18:17:38 +00:00
version_meta.value = str(version)
session.commit()
version += 1
except (SQLAlchemyError, DBAPIError):
2013-08-31 18:17:38 +00:00
log.exception('Could not run database upgrade script "upgrade_%s", upgrade process has been halted.',
version)
2011-08-27 18:43:05 +00:00
break
except (SQLAlchemyError, DBAPIError):
2013-08-31 18:17:38 +00:00
version_meta = Metadata.populate(key='version', value=int(upgrade.__version__))
session.commit()
upgrade_version = upgrade.__version__
version_meta = int(version_meta.value)
session.close()
return version_meta, upgrade_version
2011-08-25 09:02:59 +00:00
2011-08-26 19:09:25 +00:00
2010-06-18 01:26:01 +00:00
def delete_database(plugin_name, db_file_name=None):
"""
Remove a database file from the system.
2014-03-11 19:01:09 +00:00
:param plugin_name: The name of the plugin to remove the database for
:param db_file_name: The database file name. Defaults to None resulting in the plugin_name being used.
2010-06-18 01:26:01 +00:00
"""
if db_file_name:
2012-12-28 22:06:43 +00:00
db_file_path = os.path.join(AppLocation.get_section_data_path(plugin_name), db_file_name)
2010-06-18 01:26:01 +00:00
else:
2012-12-28 22:06:43 +00:00
db_file_path = os.path.join(AppLocation.get_section_data_path(plugin_name), plugin_name)
2011-01-14 18:58:47 +00:00
return delete_file(db_file_path)
2010-06-18 01:26:01 +00:00
2011-08-25 09:02:59 +00:00
2010-05-28 00:26:49 +00:00
class BaseModel(object):
"""
BaseModel provides a base object with a set of generic functions
"""
@classmethod
def populate(cls, **kwargs):
"""
Creates an instance of a class and populates it, returning the instance
"""
2011-02-07 22:07:48 +00:00
instance = cls()
2013-08-31 18:17:38 +00:00
for key, value in kwargs.items():
2011-05-07 14:14:34 +00:00
instance.__setattr__(key, value)
2011-02-07 22:07:48 +00:00
return instance
2010-06-12 23:00:14 +00:00
2013-02-01 19:58:18 +00:00
2010-06-12 23:00:14 +00:00
class Manager(object):
"""
Provide generic object persistence management
"""
2014-10-06 19:10:03 +00:00
def __init__(self, plugin_name, init_schema, db_file_name=None, upgrade_mod=None, session=None):
2010-06-12 23:00:14 +00:00
"""
Runs the initialisation process that includes creating the connection to the database and the tables if they do
not exist.
2010-06-12 23:00:14 +00:00
2014-03-11 19:01:09 +00:00
:param plugin_name: The name to setup paths and settings section names
:param init_schema: The init_schema function for this database
:param db_file_name: The upgrade_schema function for this database
:param upgrade_mod: The file name to use for this database. Defaults to None resulting in the plugin_name
being used.
2010-06-12 23:00:14 +00:00
"""
2010-10-28 17:02:28 +00:00
self.is_dirty = False
2011-12-03 12:51:40 +00:00
self.session = None
2014-10-06 19:10:03 +00:00
# See if we're using declarative_base with a pre-existing session.
log.debug('Manager: Testing for pre-existing session')
if session is not None:
log.debug('Manager: Using existing session')
2010-06-12 23:00:14 +00:00
else:
2014-10-06 19:10:03 +00:00
log.debug('Manager: Creating new session')
self.db_url = init_url(plugin_name, db_file_name)
if upgrade_mod:
2014-03-11 19:01:09 +00:00
try:
db_ver, up_ver = upgrade_db(self.db_url, upgrade_mod)
except (SQLAlchemyError, DBAPIError):
2015-01-31 21:52:02 +00:00
handle_db_error(plugin_name, db_file_name)
return
if db_ver > up_ver:
critical_error_message_box(
translate('OpenLP.Manager', 'Database Error'),
2013-02-01 19:58:18 +00:00
translate('OpenLP.Manager', 'The database being loaded was created in a more recent version of '
2013-12-24 08:56:50 +00:00
'OpenLP. The database is version %d, while OpenLP expects version %d. The database will '
'not be loaded.\n\nDatabase: %s') % (db_ver, up_ver, self.db_url)
)
return
try:
self.session = init_schema(self.db_url)
except (SQLAlchemyError, DBAPIError):
2015-01-31 21:52:02 +00:00
handle_db_error(plugin_name, db_file_name)
2010-06-12 23:00:14 +00:00
2010-07-20 15:22:05 +00:00
def save_object(self, object_instance, commit=True):
2010-06-12 23:00:14 +00:00
"""
Save an object to the database
2014-03-11 19:01:09 +00:00
:param object_instance: The object to save
:param commit: Commit the session with this object
2010-06-12 23:00:14 +00:00
"""
for try_count in range(3):
try:
self.session.add(object_instance)
if commit:
self.session.commit()
self.is_dirty = True
return True
except OperationalError:
# This exception clause is for users running MySQL which likes to terminate connections on its own
# without telling anyone. See bug #927473. However, other dbms can raise it, usually in a
# non-recoverable way. So we only retry 3 times.
2013-08-31 18:17:38 +00:00
log.exception('Probably a MySQL issue - "MySQL has gone away"')
self.session.rollback()
if try_count >= 2:
raise
except InvalidRequestError:
self.session.rollback()
2013-08-31 18:17:38 +00:00
log.exception('Object list save failed')
return False
except:
self.session.rollback()
raise
2010-06-12 23:00:14 +00:00
2010-11-16 06:19:23 +00:00
def save_objects(self, object_list, commit=True):
"""
Save a list of objects to the database
2014-03-11 19:01:09 +00:00
:param object_list: The list of objects to save
:param commit: Commit the session with this object
2010-11-16 06:19:23 +00:00
"""
for try_count in range(3):
try:
self.session.add_all(object_list)
if commit:
self.session.commit()
self.is_dirty = True
return True
except OperationalError:
# This exception clause is for users running MySQL which likes to terminate connections on its own
# without telling anyone. See bug #927473. However, other dbms can raise it, usually in a
# non-recoverable way. So we only retry 3 times.
2013-08-31 18:17:38 +00:00
log.exception('Probably a MySQL issue, "MySQL has gone away"')
self.session.rollback()
if try_count >= 2:
raise
except InvalidRequestError:
self.session.rollback()
2013-08-31 18:17:38 +00:00
log.exception('Object list save failed')
return False
except:
self.session.rollback()
raise
2010-11-16 06:19:23 +00:00
2010-06-18 01:26:01 +00:00
def get_object(self, object_class, key=None):
2010-06-12 23:00:14 +00:00
"""
Return the details of an object
2014-03-11 19:01:09 +00:00
:param object_class: The type of object to return
:param key: The unique reference or primary key for the instance to return
2010-06-12 23:00:14 +00:00
"""
2010-06-18 01:26:01 +00:00
if not key:
2010-06-12 23:00:14 +00:00
return object_class()
else:
for try_count in range(3):
try:
return self.session.query(object_class).get(key)
except OperationalError:
# This exception clause is for users running MySQL which likes to terminate connections on its own
# without telling anyone. See bug #927473. However, other dbms can raise it, usually in a
# non-recoverable way. So we only retry 3 times.
2013-08-31 18:17:38 +00:00
log.exception('Probably a MySQL issue, "MySQL has gone away"')
if try_count >= 2:
raise
2010-06-12 23:00:14 +00:00
2010-06-30 22:05:51 +00:00
def get_object_filtered(self, object_class, filter_clause):
2010-06-15 18:08:02 +00:00
"""
Returns an object matching specified criteria
2014-03-11 19:01:09 +00:00
:param object_class: The type of object to return
:param filter_clause: The criteria to select the object by
2010-06-15 18:08:02 +00:00
"""
for try_count in range(3):
try:
return self.session.query(object_class).filter(filter_clause).first()
except OperationalError:
# This exception clause is for users running MySQL which likes to terminate connections on its own
# without telling anyone. See bug #927473. However, other dbms can raise it, usually in a
# non-recoverable way. So we only retry 3 times.
2013-08-31 18:17:38 +00:00
log.exception('Probably a MySQL issue, "MySQL has gone away"')
if try_count >= 2:
raise
2010-06-15 18:08:02 +00:00
2013-03-16 16:59:10 +00:00
def get_all_objects(self, object_class, filter_clause=None, order_by_ref=None):
2010-06-15 02:08:22 +00:00
"""
2010-07-18 23:37:24 +00:00
Returns all the objects from the database
2010-06-15 02:08:22 +00:00
2014-03-11 19:01:09 +00:00
:param object_class: The type of objects to return
:param filter_clause: The filter governing selection of objects to return. Defaults to None.
:param order_by_ref: Any parameters to order the returned objects by. Defaults to None.
2010-06-15 02:08:22 +00:00
"""
2010-07-18 23:37:24 +00:00
query = self.session.query(object_class)
2010-08-02 02:53:14 +00:00
if filter_clause is not None:
2010-07-18 23:37:24 +00:00
query = query.filter(filter_clause)
if isinstance(order_by_ref, list):
query = query.order_by(*order_by_ref)
elif order_by_ref is not None:
query = query.order_by(order_by_ref)
for try_count in range(3):
try:
return query.all()
except OperationalError:
# This exception clause is for users running MySQL which likes to terminate connections on its own
# without telling anyone. See bug #927473. However, other dbms can raise it, usually in a
# non-recoverable way. So we only retry 3 times.
2013-08-31 18:17:38 +00:00
log.exception('Probably a MySQL issue, "MySQL has gone away"')
if try_count >= 2:
raise
2010-06-15 02:08:22 +00:00
2010-11-16 06:19:23 +00:00
def get_object_count(self, object_class, filter_clause=None):
"""
Returns a count of the number of objects in the database.
2014-03-11 19:01:09 +00:00
:param object_class: The type of objects to return.
:param filter_clause: The filter governing selection of objects to return. Defaults to None.
2010-11-16 06:19:23 +00:00
"""
query = self.session.query(object_class)
if filter_clause is not None:
query = query.filter(filter_clause)
for try_count in range(3):
try:
return query.count()
except OperationalError:
# This exception clause is for users running MySQL which likes to terminate connections on its own
# without telling anyone. See bug #927473. However, other dbms can raise it, usually in a
# non-recoverable way. So we only retry 3 times.
2013-08-31 18:17:38 +00:00
log.exception('Probably a MySQL issue, "MySQL has gone away"')
if try_count >= 2:
raise
2010-11-16 06:19:23 +00:00
2010-06-18 01:26:01 +00:00
def delete_object(self, object_class, key):
2010-06-12 23:00:14 +00:00
"""
Delete an object from the database
2014-03-11 19:01:09 +00:00
:param object_class: The type of object to delete
:param key: The unique reference or primary key for the instance to be deleted
2010-06-12 23:00:14 +00:00
"""
2010-06-18 01:26:01 +00:00
if key != 0:
object_instance = self.get_object(object_class, key)
for try_count in range(3):
try:
self.session.delete(object_instance)
self.session.commit()
self.is_dirty = True
return True
except OperationalError:
# This exception clause is for users running MySQL which likes to terminate connections on its own
# without telling anyone. See bug #927473. However, other dbms can raise it, usually in a
# non-recoverable way. So we only retry 3 times.
2013-08-31 18:17:38 +00:00
log.exception('Probably a MySQL issue, "MySQL has gone away"')
self.session.rollback()
if try_count >= 2:
raise
except InvalidRequestError:
self.session.rollback()
2013-08-31 18:17:38 +00:00
log.exception('Failed to delete object')
return False
except:
self.session.rollback()
raise
2010-06-12 23:00:14 +00:00
else:
return True
2010-07-18 23:37:24 +00:00
def delete_all_objects(self, object_class, filter_clause=None):
2010-06-12 23:00:14 +00:00
"""
Delete all object records. This method should only be used for simple tables and **not** ones with
2013-03-22 20:35:59 +00:00
relationships. The relationships are not deleted from the database and this will lead to database corruptions.
2010-06-12 23:00:14 +00:00
2014-03-11 19:01:09 +00:00
:param object_class: The type of object to delete
:param filter_clause: The filter governing selection of objects to return. Defaults to None.
2010-06-12 23:00:14 +00:00
"""
for try_count in range(3):
try:
query = self.session.query(object_class)
if filter_clause is not None:
query = query.filter(filter_clause)
query.delete(synchronize_session=False)
self.session.commit()
self.is_dirty = True
return True
except OperationalError:
# This exception clause is for users running MySQL which likes to terminate connections on its own
# without telling anyone. See bug #927473. However, other dbms can raise it, usually in a
# non-recoverable way. So we only retry 3 times.
2013-08-31 18:17:38 +00:00
log.exception('Probably a MySQL issue, "MySQL has gone away"')
self.session.rollback()
if try_count >= 2:
raise
except InvalidRequestError:
self.session.rollback()
2013-08-31 18:17:38 +00:00
log.exception('Failed to delete %s records', object_class.__name__)
return False
except:
self.session.rollback()
raise
def finalise(self):
"""
VACUUM the database on exit.
"""
2010-10-28 17:02:28 +00:00
if self.is_dirty:
engine = create_engine(self.db_url)
2013-08-31 18:17:38 +00:00
if self.db_url.startswith('sqlite'):
2011-01-14 18:58:47 +00:00
engine.execute("vacuum")