~sirn/fanboi2

5a8ea365f95a92a95b41006c1d71f966478d7054 — Kridsada Thanabulpong 4 years ago 23dd53b
Use topic_meta for topic metadata retrieval instead of subquery

Since we now allow posts to be deleted and the post may be restored
later, topic metadata such as post count now need to explicitly tracked
somewhere (since we can no longer reuse post.number).
M fanboi2/models/__init__.py => fanboi2/models/__init__.py +40 -0
@@ 1,15 1,19 @@
from sqlalchemy import event
from sqlalchemy.sql import desc, func, select
from ._base import DBSession, Base, JsonType
from ._identity import Identity
from ._redis_proxy import RedisProxy
from ._versioned import make_versioned
from .board import Board
from .topic import Topic
from .topic_meta import TopicMeta
from .post import Post


_MODELS = {
    'board': Board,
    'topic': Topic,
    'topic_meta': TopicMeta,
    'post': Post,
}



@@ 20,3 24,39 @@ def serialize_model(type_):
redis_conn = RedisProxy()
identity = Identity(redis=redis_conn)
make_versioned(DBSession)


@event.listens_for(DBSession, 'before_flush')
def _create_topic_meta(session, context, instances):
    """Assign a new topic meta to a topic on creation."""
    for topic in filter(lambda m: isinstance(m, Topic), session.new):
        if topic.meta is None:
            topic.meta = TopicMeta(post_count=0)


@event.listens_for(DBSession, 'before_flush')
def _update_topic_meta_states(session, context, instance):
    """Update topic metadata and related states when new posts are made."""
    for post in filter(lambda m: isinstance(m, Post), session.new):
        topic = post.topic
        board = topic.board
        if topic in session.new:
            topic_meta = topic.meta
        else:
            topic_meta = session.query(TopicMeta).\
                         filter_by(topic=topic).\
                         with_for_update().\
                         one()

        topic_meta.post_count = post.number = topic_meta.post_count + 1
        topic_meta.posted_at = post.created_at or func.now()
        if post.bumped is None or post.bumped:
            topic_meta.bumped_at = topic_meta.posted_at

        if topic.status == 'open' and \
           topic_meta.post_count >= board.settings['max_posts']:
            topic.status = 'archived'

        session.add(topic_meta)
        session.add(topic)
        session.add(post)

M fanboi2/models/post.py => fanboi2/models/post.py +2 -11
@@ 1,6 1,6 @@
from sqlalchemy import event
from sqlalchemy.orm import backref, relationship
from sqlalchemy.sql import func, select
from sqlalchemy.sql import func
from sqlalchemy.sql.schema import Column, ForeignKey, UniqueConstraint
from sqlalchemy.sql.sqltypes import Integer, DateTime, String, Text, Boolean
from ._base import Base, Versioned


@@ 25,6 25,7 @@ class Post(Versioned, Base):
    name = Column(String, nullable=False)
    body = Column(Text, nullable=False)
    bumped = Column(Boolean, nullable=False, index=True, default=True)

    topic = relationship('Topic',
                         backref=backref('posts',
                                         lazy='dynamic',


@@ 33,16 34,6 @@ class Post(Versioned, Base):


@event.listens_for(Post.__mapper__, 'before_insert')
def populate_post_number(mapper, connection, target):
    """Populate sequential :attr:`Post.number` in each topic."""
    # This will issue a subquery on every INSERT which may cause race
    # condition problem. Our UNIQUE CONSTRAINT will detect that, so the
    # calling code should retry accordingly.
    target.number = select([func.coalesce(func.max(Post.number), 0) + 1]).\
        where(Post.topic_id == target.topic_id)


@event.listens_for(Post.__mapper__, 'before_insert')
def populate_post_name(mapper, connection, target):
    """Populate :attr:`Post.name` using name set within :attr:`Post.settings`
    if name is empty otherwise use user input.

M fanboi2/models/topic.py => fanboi2/models/topic.py +9 -40
@@ 1,11 1,11 @@
import re
from sqlalchemy import event
from sqlalchemy.orm import backref, column_property, relationship
from sqlalchemy.orm import backref, relationship
from sqlalchemy.sql import desc, func, select
from sqlalchemy.sql.schema import Column, ForeignKey
from sqlalchemy.sql.sqltypes import Integer, DateTime, Enum, Unicode
from ._base import Base, DBSession, Versioned
from ._base import Base, Versioned
from .post import Post
from .topic_meta import TopicMeta


class Topic(Versioned, Base):


@@ 24,13 24,16 @@ class Topic(Versioned, Base):
    status = Column(Enum('open', 'locked', 'archived', name='topic_status'),
                    default='open',
                    nullable=False)

    board = relationship('Board',
                         backref=backref('topics',
                                         lazy='dynamic',
                                         cascade='all,delete',
                                         order_by="desc(func.coalesce("
                                                  "Topic.bumped_at,"
                                                  "Topic.created_at))"))
                                         order_by=desc(func.coalesce(
                                             select([TopicMeta.bumped_at]).\
                                                where(TopicMeta.topic_id==id).\
                                                as_scalar(),
                                             created_at))))

    QUERY = (
        ("single_post", re.compile("^(\d+)$")),


@@ 97,37 100,3 @@ class Topic(Versioned, Base):
        return self.posts.order_by(False).\
            order_by(desc(Post.number)).\
            limit(count).all()[::-1]


@event.listens_for(DBSession, 'before_flush')
def update_topic_status(session, context, instances):
    for obj in filter(lambda m: isinstance(m, Post), session.new):
        topic = obj.topic
        if topic.post_count is not None and \
                topic.status == 'open' and \
                topic.post_count >= (topic.board.settings['max_posts'] - 1):
            topic.status = 'archived'
            session.add(topic)


Topic.post_count = column_property(
    select([func.coalesce(func.max(Post.number), 0)]).
    where(Post.topic_id == Topic.id)
)


Topic.posted_at = column_property(
    select([Post.created_at]).
        where(Post.topic_id == Topic.id).
        order_by(desc(Post.created_at)).
        limit(1)
)


Topic.bumped_at = column_property(
    select([Post.created_at]).
        where(Post.topic_id == Topic.id).
        where(Post.bumped).
        order_by(desc(Post.created_at)).
        limit(1)
)

A fanboi2/models/topic_meta.py => fanboi2/models/topic_meta.py +29 -0
@@ 0,0 1,29 @@
from sqlalchemy.orm import relationship, backref
from sqlalchemy.sql.schema import Column, ForeignKey
from sqlalchemy.sql.sqltypes import Integer, DateTime
from ._base import Base


class TopicMeta(Base):
    """Model class that topic metadata. This model holds data that are related
    to internal workings of the topic model that are not part of the
    versionable records.
    """

    __tablename__ = 'topic_meta'

    topic_id = Column(Integer,
                      ForeignKey('topic.id'),
                      nullable=False,
                      primary_key=True,
                      autoincrement=False)

    post_count = Column(Integer, nullable=False)
    posted_at = Column(DateTime(timezone=True))
    bumped_at = Column(DateTime(timezone=True))

    topic = relationship('Topic',
                         backref=backref('meta',
                                         uselist=False,
                                         cascade='all,delete',
                                         lazy=True))

M fanboi2/serializers.py => fanboi2/serializers.py +3 -3
@@ 69,10 69,10 @@ def _topic_serializer(obj, request):
        'type': 'topic',
        'id': obj.id,
        'board_id': obj.board_id,
        'bumped_at': obj.bumped_at,
        'bumped_at': obj.meta.bumped_at,
        'created_at': obj.created_at,
        'post_count': obj.post_count,
        'posted_at': obj.posted_at,
        'post_count': obj.meta.post_count,
        'posted_at': obj.meta.posted_at,
        'status': obj.status,
        'title': obj.title,
        'path': request.route_path('api_topic', topic=obj.id),

M fanboi2/templates/boards/all.mako => fanboi2/templates/boards/all.mako +2 -2
@@ 9,8 9,8 @@
                <a class="cascade-header-link" href="${request.route_path('topic_scoped', board=board.slug, topic=topic.id, query='recent')}">${topic.title}</a>
            </div>
            <div class="cascade-body">
                <p>Last posted ${formatters.format_datetime(request, topic.posted_at)}</p>
                <p>Total of <strong>${topic.post_count} posts</strong></p>
                <p>Last posted ${formatters.format_datetime(request, topic.meta.posted_at)}</p>
                <p>Total of <strong>${topic.meta.post_count} posts</strong></p>
            </div>
        </div>
    </div>

M fanboi2/templates/boards/show.mako => fanboi2/templates/boards/show.mako +2 -2
@@ 8,8 8,8 @@
        <div class="topic-header">
            <div class="container">
                <h3 class="topic-header-title"><a href="${request.route_path('topic_scoped', board=board.slug, topic=topic.id, query='recent')}">${topic.title}</a></h3>
                <p class="topic-header-item">Last posted <strong>${formatters.format_datetime(request, topic.posted_at)}</strong></p>
                <p class="topic-header-item">Total of <strong>${topic.post_count} posts</strong></p>
                <p class="topic-header-item">Last posted <strong>${formatters.format_datetime(request, topic.meta.posted_at)}</strong></p>
                <p class="topic-header-item">Total of <strong>${topic.meta.post_count} posts</strong></p>
            </div>
        </div>
        <div class="topic-body">

M fanboi2/templates/topics/_subheader.mako => fanboi2/templates/topics/_subheader.mako +2 -2
@@ 3,8 3,8 @@
    <div class="container">
        <h2 class="subheader-title"><a href="${request.route_path('topic', board=board.slug, topic=topic.id)}">${topic.title}</a></h2>
        <div class="subheader-body lines">
            <p>Last posted <strong>${formatters.format_datetime(request, topic.posted_at)}</strong></p>
            <p>Total of <strong>${topic.post_count} posts</strong></p>
            <p>Last posted <strong>${formatters.format_datetime(request, topic.meta.posted_at)}</strong></p>
            <p>Total of <strong>${topic.meta.post_count} posts</strong></p>
        </div>
        <div class="subheader-footer">
            <ul class="actions">

M fanboi2/templates/topics/show.mako => fanboi2/templates/topics/show.mako +3 -3
@@ 10,9 10,9 @@
            <ul class="actions">
                <li class="actions-item"><a class="button action" href="${request.route_path('topic_scoped', board=board.slug, topic=topic.id, query='recent')}">Latest posts</a></li>
                <li class="actions-item"><a class="button action" href="${request.route_path('topic', board=board.slug, topic=topic.id)}">All posts</a></li>
                % if posts and topic.status == 'open' and posts[-1].number == topic.post_count:
                    <li class="actions-item"><a class="button brand" href="${request.route_path('topic_scoped', board=board.slug, topic=topic.id, query="%s-" % topic.post_count)}" data-topic-reloader="true">Reload posts</a></li>
                % elif posts and posts[-1].number != topic.post_count:
                % if posts and topic.status == 'open' and posts[-1].number == topic.meta.post_count:
                    <li class="actions-item"><a class="button brand" href="${request.route_path('topic_scoped', board=board.slug, topic=topic.id, query="%s-" % topic.meta.post_count)}" data-topic-reloader="true">Reload posts</a></li>
                % elif posts and posts[-1].number != topic.meta.post_count:
                    <li class="actions-item"><a class="button action" href="${request.route_path('topic_scoped', board=board.slug, topic=topic.id, query="%s-" % posts[-1].number)}" data-topic-reloader="true" data-topic-reloader-label="Reload posts" data-topic-reloader-class="button brand">Newer posts</a></li>
                % endif
            </ul>

M fanboi2/tests/__init__.py => fanboi2/tests/__init__.py +29 -8
@@ 52,25 52,46 @@ class DummyRedis(object):

class _ModelInstanceSetup(object):

    def _makeBoard(self, **kwargs):
    def _newBoard(self, **kwargs):
        from fanboi2.models import Board
        board = Board(**kwargs)
        return Board(**kwargs)

    def _newTopic(self, **kwargs):
        from fanboi2.models import Topic
        return Topic(**kwargs)

    def _newTopicMeta(self, **kwargs):
        from fanboi2.models import TopicMeta
        if not kwargs.get('post_count', None):
            kwargs['post_count'] = 0
        return TopicMeta(**kwargs)

    def _newPost(self, **kwargs):
        from fanboi2.models import Post
        if not kwargs.get('ip_address', None):
            kwargs['ip_address'] = '0.0.0.0'
        return Post(**kwargs)

    def _makeBoard(self, **kwargs):
        board = self._newBoard(**kwargs)
        DBSession.add(board)
        DBSession.flush()
        return board

    def _makeTopic(self, **kwargs):
        from fanboi2.models import Topic
        topic = Topic(**kwargs)
        topic = self._newTopic(**kwargs)
        DBSession.add(topic)
        DBSession.flush()
        return topic

    def _makeTopicMeta(self, **kwargs):
        topic_meta = self._newTopicMeta(**kwargs)
        DBSession.add(topic_meta)
        DBSession.flush()
        return topic_meta

    def _makePost(self, **kwargs):
        from fanboi2.models import Post
        if not kwargs.get('ip_address', None):
            kwargs['ip_address'] = '0.0.0.0'
        post = Post(**kwargs)
        post = self._newPost(**kwargs)
        DBSession.add(post)
        DBSession.flush()
        return post

M fanboi2/tests/test_models.py => fanboi2/tests/test_models.py +80 -54
@@ 116,10 116,12 @@ class TestJsonType(unittest.TestCase):
class TestSerializeModel(unittest.TestCase):

    def test_serialize(self):
        from fanboi2.models import serialize_model, Board, Topic, Post
        from fanboi2.models import serialize_model, \
            Board, Topic, TopicMeta, Post
        self.assertEqual(serialize_model('board'), Board)
        self.assertEqual(serialize_model('topic'), Topic)
        self.assertEqual(serialize_model('post'), Post)
        self.assertEqual(serialize_model('topic_meta'), TopicMeta)
        self.assertIsNone(serialize_model('foo'))




@@ 986,6 988,9 @@ class TestBoardModel(ModelMixin, unittest.TestCase):
        self.assertTrue(inspect(topic1).deleted)
        self.assertTrue(inspect(topic2).deleted)
        self.assertFalse(inspect(topic3).deleted)
        self.assertTrue(inspect(topic1.meta).deleted)
        self.assertTrue(inspect(topic2.meta).deleted)
        self.assertFalse(inspect(topic3.meta).deleted)
        self.assertTrue(inspect(post1).deleted)
        self.assertTrue(inspect(post2).deleted)
        self.assertTrue(inspect(post3).deleted)


@@ 1070,7 1075,6 @@ class TestBoardModel(ModelMixin, unittest.TestCase):
        self.assertEqual(board.settings, new_settings)

    def test_topics(self):
        from fanboi2.models import Topic
        board1 = self._makeBoard(title="Foobar", slug="foo")
        board2 = self._makeBoard(title="Lorem", slug="lorem")
        topic1 = self._makeTopic(board=board1, title="Heavenly Moon")


@@ 1082,7 1086,6 @@ class TestBoardModel(ModelMixin, unittest.TestCase):

    def test_topics_sort(self):
        from datetime import datetime, timedelta
        from fanboi2.models import Topic, Post
        board = self._makeBoard(title="Foobar", slug="foobar")
        topic1 = self._makeTopic(board=board, title="First")
        topic2 = self._makeTopic(board=board, title="Second")


@@ 1116,14 1119,15 @@ class TestTopicModel(ModelMixin, unittest.TestCase):

    def test_relations(self):
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        topic = self._newTopic(board=board, title="Lorem ipsum dolor")
        topic_meta = self._makeTopicMeta(topic=topic, post_count=0)
        self.assertEqual(topic.board, board)
        self.assertEqual([], list(topic.posts))
        self.assertEqual([topic], list(board.topics))
        self.assertEqual(topic.meta, topic_meta)

    def test_relations_cascade(self):
        from sqlalchemy import inspect
        from fanboi2.models import Post
        board = self._makeBoard(title='Foobar', slug='foo')
        topic1 = self._makeTopic(board=board, title='Shamshir Dance')
        topic2 = self._makeTopic(board=board, title='Nyoah Sword Dance')


@@ 1136,6 1140,8 @@ class TestTopicModel(ModelMixin, unittest.TestCase):
        DBSession.flush()
        self.assertTrue(inspect(topic1).deleted)
        self.assertFalse(inspect(topic2).deleted)
        self.assertTrue(inspect(topic1.meta).deleted)
        self.assertFalse(inspect(topic2.meta).deleted)
        self.assertTrue(inspect(post1).deleted)
        self.assertTrue(inspect(post2).deleted)
        self.assertFalse(inspect(post3).deleted)


@@ 1214,6 1220,13 @@ class TestTopicModel(ModelMixin, unittest.TestCase):
        self.assertEqual([post1, post2, post3], list(topic1.posts))
        self.assertEqual([], list(topic2.posts))

    def test_meta(self):
        board = self._makeBoard(title='Foobar', slug='foo')
        topic = self._makeTopic(board=board, title='Lorem ipsum dolor')
        self.assertEqual(topic.meta.post_count, 0)
        self.assertIsNone(topic.meta.posted_at)
        self.assertIsNone(topic.meta.bumped_at)

    def test_auto_archive(self):
        board = self._makeBoard(title="Foobar", slug="foo", settings={
            'max_posts': 5,


@@ 1236,54 1249,6 @@ class TestTopicModel(ModelMixin, unittest.TestCase):
            post = self._makePost(topic=topic, body="Post %s" % i)
        self.assertEqual(topic.status, "locked")

    def test_post_count(self):
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        self.assertEqual(topic.post_count, 0)
        for x in range(3):
            self._makePost(topic=topic, body="Hello, world!")
        self.assertEqual(topic.post_count, 3)

    def test_post_count_missing(self):
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        self.assertEqual(topic.post_count, 0)
        for x in range(2):
            self._makePost(topic=topic, body="Hello, world!")
        post = self._makePost(topic=topic, body="Hello, world!")
        self._makePost(topic=topic, body="Hello, world!")
        self.assertEqual(topic.post_count, 4)
        DBSession.delete(post)
        DBSession.flush()
        DBSession.expire(topic, ['post_count'])
        self.assertEqual(topic.post_count, 4)

    def test_posted_at(self):
        from datetime import datetime, timezone
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        self.assertIsNone(topic.posted_at)
        for x in range(2):
            self._makePost(
                topic=topic,
                body="Hello, world!",
                created_at=datetime(2013, 1, 2, 0, 4, 1, 0, timezone.utc))
        post = self._makePost(topic=topic, body="Hello, world!")
        self.assertEqual(topic.created_at, post.created_at)

    def test_bumped_at(self):
        from datetime import datetime, timezone
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        self.assertIsNone(topic.bumped_at)
        post1 = self._makePost(
            topic=topic,
            body="Hello, world",
            created_at=datetime(2013, 1, 2, 0, 4, 1, 0, timezone.utc))
        post2 = self._makePost(topic=topic, body="Spam!", bumped=False)
        self.assertEqual(topic.bumped_at, post1.created_at)
        self.assertNotEqual(topic.bumped_at, post2.created_at)

    def test_scoped_posts(self):
        board = self._makeBoard(title="Foobar", slug="foobar")
        topic = self._makeTopic(board=board, title="Hello, world!")


@@ 1412,6 1377,64 @@ class TestTopicModel(ModelMixin, unittest.TestCase):
            topic.scoped_posts("l5"))


class TestTopicMetaModel(ModelMixin, unittest.TestCase):

    def test_relations(self):
        board = self._makeBoard(title='Foobar', slug='foo')
        topic = self._newTopic(board=board, title='Lorem ipsum dolor sit')
        topic_meta = self._makeTopicMeta(topic=topic)
        self.assertEqual(topic_meta.topic, topic)
        self.assertEqual(topic_meta, topic.meta)

    def test_post_count(self):
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        self.assertEqual(topic.meta.post_count, 0)
        for x in range(3):
            self._makePost(topic=topic, body="Hello, world!")
        self.assertEqual(topic.meta.post_count, 3)

    def test_post_count_deletion(self):
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        self.assertEqual(topic.meta.post_count, 0)
        for x in range(2):
            self._makePost(topic=topic, body="Hello, world!")
        self._makePost(topic=topic, body="Hello, world!")
        post = self._makePost(topic=topic, body="Hello, world!")
        self.assertEqual(topic.meta.post_count, 4)
        DBSession.delete(post)
        DBSession.flush()
        DBSession.expire(topic.meta)
        self.assertEqual(topic.meta.post_count, 4)

    def test_posted_at(self):
        from datetime import datetime, timezone
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        self.assertIsNone(topic.meta.posted_at)
        for x in range(2):
            self._makePost(
                topic=topic,
                body="Hello, world!",
                created_at=datetime(2013, 1, 2, 0, 4, 1, 0, timezone.utc))
        post = self._makePost(topic=topic, body="Hello, world!")
        self.assertEqual(topic.created_at, post.created_at)

    def test_bumped_at(self):
        from datetime import datetime, timezone
        board = self._makeBoard(title="Foobar", slug="foo")
        topic = self._makeTopic(board=board, title="Lorem ipsum dolor")
        self.assertIsNone(topic.meta.bumped_at)
        post1 = self._makePost(
            topic=topic,
            body="Hello, world",
            created_at=datetime(2013, 1, 2, 0, 4, 1, 0, timezone.utc))
        post2 = self._makePost(topic=topic, body="Spam!", bumped=False)
        self.assertEqual(topic.meta.bumped_at, post1.created_at)
        self.assertNotEqual(topic.meta.bumped_at, post2.created_at)


class TestPostModel(ModelMixin, unittest.TestCase):

    def test_relations(self):


@@ 1470,7 1493,6 @@ class TestPostModel(ModelMixin, unittest.TestCase):
        post3 = self._makePost(topic=topic2, body="Topic 2, post 1")
        post4 = self._makePost(topic=topic1, body="Topic 1, post 3")
        post5 = self._makePost(topic=topic2, body="Topic 2, post 2")
        # Force update to ensure its number remain the same.
        post4.body = "Topic1, post 3, updated!"
        DBSession.add(post4)
        DBSession.flush()


@@ 1479,6 1501,10 @@ class TestPostModel(ModelMixin, unittest.TestCase):
        self.assertEqual(post3.number, 1)
        self.assertEqual(post4.number, 3)
        self.assertEqual(post5.number, 2)
        DBSession.delete(post5)
        DBSession.flush()
        post6 = self._makePost(topic=topic2, body="Topic 2, post 3")
        self.assertEqual(post6.number, 3)

    def test_name(self):
        board = self._makeBoard(title="Foobar", slug="foo", settings={

M fanboi2/views/api.py => fanboi2/views/api.py +6 -4
@@ 1,10 1,10 @@
import datetime
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.sql import or_, and_
from sqlalchemy.sql import or_, and_, select
from webob.multidict import MultiDict
from fanboi2.errors import ParamsInvalidError, RateLimitedError, BaseError
from fanboi2.forms import TopicForm, PostForm
from fanboi2.models import DBSession, Board, Topic
from fanboi2.models import DBSession, Board, Topic, TopicMeta
from fanboi2.tasks import ResultProxy, add_topic, add_post, celery
from fanboi2.utils import RateLimiter, serialize_request



@@ 64,10 64,12 @@ def board_topics_get(request):
    :type request: pyramid.request.Request
    :rtype: sqlalchemy.orm.Query
    """
    return board_get(request).topics. \
    return board_get(request).topics.\
        filter(or_(Topic.status == "open",
                   and_(Topic.status != "open",
                        Topic.posted_at >= datetime.datetime.now() -
                            select([TopicMeta.posted_at]).\
                            where(TopicMeta.topic_id==Topic.id).\
                            as_scalar() >= datetime.datetime.now() -
                        datetime.timedelta(days=7))))



A migration/versions/28d3c8870c89_create_topic_meta_table.py => migration/versions/28d3c8870c89_create_topic_meta_table.py +87 -0
@@ 0,0 1,87 @@
"""create topic meta table

Revision ID: 28d3c8870c89
Revises: 0d0f281cc4ec
Create Date: 2016-10-25 11:52:14.806880

"""

# revision identifiers, used by Alembic.
revision = '28d3c8870c89'
down_revision = '0d0f281cc4ec'

from alembic import op
from sqlalchemy import sql
import sqlalchemy as sa


def upgrade():
    op.create_table('topic_meta',
        sa.Column('topic_id',
                  sa.Integer(),
                  autoincrement=False,
                  nullable=False),
        sa.Column('post_count', sa.Integer(), nullable=False),
        sa.Column('posted_at', sa.DateTime(timezone=True)),
        sa.Column('bumped_at', sa.DateTime(timezone=True)),
        sa.ForeignKeyConstraint(['topic_id'], ['topic.id'], ),
        sa.PrimaryKeyConstraint('topic_id'),
    )

    topic_meta_table = sql.table(
        'topic_meta',
        sql.column('topic_id'),
        sql.column('post_count'),
        sql.column('posted_at'),
        sql.column('bumped_at'))

    topic_table = sql.table(
        'topic',
        sql.column('id'))

    post_table = sql.table(
        'post',
        sql.column('created_at'),
        sql.column('topic_id'),
        sql.column('number'),
        sql.column('bumped'))

    op.execute(
        topic_meta_table.
        insert().
        from_select(
            [
                topic_meta_table.c.topic_id,
                topic_meta_table.c.post_count,
                topic_meta_table.c.posted_at,
                topic_meta_table.c.bumped_at
            ],
            sa.select(
                [
                    topic_table.c.id,
                    sa.select([
                            sa.func.coalesce(
                                sa.func.max(post_table.c.number),
                                0).
                            label('post_count')]).
                        where(post_table.c.topic_id == topic_table.c.id).
                        label('post_count_q'),
                    sa.select([post_table.c.created_at.label('posted_at')]).
                        where(post_table.c.topic_id == topic_table.c.id).
                        order_by(sa.desc(post_table.c.created_at)).
                        limit(1).
                        label('posted_at_q'),
                    sa.select([post_table.c.created_at.label('bumped_at')]).
                        where(post_table.c.topic_id == topic_table.c.id).
                        where(post_table.c.bumped).
                        order_by(sa.desc(post_table.c.created_at)).
                        limit(1).
                        label('bumped_at_q'),
                ]
            )
        )
    )


def downgrade():
    op.drop_table('topic_meta')