Skip to content

Commit 6f43871

Browse files
committed
fix: Conversion from view to materialized view.
1 parent 1b5da69 commit 6f43871

9 files changed

Lines changed: 212 additions & 63 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
${{ runner.os }}-poetry-
4646
4747
- name: Install dependencies
48-
run: poetry install
48+
run: poetry install -E parse
4949

5050
- name: Install specific sqlalchemy version
5151
run: |

src/sqlalchemy_declarative_extensions/view/base.py

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212
from sqlalchemy_declarative_extensions.sql import qualify_name
1313
from sqlalchemy_declarative_extensions.sqlalchemy import HasMetaData
1414

15-
T = TypeVar("T", HasMetaData, MetaData)
15+
T = TypeVar("T", bound=HasMetaData)
1616

1717

18-
def view(base_or_metadata: T, materialized: bool = False) -> Callable[[type], T]:
18+
def view(base: T, materialized: bool = False) -> Callable[[type], T]:
1919
"""Decorate a class or declarative base model in order to register a View.
2020
2121
Given some object with the attributes: `__tablename__`, (optionally for schema) `__table_args__`,
@@ -48,7 +48,7 @@ def decorator(cls):
4848
table_args = getattr(cls, "__table_args__", None)
4949
view_def = cls.__view__
5050

51-
mapper = instrument_sqlalchemy(base_or_metadata, cls)
51+
mapper = instrument_sqlalchemy(base, cls)
5252

5353
schema = find_schema(table_args)
5454
constraints = find_constraints(table_args)
@@ -60,17 +60,15 @@ def decorator(cls):
6060
constraints=constraints,
6161
)
6262

63-
register_view(base_or_metadata, instance)
63+
register_view(base, instance)
6464

6565
return mapper # noqa
6666

6767
return decorator
6868

6969

70-
def instrument_sqlalchemy(base_or_metadata: T, cls) -> T:
71-
metadata = get_metadata(base_or_metadata)
72-
73-
temp_metadata = MetaData(naming_convention=metadata.naming_convention)
70+
def instrument_sqlalchemy(base: T, cls) -> T:
71+
temp_metadata = MetaData(naming_convention=base.metadata.naming_convention)
7472
try:
7573
try:
7674
from sqlalchemy import orm
@@ -87,15 +85,18 @@ def instrument_sqlalchemy(base_or_metadata: T, cls) -> T:
8785
return mapper
8886

8987

90-
def register_view(base_or_metadata: HasMetaData, view: View):
88+
def register_view(base_or_metadata: HasMetaData | MetaData, view: View):
9189
"""Register a view onto the given declarative base or `Metadata`.
9290
9391
This can be used instead of the [view](view) decorator, if you are constructing
9492
`View` objects directly. In this way, you can imperitively register views next
9593
to their corresponding table definitions, rather than at the root declarative
9694
base, like many of the other object types are documented to do.
9795
"""
98-
metadata = get_metadata(base_or_metadata)
96+
if isinstance(base_or_metadata, MetaData):
97+
metadata = base_or_metadata
98+
else:
99+
metadata = base_or_metadata.metadata
99100

100101
if not metadata.info.get("views"):
101102
metadata.info["views"] = Views()
@@ -128,7 +129,7 @@ def coerce_from_unknown(cls, unknown: Any) -> View:
128129

129130
try:
130131
import alembic_utils # noqa
131-
except ImportError:
132+
except ImportError: # pragma: no cover
132133
pass
133134
else:
134135
from alembic_utils.pg_materialized_view import PGMaterializedView
@@ -155,7 +156,7 @@ def render_definition(self, dialect: Dialect):
155156
try:
156157
import sqlglot
157158
from sqlglot.optimizer.normalize import normalize
158-
except ImportError:
159+
except ImportError: # pragma: no cover
159160
raise ImportError("View autogeneration requires the 'parse' extra.")
160161

161162
if isinstance(self.definition, str):
@@ -265,24 +266,22 @@ def append(self, view: View):
265266
self.views.append(view)
266267

267268
def __iter__(self):
268-
for grant in self.grants:
269-
yield grant
269+
for view in self.views:
270+
yield view
270271

271272
def are(self, *views: View):
272273
return replace(self, views=[View.coerce_from_unknown(v) for v in views])
273274

274275

275276
def find_schema(table_args=None):
276-
if table_args is None:
277-
return None
278-
279277
if isinstance(table_args, dict):
280278
return table_args.get("schema")
281279

282280
if isinstance(table_args, Iterable):
283281
for table_arg in table_args:
284282
if isinstance(table_arg, dict):
285283
return table_arg.get("schema")
284+
286285
return None
287286

288287

@@ -294,9 +293,3 @@ def find_constraints(table_args=None):
294293
return [table_arg for table_arg in table_args if isinstance(table_arg, Index)]
295294

296295
return None
297-
298-
299-
def get_metadata(base_or_metadata: T) -> MetaData:
300-
if isinstance(base_or_metadata, MetaData):
301-
return base_or_metadata
302-
return base_or_metadata.metadata

src/sqlalchemy_declarative_extensions/view/compare.py

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,6 @@
1313
class CreateViewOp:
1414
view: View
1515

16-
@classmethod
17-
def create_view(
18-
cls,
19-
operations,
20-
view_name: str,
21-
definition: str,
22-
*,
23-
schema: str | None = None,
24-
materialized: bool = False,
25-
):
26-
op = cls(View(view_name, definition, schema=schema, materialized=materialized))
27-
return operations.invoke(op)
28-
2916
def reverse(self):
3017
return DropViewOp(self.view)
3118

@@ -37,11 +24,6 @@ def to_sql(self, dialect: Dialect) -> list[str]:
3724
class DropViewOp:
3825
view: View
3926

40-
@classmethod
41-
def drop_view(cls, operations, view_name: str, schema: str | None = None):
42-
op = cls(View(view_name, definition="", schema=schema))
43-
return operations.invoke(op)
44-
4527
def reverse(self):
4628
return CreateViewOp(self.view)
4729

@@ -54,8 +36,6 @@ def to_sql(self, dialect: Dialect) -> list[str]:
5436

5537
def compare_views(connection: Connection, views: Views) -> list[Operation]:
5638
result: list[Operation] = []
57-
if not views:
58-
return result
5939

6040
views_by_name = {r.qualified_name: r for r in views.views}
6141
expected_view_names = set(views_by_name)
@@ -67,7 +47,7 @@ def compare_views(connection: Connection, views: Views) -> list[Operation]:
6747
new_view_names = expected_view_names - existing_view_names
6848
removed_view_names = existing_view_names - expected_view_names
6949

70-
for view in views.views:
50+
for view in views:
7151
view_name = view.qualified_name
7252

7353
if view_name in views.ignore_views:
@@ -83,7 +63,7 @@ def compare_views(connection: Connection, views: Views) -> list[Operation]:
8363
view_updated = not existing_view.equals(view, connection.dialect)
8464
if view_updated:
8565
existing_view = existing_views_by_name[view_name]
86-
result.append(DropViewOp(view))
66+
result.append(DropViewOp(existing_view))
8767
result.append(CreateViewOp(view))
8868

8969
if not views.ignore_unspecified:

tests/examples/test_view_drop_pg/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from sqlalchemy import Column, types
33
from sqlalchemy.ext.declarative import declarative_base
44

5-
from sqlalchemy_declarative_extensions import Row, Views, declarative_database
5+
from sqlalchemy_declarative_extensions import Row, declarative_database
66

77
_Base = declarative_base()
88

@@ -17,7 +17,7 @@ class Base(_Base):
1717
Row("foo", id=11),
1818
Row("foo", id=12),
1919
]
20-
views = Views()
20+
views = []
2121

2222

2323
class Foo(Base):
Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import sqlalchemy
2-
from sqlalchemy import Column, select, types
2+
from sqlalchemy import Column, types
33
from sqlalchemy.ext.declarative import declarative_base
44

5-
from sqlalchemy_declarative_extensions import Row, Views, declarative_database, view
5+
from sqlalchemy_declarative_extensions import Row, View, Views, declarative_database
66

77
_Base = declarative_base()
88

@@ -17,7 +17,7 @@ class Base(_Base):
1717
Row("foo", id=11),
1818
Row("foo", id=12),
1919
]
20-
views = Views()
20+
views = Views().are(View("bar", "select id from foo where id > 10"))
2121

2222

2323
class Foo(Base):
@@ -30,14 +30,3 @@ class Foo(Base):
3030
server_default=sqlalchemy.text("CURRENT_TIMESTAMP"),
3131
nullable=False,
3232
)
33-
34-
35-
foo_table = Foo.__table__
36-
37-
38-
@view(Base.metadata)
39-
class Bar:
40-
__tablename__ = "bar"
41-
__view__ = select(foo_table.c.id).where(foo_table.c.id > 10)
42-
43-
id = Column(types.Integer(), autoincrement=True, primary_key=True)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from pytest_mock_resources import create_postgres_fixture
2+
from sqlalchemy import Column, text, types
3+
from sqlalchemy.ext.declarative import declarative_base
4+
5+
from sqlalchemy_declarative_extensions import (
6+
Row,
7+
Rows,
8+
Schemas,
9+
View,
10+
declarative_database,
11+
register_sqlalchemy_events,
12+
register_view,
13+
)
14+
15+
Base_ = declarative_base()
16+
17+
18+
@declarative_database
19+
class Base(Base_):
20+
__abstract__ = True
21+
22+
schemas = Schemas().are("fooschema")
23+
rows = Rows().are(
24+
Row("fooschema.foo", id=1),
25+
Row("fooschema.foo", id=2),
26+
Row("fooschema.foo", id=12),
27+
Row("fooschema.foo", id=13),
28+
)
29+
30+
31+
class Foo(Base):
32+
__tablename__ = "foo"
33+
__table_args__ = {"schema": "fooschema"}
34+
35+
id = Column(types.Integer(), primary_key=True)
36+
37+
38+
# Register imperitively
39+
view = View(
40+
"bar",
41+
"select id from fooschema.foo where id < 10",
42+
schema="fooschema",
43+
materialized=True,
44+
)
45+
46+
register_view(Base.metadata, view)
47+
48+
49+
register_sqlalchemy_events(Base.metadata, schemas=True, views=True, rows=True)
50+
51+
pg = create_postgres_fixture(
52+
scope="function", engine_kwargs={"echo": True}, session=True
53+
)
54+
55+
56+
def test_create_view_postgresql(pg):
57+
pg.execute(text("CREATE SCHEMA fooschema"))
58+
pg.execute(text("CREATE TABLE fooschema.foo (id integer)"))
59+
60+
pg.execute(
61+
text(
62+
"CREATE VIEW fooschema.bar AS (SELECT id FROM fooschema.foo WHERE id < 10)"
63+
)
64+
)
65+
66+
Base.metadata.create_all(bind=pg.connection())
67+
68+
result = [f.id for f in pg.query(Foo).all()]
69+
assert result == [1, 2, 12, 13]
70+
71+
pg.execute(text("refresh materialized view fooschema.bar"))
72+
result = [f.id for f in pg.execute(text("select * from fooschema.bar")).all()]
73+
assert result == [1, 2]
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from pytest_mock_resources import create_postgres_fixture
2+
from sqlalchemy import Column, text, types
3+
from sqlalchemy.ext.declarative import declarative_base
4+
5+
from sqlalchemy_declarative_extensions import (
6+
Schemas,
7+
Views,
8+
declarative_database,
9+
register_sqlalchemy_events,
10+
)
11+
from sqlalchemy_declarative_extensions.view.compare import compare_views
12+
13+
Base_ = declarative_base()
14+
15+
16+
@declarative_database
17+
class Base(Base_):
18+
__abstract__ = True
19+
20+
schemas = Schemas().are("fooschema")
21+
views = Views(ignore_unspecified=True)
22+
23+
24+
class Foo(Base):
25+
__tablename__ = "foo"
26+
__table_args__ = {"schema": "fooschema"}
27+
28+
id = Column(types.Integer(), primary_key=True)
29+
30+
31+
register_sqlalchemy_events(Base.metadata, schemas=True, views=True, rows=True)
32+
33+
pg = create_postgres_fixture(
34+
scope="function", engine_kwargs={"echo": True}, session=True
35+
)
36+
37+
38+
def test_ignore_views(pg):
39+
Base.metadata.create_all(bind=pg.connection())
40+
41+
pg.execute(text("CREATE VIEW meow as (SELECT id from fooschema.foo)"))
42+
43+
# Verify this no longer sees changes to make! Failing here would imply the autogenerate
44+
# is not fully normalizing the difference.
45+
result = compare_views(pg.connection(), views=Base.views)
46+
assert result == []

0 commit comments

Comments
 (0)