| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | """ |
|---|
| 3 | zine.database |
|---|
| 4 | ~~~~~~~~~~~~~ |
|---|
| 5 | |
|---|
| 6 | This module is a rather complex layer on top of SQLAlchemy. |
|---|
| 7 | |
|---|
| 8 | Basically you will never use the `zine.database` module except if you |
|---|
| 9 | are a core developer, but always the high level :mod:`~zine.database.db` |
|---|
| 10 | module which you can import from the :mod:`zine.api` module. |
|---|
| 11 | |
|---|
| 12 | :copyright: (c) 2010 by the Zine Team, see AUTHORS for more details. |
|---|
| 13 | :license: BSD, see LICENSE for more details. |
|---|
| 14 | """ |
|---|
| 15 | import re |
|---|
| 16 | import os |
|---|
| 17 | import sys |
|---|
| 18 | import time |
|---|
| 19 | from os import path |
|---|
| 20 | from types import ModuleType |
|---|
| 21 | from copy import deepcopy |
|---|
| 22 | |
|---|
| 23 | import sqlalchemy |
|---|
| 24 | from sqlalchemy import orm |
|---|
| 25 | from sqlalchemy.interfaces import ConnectionProxy |
|---|
| 26 | from sqlalchemy.orm.interfaces import AttributeExtension |
|---|
| 27 | from sqlalchemy.exc import ArgumentError |
|---|
| 28 | from sqlalchemy.ext.declarative import declarative_base |
|---|
| 29 | from sqlalchemy.engine.url import make_url, URL |
|---|
| 30 | from sqlalchemy.types import TypeDecorator |
|---|
| 31 | from sqlalchemy.ext.associationproxy import association_proxy |
|---|
| 32 | |
|---|
| 33 | from werkzeug import url_decode |
|---|
| 34 | from werkzeug.exceptions import NotFound |
|---|
| 35 | |
|---|
| 36 | from zine.utils import local_manager |
|---|
| 37 | |
|---|
| 38 | |
|---|
| 39 | if sys.platform == 'win32': |
|---|
| 40 | _timer = time.clock |
|---|
| 41 | else: |
|---|
| 42 | _timer = time.time |
|---|
| 43 | |
|---|
| 44 | |
|---|
| 45 | _sqlite_re = re.compile(r'sqlite:(?:(?://(.*?))|memory)(?:\?(.*))?$') |
|---|
| 46 | |
|---|
| 47 | |
|---|
| 48 | def get_engine(): |
|---|
| 49 | """Return the active database engine (the database engine of the active |
|---|
| 50 | application). If no application is enabled this has an undefined behavior. |
|---|
| 51 | If you are not sure if the application is bound to the active thread, use |
|---|
| 52 | :func:`~zine.application.get_application` and check it for `None`. |
|---|
| 53 | The database engine is stored on the application object as `database_engine`. |
|---|
| 54 | """ |
|---|
| 55 | from zine.application import get_application |
|---|
| 56 | return get_application().database_engine |
|---|
| 57 | |
|---|
| 58 | |
|---|
| 59 | def create_engine(uri, relative_to=None, debug=False): |
|---|
| 60 | """Create a new engine. This works a bit like SQLAlchemy's |
|---|
| 61 | `create_engine` with the difference that it automaticaly set's MySQL |
|---|
| 62 | engines to 'utf-8', and paths for SQLite are relative to the path |
|---|
| 63 | provided as `relative_to`. |
|---|
| 64 | |
|---|
| 65 | Furthermore the engine is created with `convert_unicode` by default. |
|---|
| 66 | """ |
|---|
| 67 | # special case sqlite. We want nicer urls for that one. |
|---|
| 68 | if uri.startswith('sqlite:'): |
|---|
| 69 | match = _sqlite_re.match(uri) |
|---|
| 70 | if match is None: |
|---|
| 71 | raise ArgumentError('Could not parse rfc1738 URL') |
|---|
| 72 | database, query = match.groups() |
|---|
| 73 | if database is None: |
|---|
| 74 | database = ':memory:' |
|---|
| 75 | elif relative_to is not None: |
|---|
| 76 | database = path.join(relative_to, database) |
|---|
| 77 | if query: |
|---|
| 78 | query = url_decode(query).to_dict() |
|---|
| 79 | else: |
|---|
| 80 | query = {} |
|---|
| 81 | info = URL('sqlite', database=database, query=query) |
|---|
| 82 | |
|---|
| 83 | else: |
|---|
| 84 | info = make_url(uri) |
|---|
| 85 | |
|---|
| 86 | # if mysql is the database engine and no connection encoding is |
|---|
| 87 | # provided we set it to utf-8 |
|---|
| 88 | if info.drivername == 'mysql': |
|---|
| 89 | info.query.setdefault('charset', 'utf8') |
|---|
| 90 | |
|---|
| 91 | options = {'convert_unicode': True} |
|---|
| 92 | |
|---|
| 93 | # alternative pool sizes / recycle settings and more. These are |
|---|
| 94 | # interpreter wide and not from the config for the following reasons: |
|---|
| 95 | # |
|---|
| 96 | # - system administrators can set it independently from the webserver |
|---|
| 97 | # configuration via SetEnv and friends. |
|---|
| 98 | # - this setting is deployment dependent should not affect a development |
|---|
| 99 | # server for the same instance or a development shell |
|---|
| 100 | for key in 'pool_size', 'pool_recycle', 'pool_timeout': |
|---|
| 101 | value = os.environ.get('ZINE_DATABASE_' + key.upper()) |
|---|
| 102 | if value is not None: |
|---|
| 103 | options[key] = int(value) |
|---|
| 104 | |
|---|
| 105 | # if debugging is enabled, hook the ConnectionDebugProxy in |
|---|
| 106 | if debug: |
|---|
| 107 | options['proxy'] = ConnectionDebugProxy() |
|---|
| 108 | |
|---|
| 109 | return sqlalchemy.create_engine(info, **options) |
|---|
| 110 | |
|---|
| 111 | |
|---|
| 112 | def secure_database_uri(uri): |
|---|
| 113 | """Returns the database uri with confidental information stripped.""" |
|---|
| 114 | obj = make_url(uri) |
|---|
| 115 | if obj.password: |
|---|
| 116 | obj.password = '***' |
|---|
| 117 | return unicode(obj).replace(u':%2A%2A%2A@', u':***@', 1) |
|---|
| 118 | |
|---|
| 119 | |
|---|
| 120 | def attribute_loaded(model, attribute): |
|---|
| 121 | """Returns true if the attribute of the model was already loaded.""" |
|---|
| 122 | # XXX: this works but it relys on a specific implementation in |
|---|
| 123 | # SQLAlchemy. Figure out if SA provides a way to query that information. |
|---|
| 124 | return attribute in model.__dict__ |
|---|
| 125 | |
|---|
| 126 | |
|---|
| 127 | class ConnectionDebugProxy(ConnectionProxy): |
|---|
| 128 | """Helps debugging the database.""" |
|---|
| 129 | |
|---|
| 130 | def cursor_execute(self, execute, cursor, statement, parameters, |
|---|
| 131 | context, executemany): |
|---|
| 132 | start = _timer() |
|---|
| 133 | try: |
|---|
| 134 | return execute(cursor, statement, parameters, context) |
|---|
| 135 | finally: |
|---|
| 136 | from zine.application import get_request |
|---|
| 137 | from zine.utils.debug import find_calling_context |
|---|
| 138 | request = get_request() |
|---|
| 139 | if request is not None: |
|---|
| 140 | request.queries.append((statement, parameters, start, |
|---|
| 141 | _timer(), find_calling_context())) |
|---|
| 142 | |
|---|
| 143 | |
|---|
| 144 | class ZEMLParserData(TypeDecorator): |
|---|
| 145 | """Holds parser data. The implementation is rather ugly because it does |
|---|
| 146 | not really compare the trees for performance reasons but only the dirty |
|---|
| 147 | flags. This might change in future versions if there is some sort of |
|---|
| 148 | support for this kind of hackery in SQLAlchemy or if we find a fast way |
|---|
| 149 | to compare the trees. |
|---|
| 150 | """ |
|---|
| 151 | |
|---|
| 152 | impl = sqlalchemy.LargeBinary |
|---|
| 153 | |
|---|
| 154 | def process_bind_param(self, value, dialect): |
|---|
| 155 | if value is None: |
|---|
| 156 | return |
|---|
| 157 | from zine.utils.zeml import dump_parser_data |
|---|
| 158 | return dump_parser_data(value) |
|---|
| 159 | |
|---|
| 160 | def process_result_value(self, value, dialect): |
|---|
| 161 | from zine.utils.zeml import load_parser_data |
|---|
| 162 | try: |
|---|
| 163 | return load_parser_data(value) |
|---|
| 164 | except ValueError: # Parser data invalid. Database corruption? |
|---|
| 165 | from zine.i18n import _ |
|---|
| 166 | from zine.utils import log |
|---|
| 167 | log.exception(_(u'Error when loading parsed data from database. ' |
|---|
| 168 | u'Maybe the database was manually edited and got ' |
|---|
| 169 | u'corrupted? The system returned an empty value.')) |
|---|
| 170 | return {} |
|---|
| 171 | |
|---|
| 172 | def copy_value(self, value): |
|---|
| 173 | return deepcopy(value) |
|---|
| 174 | |
|---|
| 175 | def compare_values(self, x, y): |
|---|
| 176 | return x is y |
|---|
| 177 | |
|---|
| 178 | def is_mutable(self): |
|---|
| 179 | return False |
|---|
| 180 | |
|---|
| 181 | |
|---|
| 182 | class Query(orm.Query): |
|---|
| 183 | """Default query class.""" |
|---|
| 184 | |
|---|
| 185 | def lightweight(self, deferred=None, lazy=None): |
|---|
| 186 | """Send a lightweight query which deferes some more expensive |
|---|
| 187 | things such as comment queries or even text and parser data. |
|---|
| 188 | """ |
|---|
| 189 | args = map(db.lazyload, lazy or ()) + map(db.defer, deferred or ()) |
|---|
| 190 | return self.options(*args) |
|---|
| 191 | |
|---|
| 192 | def first(self, raise_if_missing=False): |
|---|
| 193 | """Return the first result of this `Query` or None if the result |
|---|
| 194 | doesn't contain any rows. If `raise_if_missing` is set to `True` |
|---|
| 195 | a `NotFound` exception is raised if no row is found. |
|---|
| 196 | """ |
|---|
| 197 | rv = orm.Query.first(self) |
|---|
| 198 | if rv is None and raise_if_missing: |
|---|
| 199 | raise NotFound() |
|---|
| 200 | return rv |
|---|
| 201 | |
|---|
| 202 | |
|---|
| 203 | session = orm.scoped_session(lambda: orm.create_session(get_engine(), |
|---|
| 204 | autoflush=True, autocommit=False), |
|---|
| 205 | local_manager.get_ident) |
|---|
| 206 | |
|---|
| 207 | |
|---|
| 208 | def mapper(cls, *args, **kwargs): |
|---|
| 209 | """Attaches a query and auto registers.""" |
|---|
| 210 | if not hasattr(cls, 'query'): |
|---|
| 211 | cls.query = session.query_property(Query) |
|---|
| 212 | old_init = getattr(cls, '__init__', None) |
|---|
| 213 | def register_init(self, *args, **kwargs): |
|---|
| 214 | if old_init is not None: |
|---|
| 215 | old_init(self, *args, **kwargs) |
|---|
| 216 | session.add(self) |
|---|
| 217 | cls.__init__ = register_init |
|---|
| 218 | return orm.mapper(cls, *args, **kwargs) |
|---|
| 219 | |
|---|
| 220 | |
|---|
| 221 | # configure a declarative base. This is unused in the code but makes it easier |
|---|
| 222 | # for plugins to work with the database. |
|---|
| 223 | class ModelBase(object): |
|---|
| 224 | """Internal baseclass for `Model`.""" |
|---|
| 225 | Model = declarative_base(name='Model', cls=ModelBase, mapper=mapper) |
|---|
| 226 | ModelBase.query = session.query_property(Query) |
|---|
| 227 | |
|---|
| 228 | |
|---|
| 229 | #: create a new module for all the database related functions and objects |
|---|
| 230 | sys.modules['zine.database.db'] = db = ModuleType('db') |
|---|
| 231 | key = value = mod = None |
|---|
| 232 | for mod in sqlalchemy, orm: |
|---|
| 233 | for key, value in mod.__dict__.iteritems(): |
|---|
| 234 | if key in mod.__all__: |
|---|
| 235 | setattr(db, key, value) |
|---|
| 236 | del key, mod, value |
|---|
| 237 | |
|---|
| 238 | #: forward some session methods to the module as well |
|---|
| 239 | for name in 'delete', 'flush', 'execute', 'begin', 'commit', 'rollback', \ |
|---|
| 240 | 'refresh', 'expire', 'query_property': |
|---|
| 241 | setattr(db, name, getattr(session, name)) |
|---|
| 242 | |
|---|
| 243 | # Some things changed names with SQLAlchemy 0.6.0 |
|---|
| 244 | db.save = session.add |
|---|
| 245 | db.clear = session.expunge_all |
|---|
| 246 | |
|---|
| 247 | #: and finally hook our own implementations of various objects in |
|---|
| 248 | db.Model = Model |
|---|
| 249 | db.Query = Query |
|---|
| 250 | db.get_engine = get_engine |
|---|
| 251 | db.create_engine = create_engine |
|---|
| 252 | db.session = session |
|---|
| 253 | db.ZEMLParserData = ZEMLParserData |
|---|
| 254 | db.mapper = mapper |
|---|
| 255 | db.basic_mapper = orm.mapper |
|---|
| 256 | db.association_proxy = association_proxy |
|---|
| 257 | db.attribute_loaded = attribute_loaded |
|---|
| 258 | db.AttributeExtension = AttributeExtension |
|---|
| 259 | |
|---|
| 260 | #: called at the end of a request |
|---|
| 261 | cleanup_session = session.remove |
|---|
| 262 | |
|---|
| 263 | #: metadata for the core tables and the core table definitions |
|---|
| 264 | metadata = db.MetaData() |
|---|
| 265 | |
|---|
| 266 | schema_versions = db.Table('schema_versions', metadata, |
|---|
| 267 | db.Column('repository_id', db.String(255), primary_key=True), |
|---|
| 268 | db.Column('repository_path', db.Text), |
|---|
| 269 | db.Column('version', db.Integer) |
|---|
| 270 | ) |
|---|
| 271 | |
|---|
| 272 | users = db.Table('users', metadata, |
|---|
| 273 | db.Column('user_id', db.Integer, primary_key=True), |
|---|
| 274 | db.Column('username', db.String(30)), |
|---|
| 275 | db.Column('real_name', db.String(180)), |
|---|
| 276 | db.Column('display_name', db.String(180)), |
|---|
| 277 | db.Column('description', db.Text), |
|---|
| 278 | db.Column('extra', db.PickleType), |
|---|
| 279 | db.Column('pw_hash', db.String(70)), |
|---|
| 280 | db.Column('email', db.String(250)), |
|---|
| 281 | db.Column('www', db.String(200)), |
|---|
| 282 | db.Column('is_author', db.Boolean) |
|---|
| 283 | ) |
|---|
| 284 | |
|---|
| 285 | groups = db.Table('groups', metadata, |
|---|
| 286 | db.Column('group_id', db.Integer, primary_key=True), |
|---|
| 287 | db.Column('name', db.String(30)) |
|---|
| 288 | ) |
|---|
| 289 | |
|---|
| 290 | group_users = db.Table('group_users', metadata, |
|---|
| 291 | db.Column('group_id', db.Integer, db.ForeignKey('groups.group_id')), |
|---|
| 292 | db.Column('user_id', db.Integer, db.ForeignKey('users.user_id')) |
|---|
| 293 | ) |
|---|
| 294 | |
|---|
| 295 | privileges = db.Table('privileges', metadata, |
|---|
| 296 | db.Column('privilege_id', db.Integer, primary_key=True), |
|---|
| 297 | db.Column('name', db.String(50), unique=True) |
|---|
| 298 | ) |
|---|
| 299 | |
|---|
| 300 | user_privileges = db.Table('user_privileges', metadata, |
|---|
| 301 | db.Column('user_id', db.Integer, db.ForeignKey('users.user_id')), |
|---|
| 302 | db.Column('privilege_id', db.Integer, |
|---|
| 303 | db.ForeignKey('privileges.privilege_id')) |
|---|
| 304 | ) |
|---|
| 305 | |
|---|
| 306 | group_privileges = db.Table('group_privileges', metadata, |
|---|
| 307 | db.Column('group_id', db.Integer, db.ForeignKey('groups.group_id')), |
|---|
| 308 | db.Column('privilege_id', db.Integer, |
|---|
| 309 | db.ForeignKey('privileges.privilege_id')) |
|---|
| 310 | ) |
|---|
| 311 | |
|---|
| 312 | categories = db.Table('categories', metadata, |
|---|
| 313 | db.Column('category_id', db.Integer, primary_key=True), |
|---|
| 314 | db.Column('slug', db.String(50)), |
|---|
| 315 | db.Column('name', db.String(50)), |
|---|
| 316 | db.Column('description', db.Text) |
|---|
| 317 | ) |
|---|
| 318 | |
|---|
| 319 | texts = db.Table('texts', metadata, |
|---|
| 320 | db.Column('text_id', db.Integer, primary_key=True), |
|---|
| 321 | db.Column('text', db.Text), |
|---|
| 322 | db.Column('parser_data', db.ZEMLParserData), |
|---|
| 323 | db.Column('extra', db.PickleType) |
|---|
| 324 | ) |
|---|
| 325 | |
|---|
| 326 | posts = db.Table('posts', metadata, |
|---|
| 327 | db.Column('post_id', db.Integer, primary_key=True), |
|---|
| 328 | db.Column('pub_date', db.DateTime), |
|---|
| 329 | db.Column('last_update', db.DateTime), |
|---|
| 330 | db.Column('slug', db.String(200), index=True, nullable=False), |
|---|
| 331 | db.Column('uid', db.String(250)), |
|---|
| 332 | db.Column('title', db.String(150)), |
|---|
| 333 | db.Column('text_id', db.Integer, db.ForeignKey('texts.text_id')), |
|---|
| 334 | db.Column('author_id', db.Integer, db.ForeignKey('users.user_id')), |
|---|
| 335 | db.Column('comments_enabled', db.Boolean), |
|---|
| 336 | db.Column('comment_count', db.Integer, nullable=False, default=0), |
|---|
| 337 | db.Column('pings_enabled', db.Boolean), |
|---|
| 338 | db.Column('content_type', db.String(40), index=True), |
|---|
| 339 | db.Column('status', db.Integer), |
|---|
| 340 | ) |
|---|
| 341 | |
|---|
| 342 | post_links = db.Table('post_links', metadata, |
|---|
| 343 | db.Column('link_id', db.Integer, primary_key=True), |
|---|
| 344 | db.Column('post_id', db.Integer, db.ForeignKey('posts.post_id')), |
|---|
| 345 | db.Column('href', db.String(250), nullable=False), |
|---|
| 346 | db.Column('rel', db.String(250)), |
|---|
| 347 | db.Column('type', db.String(100)), |
|---|
| 348 | db.Column('hreflang', db.String(30)), |
|---|
| 349 | db.Column('title', db.String(200)), |
|---|
| 350 | db.Column('length', db.Integer) |
|---|
| 351 | ) |
|---|
| 352 | |
|---|
| 353 | tags = db.Table('tags', metadata, |
|---|
| 354 | db.Column('tag_id', db.Integer, primary_key=True), |
|---|
| 355 | db.Column('slug', db.String(150), unique=True, nullable=False), |
|---|
| 356 | db.Column('name', db.String(100), unique=True, nullable=False) |
|---|
| 357 | ) |
|---|
| 358 | |
|---|
| 359 | post_categories = db.Table('post_categories', metadata, |
|---|
| 360 | db.Column('post_id', db.Integer, db.ForeignKey('posts.post_id')), |
|---|
| 361 | db.Column('category_id', db.Integer, db.ForeignKey('categories.category_id')) |
|---|
| 362 | ) |
|---|
| 363 | |
|---|
| 364 | post_tags = db.Table('post_tags', metadata, |
|---|
| 365 | db.Column('post_id', db.Integer, db.ForeignKey('posts.post_id')), |
|---|
| 366 | db.Column('tag_id', db.Integer, db.ForeignKey('tags.tag_id')) |
|---|
| 367 | ) |
|---|
| 368 | |
|---|
| 369 | comments = db.Table('comments', metadata, |
|---|
| 370 | db.Column('comment_id', db.Integer, primary_key=True), |
|---|
| 371 | db.Column('post_id', db.Integer, db.ForeignKey('posts.post_id')), |
|---|
| 372 | db.Column('user_id', db.Integer, db.ForeignKey('users.user_id')), |
|---|
| 373 | db.Column('author', db.String(160)), |
|---|
| 374 | db.Column('email', db.String(250)), |
|---|
| 375 | db.Column('www', db.String(200)), |
|---|
| 376 | db.Column('text_id', db.Integer, db.ForeignKey('texts.text_id')), |
|---|
| 377 | db.Column('is_pingback', db.Boolean, nullable=False), |
|---|
| 378 | db.Column('parent_id', db.Integer, db.ForeignKey('comments.comment_id')), |
|---|
| 379 | db.Column('pub_date', db.DateTime), |
|---|
| 380 | db.Column('blocked_msg', db.String(250)), |
|---|
| 381 | db.Column('submitter_ip', db.String(100)), |
|---|
| 382 | db.Column('status', db.Integer, nullable=False) |
|---|
| 383 | ) |
|---|
| 384 | |
|---|
| 385 | redirects = db.Table('redirects', metadata, |
|---|
| 386 | db.Column('redirect_id', db.Integer, primary_key=True), |
|---|
| 387 | db.Column('original', db.String(200), unique=True), |
|---|
| 388 | db.Column('new', db.String(200)) |
|---|
| 389 | ) |
|---|
| 390 | |
|---|
| 391 | notification_subscriptions = db.Table('notification_subscriptions', metadata, |
|---|
| 392 | db.Column('subscription_id', db.Integer, primary_key=True), |
|---|
| 393 | db.Column('user_id', db.Integer, db.ForeignKey('users.user_id')), |
|---|
| 394 | db.Column('notification_system', db.String(50)), |
|---|
| 395 | db.Column('notification_id', db.String(100)), |
|---|
| 396 | db.UniqueConstraint('user_id', 'notification_system', 'notification_id') |
|---|
| 397 | ) |
|---|
| 398 | |
|---|
| 399 | |
|---|
| 400 | def init_database(engine): |
|---|
| 401 | """This is called from the websetup which explains why it takes an engine |
|---|
| 402 | and not a zine application. |
|---|
| 403 | """ |
|---|
| 404 | # XXX: consider using something like this for mysql: |
|---|
| 405 | # cx = engine.connect() |
|---|
| 406 | # cx.execute('set storage_engine=innodb') |
|---|
| 407 | # metadata.create_all(cx) |
|---|
| 408 | metadata.create_all(engine) |
|---|