diff --git a/docs/source/views.md b/docs/source/views.md index 8e30e51..34e14b1 100644 --- a/docs/source/views.md +++ b/docs/source/views.md @@ -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..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__`): @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 3c936ee..655c82b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "sqlalchemy-declarative-extensions" -version = "0.8.3" +version = "0.9.0" authors = ["Dan Cardin "] description = "Library to declare additional kinds of objects not natively supported by SQLAlchemy/Alembic." diff --git a/src/sqlalchemy_declarative_extensions/dialects/__init__.py b/src/sqlalchemy_declarative_extensions/dialects/__init__.py index 1c3a2bf..8a56400 100644 --- a/src/sqlalchemy_declarative_extensions/dialects/__init__.py +++ b/src/sqlalchemy_declarative_extensions/dialects/__init__.py @@ -13,6 +13,7 @@ get_schemas, get_triggers, get_view, + get_view_cls, get_views, ) @@ -22,6 +23,7 @@ "get_current_schema", "get_default_grants", "get_function_cls", + "get_functions", "get_grants", "get_objects", "get_role_cls", @@ -29,8 +31,8 @@ "get_schemas", "get_triggers", "get_view", + "get_view_cls", "get_views", - "get_functions", "mysql", "postgresql", "sqlite", diff --git a/src/sqlalchemy_declarative_extensions/dialects/postgresql/__init__.py b/src/sqlalchemy_declarative_extensions/dialects/postgresql/__init__.py index 98da4d3..1c62e2d 100644 --- a/src/sqlalchemy_declarative_extensions/dialects/postgresql/__init__.py +++ b/src/sqlalchemy_declarative_extensions/dialects/postgresql/__init__.py @@ -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", @@ -35,6 +40,7 @@ "GrantStatement", "GrantStatement", "GrantTypes", + "MaterializedOptions", "Role", "SchemaGrants", "SequenceGrants", @@ -44,4 +50,6 @@ "TriggerForEach", "TriggerTimes", "TypeGrants", + "View", + "ViewIndex", ] diff --git a/src/sqlalchemy_declarative_extensions/dialects/postgresql/query.py b/src/sqlalchemy_declarative_extensions/dialects/postgresql/query.py index ec170f8..30cf75d 100644 --- a/src/sqlalchemy_declarative_extensions/dialects/postgresql/query.py +++ b/src/sqlalchemy_declarative_extensions/dialects/postgresql/query.py @@ -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, @@ -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): @@ -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) diff --git a/src/sqlalchemy_declarative_extensions/dialects/postgresql/view.py b/src/sqlalchemy_declarative_extensions/dialects/postgresql/view.py new file mode 100644 index 0000000..059e8b0 --- /dev/null +++ b/src/sqlalchemy_declarative_extensions/dialects/postgresql/view.py @@ -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).strip(";") + + 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), + ) diff --git a/src/sqlalchemy_declarative_extensions/dialects/query.py b/src/sqlalchemy_declarative_extensions/dialects/query.py index a0ebfc2..f2057eb 100644 --- a/src/sqlalchemy_declarative_extensions/dialects/query.py +++ b/src/sqlalchemy_declarative_extensions/dialects/query.py @@ -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, @@ -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, ) diff --git a/src/sqlalchemy_declarative_extensions/role/compare.py b/src/sqlalchemy_declarative_extensions/role/compare.py index c89fe8d..96bc628 100644 --- a/src/sqlalchemy_declarative_extensions/role/compare.py +++ b/src/sqlalchemy_declarative_extensions/role/compare.py @@ -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. diff --git a/src/sqlalchemy_declarative_extensions/sqlalchemy.py b/src/sqlalchemy_declarative_extensions/sqlalchemy.py index 3d05b74..b766710 100644 --- a/src/sqlalchemy_declarative_extensions/sqlalchemy.py +++ b/src/sqlalchemy_declarative_extensions/sqlalchemy.py @@ -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, @@ -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." diff --git a/src/sqlalchemy_declarative_extensions/view/base.py b/src/sqlalchemy_declarative_extensions/view/base.py index 78b70aa..72ca7a8 100644 --- a/src/sqlalchemy_declarative_extensions/view/base.py +++ b/src/sqlalchemy_declarative_extensions/view/base.py @@ -1,8 +1,9 @@ from __future__ import annotations import uuid +import warnings from dataclasses import dataclass, field, replace -from typing import Any, Callable, Iterable, List, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Optional, TypeVar, cast from sqlalchemy import Index, MetaData, UniqueConstraint, text from sqlalchemy.engine import Connection, Dialect @@ -11,6 +12,7 @@ from sqlalchemy.sql.elements import conv from sqlalchemy.sql.naming import ConventionDict from sqlalchemy.sql.schema import DEFAULT_NAMING_CONVENTION +from typing_extensions import Self from sqlalchemy_declarative_extensions.sql import qualify_name from sqlalchemy_declarative_extensions.sqlalchemy import ( @@ -19,10 +21,21 @@ escape_params, ) +if TYPE_CHECKING: + from sqlalchemy_declarative_extensions.dialects.postgresql import ( + MaterializedOptions, + ) + T = TypeVar("T") +ViewType = TypeVar("ViewType", "View", "DeclarativeView") -def view(base, materialized: bool = False, register_as_model=False) -> Callable[[T], T]: +def view( + base, + *, + register_as_model=False, + materialized: bool | dict | MaterializedOptions = False, +) -> Callable[[T], T]: """Decorate a class or declarative base model in order to register a View. Given some object with the attributes: `__tablename__`, (optionally for schema) `__table_args__`, @@ -38,10 +51,12 @@ def view(base, materialized: bool = False, register_as_model=False) -> Callable[ Arguments: base: A declarative base object - materialized: Whether the view should be a materialized view register_as_model: Whether the view should be registered as a SQLAlchemy mapped object. Note this only works if the view defines mappable models columns (minimally a primary key), like a proper modeled table + materialized: Whether a view should be materialized or not. Accepts a `bool` for default + options, a dialect-specific `MaterializedOptions` variant, or a dict describing that + dialect-specific variant. >>> try: ... from sqlalchemy.orm import declarative_base @@ -64,19 +79,7 @@ def view(base, materialized: bool = False, register_as_model=False) -> Callable[ raise ValueError("Model must have a 'metadata' attribute.") def decorator(cls): - name = cls.__tablename__ - table_args = getattr(cls, "__table_args__", None) - view_def = cls.__view__ - - schema = find_schema(table_args) - constraints = find_constraints(table_args) - instance = View( - name, - view_def, - schema=schema, - materialized=materialized, - constraints=constraints, - ) + instance = DeclarativeView(cls, materialized) register_view(base, instance) @@ -92,7 +95,7 @@ def instrument_sqlalchemy(base: T, cls) -> T: return create_mapper(cls, temp_metadata) -def register_view(base_or_metadata: HasMetaData | MetaData, view: View): +def register_view(base_or_metadata: HasMetaData | MetaData, view: ViewType): """Register a view onto the given declarative base or `Metadata`. This can be used instead of the [view](view) decorator, if you are constructing @@ -110,6 +113,52 @@ def register_view(base_or_metadata: HasMetaData | MetaData, view: View): metadata.info["views"].append(view) +@dataclass +class DeclarativeView: + cls: type + materialized: bool | dict | MaterializedOptions + + @property + def name(self): + return self.cls.__tablename__ + + @property + def table_args(self): + return getattr(self.cls, "__table_args__", None) + + @property + def view_def(self): + return self.cls.__view__ + + @property + def schema(self): + table_args = self.table_args + if isinstance(table_args, dict): + return table_args.get("schema") + + if isinstance(table_args, Iterable): + for table_arg in table_args: + if isinstance(table_arg, dict): + return table_arg.get("schema") + + return None + + @property + def constraints(self): + table_args = self.table_args + if isinstance(table_args, dict): + return None + + if isinstance(table_args, Iterable): + return [ + arg + for arg in table_args + if isinstance(arg, (UniqueConstraint, ViewIndex, Index)) + ] + + return None + + @dataclass class View: """Definition of a view. @@ -126,30 +175,22 @@ class View: name: str definition: str | Select schema: str | None = None - materialized: bool = False + materialized: bool | dict | MaterializedOptions = False constraints: list[Index | UniqueConstraint | ViewIndex] | None = field(default=None) @classmethod def coerce_from_unknown(cls, unknown: Any) -> View: if isinstance(unknown, View): + if unknown.materialized or unknown.constraints: + warnings.warn( + "`materialized` and `constraints` have been relocated to dialect-specific MaterializedView variants.", + DeprecationWarning, + ) + return unknown - 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, PGMaterializedView)): - materialized = isinstance(unknown, PGMaterializedView) - return cls( - name=unknown.signature, - definition=unknown.definition, - schema=unknown.schema, - materialized=materialized, - ) + if isinstance(unknown, DeclarativeView): + return cls(unknown.name, unknown.view_def, unknown.schema) raise NotImplementedError( f"Unsupported view source object {unknown}" @@ -242,7 +283,7 @@ def render_constraints(self, *, create): def normalize( self, conn: Connection, metadata: MetaData, using_connection: bool = True - ) -> View: + ) -> Self: constraints = None if self.constraints: constraints = [ @@ -257,16 +298,11 @@ def normalize( ) def to_sql_create(self, dialect: Dialect) -> list[str]: - definition = self.compile_definition(dialect) + assert self.materialized is False - components = ["CREATE"] - if self.materialized: - components.append("MATERIALIZED") + definition = self.compile_definition(dialect) - components.append("VIEW") - components.append(self.qualified_name) - components.append("AS") - components.append(definition) + components = ["CREATE", "VIEW", self.qualified_name, "AS", definition] statement = " ".join(components) result = [statement] @@ -311,75 +347,6 @@ def to_sql_drop(self, dialect: Dialect) -> list[str]: return result -@dataclass -class Views: - """The collection of views and associated options comparisons. - - Note: `Views` supports views being specified from certain alternative sources, such - as `alembic_utils`'s `PGView` and `PGMaterializedView`. In order for that to work, - one needs to either call `View.coerce_from_unknown(alembic_utils_view)` directly, or - use `Views().are(...)` (which internally calls `coerce_from_unknown`). - - Note: `ignore` option accepts a list of strings. Each string is individually - interpreted as a "glob". This means a string like "foo.*" would ignore all views - contained within the schema "foo". - """ - - views: list[View] = field(default_factory=list) - - ignore_unspecified: bool = False - - ignore: Iterable[str] = field(default_factory=set) - ignore_views: Iterable[str] = field(default_factory=set) - - @classmethod - def coerce_from_unknown( - cls, unknown: None | Iterable[View] | Views - ) -> Views | None: - if isinstance(unknown, Views): - return unknown - - if isinstance(unknown, Iterable): - return cls().are(*unknown) - - return None - - def append(self, view: View): - self.views.append(view) - - def __iter__(self): - yield from self.views - - def are(self, *views: View): - return replace(self, views=[View.coerce_from_unknown(v) for v in views]) - - -def find_schema(table_args=None): - if isinstance(table_args, dict): - return table_args.get("schema") - - if isinstance(table_args, Iterable): - for table_arg in table_args: - if isinstance(table_arg, dict): - return table_arg.get("schema") - - return None - - -def find_constraints(table_args=None): - if isinstance(table_args, dict): - return None - - if isinstance(table_args, Iterable): - return [ - arg - for arg in table_args - if isinstance(arg, (UniqueConstraint, ViewIndex, Index)) - ] - - return None - - @dataclass class ViewIndex: columns: list[str] @@ -509,3 +476,46 @@ class _ColumnNamingAdapter: @property def _ddl_label(self): return self.name + + +@dataclass +class Views: + """The collection of views and associated options comparisons. + + Note: `Views` supports views being specified from certain alternative sources, such + as `alembic_utils`'s `PGView` and `PGMaterializedView`. In order for that to work, + one needs to either call `View.coerce_from_unknown(alembic_utils_view)` directly, or + use `Views().are(...)` (which internally calls `coerce_from_unknown`). + + Note: `ignore` option accepts a list of strings. Each string is individually + interpreted as a "glob". This means a string like "foo.*" would ignore all views + contained within the schema "foo". + """ + + views: list[View | DeclarativeView] = field(default_factory=list) + + ignore_unspecified: bool = False + + ignore: Iterable[str] = field(default_factory=set) + ignore_views: Iterable[str] = field(default_factory=set) + + @classmethod + def coerce_from_unknown( + cls, unknown: None | Iterable[View] | Views + ) -> Views | None: + if isinstance(unknown, Views): + return unknown + + if isinstance(unknown, Iterable): + return cls().are(*unknown) + + return None + + def append(self, view: View | DeclarativeView): + self.views.append(view) + + def __iter__(self): + yield from self.views + + def are(self, *views: View): + return replace(self, views=list(views)) diff --git a/src/sqlalchemy_declarative_extensions/view/compare.py b/src/sqlalchemy_declarative_extensions/view/compare.py index ccf7f7b..3847a77 100644 --- a/src/sqlalchemy_declarative_extensions/view/compare.py +++ b/src/sqlalchemy_declarative_extensions/view/compare.py @@ -8,7 +8,7 @@ from sqlalchemy import MetaData from sqlalchemy.engine import Connection, Dialect -from sqlalchemy_declarative_extensions.dialects import get_views +from sqlalchemy_declarative_extensions.dialects import get_view_cls, get_views from sqlalchemy_declarative_extensions.view.base import View, Views @@ -63,7 +63,12 @@ def compare_views( result: list[Operation] = [] - views_by_name = {r.qualified_name: r for r in views.views} + view_cls = get_view_cls(connection) + concrete_defined_views: list[View] = [ + view_cls.coerce_from_unknown(view) for view in views.views + ] + + views_by_name = {r.qualified_name: r for r in concrete_defined_views} expected_view_names = set(views_by_name) existing_views = get_views(connection) @@ -73,7 +78,7 @@ def compare_views( new_view_names = expected_view_names - existing_view_names removed_view_names = existing_view_names - expected_view_names - for view in views: + for view in concrete_defined_views: view_name = view.qualified_name ignore_matches = any( diff --git a/tests/view/test_add_constraint_to_existing.py b/tests/view/test_add_constraint_to_existing.py index 5f53874..d382037 100644 --- a/tests/view/test_add_constraint_to_existing.py +++ b/tests/view/test_add_constraint_to_existing.py @@ -4,11 +4,11 @@ from sqlalchemy_declarative_extensions import ( Row, Rows, - View, declarative_database, register_sqlalchemy_events, register_view, ) +from sqlalchemy_declarative_extensions.dialects.postgresql import View from sqlalchemy_declarative_extensions.sqlalchemy import declarative_base _Base = declarative_base() diff --git a/tests/view/test_convert_to_materialized.py b/tests/view/test_convert_to_materialized.py index 6229a83..8d15a4e 100644 --- a/tests/view/test_convert_to_materialized.py +++ b/tests/view/test_convert_to_materialized.py @@ -5,11 +5,11 @@ Row, Rows, Schemas, - View, declarative_database, register_sqlalchemy_events, register_view, ) +from sqlalchemy_declarative_extensions.dialects.postgresql import View from sqlalchemy_declarative_extensions.sqlalchemy import declarative_base _Base = declarative_base() diff --git a/tests/view/test_only_constraint_changes.py b/tests/view/test_only_constraint_changes.py index 42f6a32..46c7700 100644 --- a/tests/view/test_only_constraint_changes.py +++ b/tests/view/test_only_constraint_changes.py @@ -3,12 +3,12 @@ from sqlalchemy_declarative_extensions import ( Schemas, - View, ViewIndex, declarative_database, register_sqlalchemy_events, register_view, ) +from sqlalchemy_declarative_extensions.dialects.postgresql import View from sqlalchemy_declarative_extensions.sqlalchemy import declarative_base from sqlalchemy_declarative_extensions.view.compare import compare_views diff --git a/tests/view/test_only_different_constraints_drop.py b/tests/view/test_only_different_constraints_drop.py index 9edbbf0..350f5c5 100644 --- a/tests/view/test_only_different_constraints_drop.py +++ b/tests/view/test_only_different_constraints_drop.py @@ -3,12 +3,12 @@ from sqlalchemy_declarative_extensions import ( Schemas, - View, ViewIndex, declarative_database, register_sqlalchemy_events, register_view, ) +from sqlalchemy_declarative_extensions.dialects.postgresql import View from sqlalchemy_declarative_extensions.sqlalchemy import declarative_base from sqlalchemy_declarative_extensions.view.compare import compare_views diff --git a/tests/view/test_pg_materialized_no_data.py b/tests/view/test_pg_materialized_no_data.py new file mode 100644 index 0000000..f95d7f9 --- /dev/null +++ b/tests/view/test_pg_materialized_no_data.py @@ -0,0 +1,117 @@ +import pytest +import sqlalchemy.exc +from pytest_mock_resources import create_postgres_fixture +from sqlalchemy import Column, text, types + +from sqlalchemy_declarative_extensions import ( + Row, + Rows, + declarative_database, + register_sqlalchemy_events, + view, +) +from sqlalchemy_declarative_extensions.dialects.postgresql import MaterializedOptions +from sqlalchemy_declarative_extensions.sqlalchemy import declarative_base +from tests import skip_sqlalchemy13 + +_Base = declarative_base() + + +@declarative_database +class Base(_Base): # type: ignore + __abstract__ = True + + rows = Rows().are( + Row("foo", id=1), Row("foo", id=2), Row("foo", id=12), Row("foo", id=13) + ) + + +class Foo(Base): + __tablename__ = "foo" + + id = Column(types.Integer(), primary_key=True) + + +@view( + Base, + materialized=MaterializedOptions(with_data=True), + register_as_model=True, +) +class Eager: + __tablename__ = "eager" + __view__ = "select id from foo where id < 10" + + id = Column(types.Integer(), primary_key=True) + + +@view( + Base, + materialized=MaterializedOptions(with_data=False), + register_as_model=True, +) +class Lazy: + __tablename__ = "lazy" + __view__ = "select id from foo where id < 10" + + id = Column(types.Integer(), primary_key=True) + + +@view(Base, materialized={"with_data": False}, register_as_model=True) +class Lazy2: + __tablename__ = "lazy2" + __view__ = "select id from foo where id < 10" + + id = Column(types.Integer(), primary_key=True) + + +register_sqlalchemy_events(Base.metadata, schemas=True, views=True, rows=True) + +pg = create_postgres_fixture( + scope="function", engine_kwargs={"echo": True}, session=True +) + + +@skip_sqlalchemy13 +def test_create_view_postgresql_eager(pg): + Base.metadata.create_all(bind=pg.connection()) + pg.commit() + + result = [f.id for f in pg.query(Foo).all()] + assert result == [1, 2, 12, 13] + + result = [f.id for f in pg.query(Eager).all()] + assert result == [] + + pg.execute(text("refresh materialized view eager")) + result = [f.id for f in pg.query(Eager).all()] + assert result == [1, 2] + + +@skip_sqlalchemy13 +def test_create_view_postgresql_lazy(pg): + Base.metadata.create_all(bind=pg.connection()) + pg.commit() + + with pytest.raises(sqlalchemy.exc.OperationalError) as e: + pg.query(Lazy).all() + assert 'materialized view "lazy" has not been populated' in str(e) + + pg.rollback() + pg.execute(text("refresh materialized view lazy")) + result = [f.id for f in pg.query(Lazy).all()] + assert result == [1, 2] + + +@skip_sqlalchemy13 +def test_create_view_postgresql_lazy2(pg): + Base.metadata.create_all(bind=pg.connection()) + pg.commit() + + with pytest.raises(sqlalchemy.exc.OperationalError) as e: + pg.query(Lazy2).all() + assert 'materialized view "lazy2" has not been populated' in str(e) + + pg.rollback() + pg.execute(text("refresh materialized view lazy2")) + result = [f.id for f in pg.query(Lazy2).all()] + assert result == [1, 2]