Skip to content

Commit

Permalink
feat: Begin specialization of postgres view from the base View.
Browse files Browse the repository at this point in the history
Refactors out the notion of dialect-specific view classes that can
contain dialect-specific view behaviors. This moves the handling of
PGView and PGMaterializedView adapters from alembic_utils to that
postgres dialect definition.

Using the `@view` decorator should work identically before/after this
change. However, note this deprecates the use of `materialized` and `constraints`
through direct construction of a generic `View` instance. Instead, you
should directly construct the dialect-specific variant (of which
`sqlalchemy_declarative_extensions.dialects.postgresql.View` is the only
one currently).

The primary **addition** of this PR is an optional
`materialized=MaterializedOptions(with_data=False)` variant of defining
materialized views.
  • Loading branch information
DanCardin committed May 21, 2024
1 parent 854d0f3 commit aa12d8a
Show file tree
Hide file tree
Showing 16 changed files with 424 additions and 127 deletions.
18 changes: 17 additions & 1 deletion docs/source/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,16 @@ Finally, you can directly call `register_view` to imperitively register a normal
## Materialized views

Materialized views can be created by adding the `materialized=True` kwarg to the
`@view` decorator, or else by supplying the same kwarg directly to the `View`
`@view` decorator, or else by supplying the same kwarg directly to a supported `View`
constructor.

```{note}
Only certain dialects support `materialized` and `constraints`. If you need these
options, you should reach for the dialect-specific variant defined at
`sqlalchemy_declarative_extensions.dialects.<dialect>.View` instead of the generic
one.
```

Note that in order to refresh materialized views concurrently, the Postgres
requires the view to have a unique constraint. The constraint can be applied in
the same way that it would be on a normal table (i.e. `__table_args__`):
Expand All @@ -121,3 +128,12 @@ Additionally the sqlalchemy `UniqueConstraint` index type is supported.
Internally these options are converted to
`sqlalchemy_declarative_extensions.ViewIndex`, which you **can** instead use
directly, if desired.

### `MaterializedOptions`

The `materialized` argument (either through `@view` or through direct construction
of a `View` object) can be **either** a `bool` or a dialect-specific `MaterializedOptions`
object.

Use of the bool implies the dialect's default settings for construction of a materialized
view.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "sqlalchemy-declarative-extensions"
version = "0.8.3"
version = "0.9.0"
authors = ["Dan Cardin <ddcardin@gmail.com>"]

description = "Library to declare additional kinds of objects not natively supported by SQLAlchemy/Alembic."
Expand Down
4 changes: 3 additions & 1 deletion src/sqlalchemy_declarative_extensions/dialects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
get_schemas,
get_triggers,
get_view,
get_view_cls,
get_views,
)

Expand All @@ -22,15 +23,16 @@
"get_current_schema",
"get_default_grants",
"get_function_cls",
"get_functions",
"get_grants",
"get_objects",
"get_role_cls",
"get_roles",
"get_schemas",
"get_triggers",
"get_view",
"get_view_cls",
"get_views",
"get_functions",
"mysql",
"postgresql",
"sqlite",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
TriggerForEach,
TriggerTimes,
)
from sqlalchemy_declarative_extensions.dialects.postgresql.view import (
MaterializedOptions,
View,
)
from sqlalchemy_declarative_extensions.view.base import ViewIndex

__all__ = [
"DefaultGrant",
Expand All @@ -35,6 +40,7 @@
"GrantStatement",
"GrantStatement",
"GrantTypes",
"MaterializedOptions",
"Role",
"SchemaGrants",
"SequenceGrants",
Expand All @@ -44,4 +50,6 @@
"TriggerForEach",
"TriggerTimes",
"TypeGrants",
"View",
"ViewIndex",
]
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlalchemy import Index, UniqueConstraint
from sqlalchemy.engine import Connection

from sqlalchemy_declarative_extensions.dialects.postgresql import View, ViewIndex
from sqlalchemy_declarative_extensions.dialects.postgresql.acl import (
parse_acl,
parse_default_acl,
Expand All @@ -29,8 +30,10 @@
TriggerForEach,
TriggerTimes,
)
from sqlalchemy_declarative_extensions.dialects.postgresql.view import (
MaterializedOptions,
)
from sqlalchemy_declarative_extensions.sql import qualify_name
from sqlalchemy_declarative_extensions.view.base import View, ViewIndex


def get_schemas_postgresql(connection: Connection):
Expand Down Expand Up @@ -136,7 +139,7 @@ def get_views_postgresql(connection: Connection):
v.name,
v.definition,
schema=schema,
materialized=v.materialized,
materialized=MaterializedOptions() if v.materialized else False,
constraints=indexes or None,
)
views.append(view)
Expand Down
130 changes: 130 additions & 0 deletions src/sqlalchemy_declarative_extensions/dialects/postgresql/view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
from __future__ import annotations

from dataclasses import dataclass, field, replace
from typing import Any, Literal

from sqlalchemy import MetaData
from sqlalchemy.engine import Connection, Dialect
from typing_extensions import override

from sqlalchemy_declarative_extensions.view import base


@dataclass
class MaterializedOptions:
with_data: bool = field(compare=False, default=True)

@classmethod
def from_value(
cls, value: bool | dict | MaterializedOptions
) -> MaterializedOptions | Literal[False]:
if not value:
return False

if value is True:
return MaterializedOptions()

if isinstance(value, dict):
return MaterializedOptions(**value)

return value


@dataclass
class View(base.View):
"""Represent a postgres-specific view.
Per the deprecation on the base `View` object: There exists a `materialized`
argument on this class, which has no effect on this class. It will be removed
when the attribute is removed from the base class. The `constraints` field however,
will remain, as this class is the new destination.
"""

@classmethod
@override
def coerce_from_unknown(cls, unknown: Any) -> View:
if isinstance(unknown, View):
return unknown

if isinstance(unknown, base.DeclarativeView):
return cls(
unknown.name,
unknown.view_def,
unknown.schema,
materialized=unknown.materialized,
constraints=unknown.constraints,
)

try:
import alembic_utils # noqa
except ImportError: # pragma: no cover
pass
else:
from alembic_utils.pg_materialized_view import PGMaterializedView
from alembic_utils.pg_view import PGView

if isinstance(unknown, PGView):
return cls(
name=unknown.signature,
definition=unknown.definition,
schema=unknown.schema,
)

if isinstance(unknown, PGMaterializedView):
return cls(
name=unknown.signature,
definition=unknown.definition,
schema=unknown.schema,
materialized=MaterializedOptions(with_data=unknown.with_data),
)

result = super().coerce_from_unknown(unknown)
if result.materialized:
return cls(
name=result.name,
definition=result.definition,
schema=result.schema,
materialized=True,
constraints=result.constraints,
)

return cls(
name=result.name,
definition=result.definition,
schema=result.schema,
)

def to_sql_create(self, dialect: Dialect) -> list[str]:
definition = self.compile_definition(dialect).removesuffix(";")

components = ["CREATE"]
if self.materialized:
components.append("MATERIALIZED")

components.append("VIEW")
components.append(self.qualified_name)
components.append("AS")
components.append(definition)

materialized_options = MaterializedOptions.from_value(self.materialized)

if self.materialized:
assert isinstance(self.materialized, MaterializedOptions)
if materialized_options and not materialized_options.with_data:
components.append("WITH NO DATA")

statement = " ".join(components) + ";"

result = [statement]
result.extend(self.render_constraints(create=True))

return result

def normalize(
self, conn: Connection, metadata: MetaData, using_connection: bool = True
) -> View:
instance = super().normalize(conn, metadata, using_connection)
return replace(
instance,
materialized=MaterializedOptions.from_value(self.materialized),
)
6 changes: 6 additions & 0 deletions src/sqlalchemy_declarative_extensions/dialects/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
get_views_sqlite,
)
from sqlalchemy_declarative_extensions.sqlalchemy import dialect_dispatch, select
from sqlalchemy_declarative_extensions.view import View

get_schemas = dialect_dispatch(
postgresql=get_schemas_postgresql,
Expand Down Expand Up @@ -74,6 +75,11 @@
mysql=get_views_mysql,
)

get_view_cls = dialect_dispatch(
postgresql=lambda _: postgresql.View,
default=lambda _: View,
)

get_view = dialect_dispatch(
postgresql=get_view_postgresql,
)
Expand Down
1 change: 0 additions & 1 deletion src/sqlalchemy_declarative_extensions/role/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ def compare_roles(connection: Connection, roles: Roles) -> list[Operation]:
result.append(CreateRoleOp(role))
else:
existing_role = existing_roles_by_name[role_name]
role_cls = type(existing_role)

# An input role might be defined as a more general `Role` while
# the `existing_role` will always be a concrete dialect-specific version.
Expand Down
5 changes: 3 additions & 2 deletions src/sqlalchemy_declarative_extensions/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def dialect_dispatch(
sqlite: Callable[Concatenate[Connection, P], T] | None = None,
mysql: Callable[Concatenate[Connection, P], T] | None = None,
snowflake: Callable[Concatenate[Connection, P], T] | None = None,
default: Callable[Concatenate[Connection, P], T] | None = None,
) -> Callable[Concatenate[Connection, P], T]:
dispatchers = {
"postgresql": postgresql,
Expand All @@ -35,10 +36,10 @@ def dialect_dispatch(

def dispatch(connection: Connection, *args: P.args, **kwargs: P.kwargs) -> T:
dialect_name = connection.dialect.name
if dialect_name == "pmrsqlite":
if "sqlite" in dialect_name:
dialect_name = "sqlite"

dispatcher = dispatchers.get(dialect_name)
dispatcher = dispatchers.get(dialect_name) or default
if dispatcher is None: # pragma: no cover
raise NotImplementedError(
f"'{dialect_name}' is not yet supported for this operation."
Expand Down
Loading

0 comments on commit aa12d8a

Please sign in to comment.