Zine

open source content publishing system


source: zine/database.py @ 1375:01b77c91b28e

Revision 1375:01b77c91b28e, 14.4 KB checked in by mitsuhiko, 2 years ago (diff)

Merged

Line 
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"""
15import re
16import os
17import sys
18import time
19from os import path
20from types import ModuleType
21from copy import deepcopy
22
23import sqlalchemy
24from sqlalchemy import orm
25from sqlalchemy.interfaces import ConnectionProxy
26from sqlalchemy.orm.interfaces import AttributeExtension
27from sqlalchemy.exc import ArgumentError
28from sqlalchemy.ext.declarative import declarative_base
29from sqlalchemy.engine.url import make_url, URL
30from sqlalchemy.types import TypeDecorator
31from sqlalchemy.ext.associationproxy import association_proxy
32
33from werkzeug import url_decode
34from werkzeug.exceptions import NotFound
35
36from zine.utils import local_manager
37
38
39if sys.platform == 'win32':
40    _timer = time.clock
41else:
42    _timer = time.time
43
44
45_sqlite_re = re.compile(r'sqlite:(?:(?://(.*?))|memory)(?:\?(.*))?$')
46
47
48def 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
59def 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
112def 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
120def 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
127class 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
144class 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
182class 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
203session = orm.scoped_session(lambda: orm.create_session(get_engine(),
204                             autoflush=True, autocommit=False),
205                             local_manager.get_ident)
206
207
208def 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.
223class ModelBase(object):
224    """Internal baseclass for `Model`."""
225Model = declarative_base(name='Model', cls=ModelBase, mapper=mapper)
226ModelBase.query = session.query_property(Query)
227
228
229#: create a new module for all the database related functions and objects
230sys.modules['zine.database.db'] = db = ModuleType('db')
231key = value = mod = None
232for mod in sqlalchemy, orm:
233    for key, value in mod.__dict__.iteritems():
234        if key in mod.__all__:
235            setattr(db, key, value)
236del key, mod, value
237
238#: forward some session methods to the module as well
239for 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
244db.save = session.add
245db.clear = session.expunge_all
246
247#: and finally hook our own implementations of various objects in
248db.Model = Model
249db.Query = Query
250db.get_engine = get_engine
251db.create_engine = create_engine
252db.session = session
253db.ZEMLParserData = ZEMLParserData
254db.mapper = mapper
255db.basic_mapper = orm.mapper
256db.association_proxy = association_proxy
257db.attribute_loaded = attribute_loaded
258db.AttributeExtension = AttributeExtension
259
260#: called at the end of a request
261cleanup_session = session.remove
262
263#: metadata for the core tables and the core table definitions
264metadata = db.MetaData()
265
266schema_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
272users = 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
285groups = db.Table('groups', metadata,
286    db.Column('group_id', db.Integer, primary_key=True),
287    db.Column('name', db.String(30))
288)
289
290group_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
295privileges = 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
300user_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
306group_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
312categories = 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
319texts = 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
326posts = 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
342post_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
353tags = 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
359post_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
364post_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
369comments = 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
385redirects = 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
391notification_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
400def 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)
Note: See TracBrowser for help on using the repository browser.