Hello. I am currently working on an implementation of PostgreSQL 
materialized views in SQLAlchemy and Alembic. To give a basic explanation, 
materialized views are regular views but all the rows are computed 
statically, and you can refresh them with a REFRESH MATERIALIZED VIEW query.
I've attached the code I have so far. It uses sqlalchemy.sql.table and 
column proxies to simulate a table, and also implements Alembic operations 
with autogenerate. I want to make MaterializedView into a proper class like 
Table. What would be the best way to go about this? What should I inherit 
from? Which methods should I override? Any input is appreciated.

-- 
SQLAlchemy - 
The Python SQL Toolkit and Object Relational Mapper

http://www.sqlalchemy.org/

To post example code, please provide an MCVE: Minimal, Complete, and Verifiable 
Example.  See  http://stackoverflow.com/help/mcve for a full description.
--- 
You received this message because you are subscribed to the Google Groups 
"sqlalchemy" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to sqlalchemy+unsubscr...@googlegroups.com.
To view this discussion on the web visit 
https://groups.google.com/d/msgid/sqlalchemy/ee389a85-cba5-4773-9862-a412aa0e67ca%40googlegroups.com.
import importlib
from typing import Union

from sqlalchemy import MetaData
from sqlalchemy.ext import compiler
from sqlalchemy.schema import DDLElement
from sqlalchemy.sql import Selectable, table
from sqlalchemy.sql.compiler import DDLCompiler

from alembic.operations import Operations, MigrateOperation
from alembic.autogenerate import comparators, renderers
from alembic.autogenerate.api import AutogenContext

# http://www.jeffwidman.com/blog/847/using-sqlalchemy-to-create-and-manage-postgresql-materialized-views/
# Since that blogpost;
# 1) focuses on Flask-SQLalchemy
# 2) doesn't provide a way to migrate
# I made this code use pure SQLAlchemy and also defined Alembic operations.


__all__ = (
    "CreateMaterializedView",
    "DropMaterializedView",
    "CreateMaterializedViewOp",
    "DropMaterializedViewOp",
    "MaterializedView",
)


def _import_selectable(path: str) -> Selectable:
    """Imports a Selectable object from the given import path."""
    module, obj = path.rsplit(".", 1)
    return getattr(importlib.import_module(module), obj)


# SQLAlchemy custom DDL elements


class CreateMaterializedView(DDLElement):
    """A DDL element which creates a materialized view on PostgreSQL."""

    def __init__(self, name: str, selectable: Selectable):
        self.name = name
        self.selectable = selectable


@compiler.compiles(CreateMaterializedView, "postgresql")
def compile_create_materialized_view(
    element: CreateMaterializedView, compiler: DDLCompiler, **kw
):
    return "CREATE MATERIALIZED VIEW {:s} AS {:s}".format(
        element.name,
        compiler.sql_compiler.process(element.selectable, literal_binds=True),
    )


class DropMaterializedView(DDLElement):
    """A DDL element which drops a materialized view on PostgreSQL."""

    def __init__(self, name: str):
        self.name = name


@compiler.compiles(DropMaterializedView, "postgresql")
def compile_drop_materialized_view(
    element: DropMaterializedView, compiler: DDLCompiler, **kw
):
    return "DROP MATERIALIZED VIEW {:s}".format(element.name)


def MaterializedView(name: str, metadata: MetaData, import_path: str, schema=None):
    """Creates a PostgreSQL MATERIALIZED VIEW and returns it.

    Arguments:
    name        -- The name of the view.
    import_path -- You need to pass the absolute path of the query expression
                   to this. The query expression must be top-level. This is
                   due to how Alembic renders migrations (migrations are just
                   text templates, so all semantic information is lost).
    """

    t = table(name)

    t.schema = schema

    CreateMaterializedView(name, _import_selectable(import_path)).execute_at(
        "after-create", metadata
    )
    DropMaterializedView(name).execute_at("before-drop", metadata)

    # Add metadata for alembic
    metadata.info.setdefault("materialized_views", {})[(name, schema)] = import_path

    return t


# Alembic operations
QueryOrPath = Union[Selectable, str]


@Operations.register_operation("create_materialized_view")
class CreateMaterializedViewOp(MigrateOperation):
    """Implements a PostgreSQL CREATE MATERIALIZED VIEW operation."""

    def __init__(self, view_name: str, qry: QueryOrPath, schema=None):
        if isinstance(qry, str):
            self.qry_path = qry
            qry = _import_selectable(qry)
        else:
            self.qry_path = None

        self.view_name = view_name
        self.qry = qry
        self.schema = schema

    @classmethod
    def create_materialized_view(
        cls, operations: Operations, view_name: str, selectable: Selectable, **kw
    ):
        return operations.invoke(cls(view_name, selectable, **kw))

    def reverse(self):
        return DropMaterializedViewOp(
            self.view_name, schema=self.schema, _orig_path=self.qry_path
        )


@Operations.implementation_for(CreateMaterializedViewOp)
def create_materialized_view(ops: Operations, op: CreateMaterializedViewOp):
    if op.schema is not None:
        name = "{}.{}".format(op.schema, op.view_name)
    else:
        name = op.view_name

    ops.execute(CreateMaterializedView(name, op.qry))


@Operations.register_operation("drop_materialized_view")
class DropMaterializedViewOp(MigrateOperation):
    """Implements a PostgreSQL DROP MATERIALIZED VIEW operation."""

    def __init__(self, view_name: str, schema=None, _orig_path: str = None):
        self.view_name = view_name
        self.schema = schema
        self._orig_path = _orig_path

    @classmethod
    def drop_materialized_view(cls, operations: Operations, view_name: str, **kw):
        return operations.invoke(cls(view_name, **kw))

    def reverse(self):
        if self._orig_path is None:
            raise ValueError("cannot autogenerate; path to query doesn't exist")

        return CreateMaterializedViewOp(
            self.view_name, self._orig_path, schema=self.schema
        )


@Operations.implementation_for(DropMaterializedViewOp)
def drop_materialized_view(ops: Operations, op: DropMaterializedViewOp):
    if op.schema is not None:
        name = "{}.{}".format(op.schema, op.view_name)
    else:
        name = op.view_name

    ops.execute(DropMaterializedView(name))


# Comparator implementation


@comparators.dispatch_for("schema")
def compare_materialized_views(ctx: AutogenContext, upgrade_ops, schemas):
    all_conn_matviews = set()

    # Collect all materialized views from all the tables
    for sch in schemas:
        all_conn_matviews.update(
            [
                (row[0], sch)
                for row in ctx.connection.execute(
                    # This query fetches the names of all materialized views
                    # in this schema.
                    "SELECT relname FROM pg_class c join "
                    "pg_namespace n on n.oid=c.relnamespace where "
                    "relkind='m' and n.nspname=%(nspname)s",
                    nspname=(ctx.dialect.default_schema_name if sch is None else sch),
                )
            ]
        )

    # Get the materialized views that are registered locally
    meta_matviews: dict = ctx.metadata.info.setdefault("materialized_views", {})
    matviews_set = set(meta_matviews.keys())

    # Create new materialized views for those in the metadata but not in the
    # connection
    for key in matviews_set.difference(all_conn_matviews):
        name, schema = key
        upgrade_ops.ops.append(
            CreateMaterializedViewOp(name, meta_matviews[key], schema=schema)
        )

    # Drop materialized views for those in the connection but not in the metadata
    # anymore
    # TODO: Implement fetching of the original selectable, so that drops are reversible
    for key in all_conn_matviews.difference(matviews_set):
        name, schema = key
        upgrade_ops.ops.append(DropMaterializedViewOp(name, schema=schema))


# Renderer implementations


@renderers.dispatch_for(CreateMaterializedViewOp)
def render_create_materialized_view(
    ctx: AutogenContext, op: CreateMaterializedViewOp
) -> str:
    module, obj_path = op.qry_path.split(".", 1)

    return "op.create_materialized_view({}, __import__({}).{}, **{})".format(
        repr(op.view_name), repr(module), obj_path, {"schema": op.schema}
    )


@renderers.dispatch_for(DropMaterializedViewOp)
def render_drop_materialized_view(
    ctx: AutogenContext, op: DropMaterializedViewOp
) -> str:
    return "op.drop_materialized_view({}, **{})".format(
        repr(op.view_name), {"schema": op.schema}
    )

Reply via email to