Skip to content

Commit b9f1cc9

Browse files
authored
Merge pull request #9 from DanCardin/dc/view
2 parents 2a3c056 + 4cc6e1a commit b9f1cc9

158 files changed

Lines changed: 2662 additions & 476 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,10 @@ test:
1313
coverage xml
1414

1515
lint:
16-
flake8 --ignore=E203,W503 src tests
17-
isort --check-only src tests
18-
pydocstyle src tests
16+
ruff src tests
1917
mypy src tests
2018
black --check src tests
2119

2220
format:
23-
isort src tests
21+
ruff src tests --fix
2422
black src tests

README.md

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -126,43 +126,56 @@ include database specific objects.
126126

127127
## Alembic-utils
128128

129-
Currently, the set of supported declarative objects is essentially non-overlapping with
129+
Currently, the set of supported declarative objects is largely non-overlapping with
130130
[Alembic-utils](https://github.com/olirice/alembic_utils). However in principle, there's
131-
no reason that objects supported by this library couldn't begin to overlap (views, functions,
131+
no reason that objects supported by this library couldn't begin to overlap (functions,
132132
triggers); and one might begin to question when to use which library.
133133

134-
First, it's likely that this library can/should grow handlers for objects already supported by
135-
alembic-utils. In particular, it's likely that any future support in this library for something
136-
like a view could easily accept an `alembic_utils.pg_view.PGView` definition and handle it directly.
137-
The two libraries are likely fairly complementary in that way, although it's important to note
138-
some of the differences.
134+
Note that where possible this library tries to support alembic-utils native objects
135+
as stand-ins for the objects defined in this library. For example, `alembic_utils.pg_view.PGView`
136+
can be declared instead of a `sqlalchemy_declarative_extensions.View`, and we will internally
137+
coerce it into the appropriate type. Hopefully this eases any transitional costs, or
138+
issues using one or the other library.
139139

140140
Alembic utils:
141141

142-
- Is more directly tied to Alembic and specifically provides functionality for autogenerating
143-
DDL for alembic, as the name might imply. It does **not** register into sqlalchemy's event
144-
system.
145-
- Requires one to explicitly find/include the objects one wants to track with alembic.
146-
- It provides direct translation of individual entities (like a single, specific `PGGrantTable`).
147-
- In most cases, it appears to define a very "literal" interface (for example, `PGView` accepts
148-
the whole view definition as a raw literal string), rather than an abstracted one.
142+
1. Is more directly tied to Alembic and specifically provides functionality for autogenerating
143+
DDL for alembic, as the name might imply. It does **not** register into sqlalchemy's event
144+
system.
145+
146+
2. Requires one to explicitly find/include the objects one wants to track with alembic.
147+
148+
3. Declares single, specific object instances (like a single, specific `PGGrantTable`). This
149+
has the side-effect that it can only track included objects. It cannot, for example,
150+
remove objects which should not exist due to their omission.
151+
152+
4. In most cases, it appears to define a very "literal" interface (for example, `PGView` accepts
153+
the whole view definition as a raw literal string), rather than attempting to either abstract
154+
the objects or accept abstracted (like a `select` object) definition.
155+
156+
5. Appears to only be interested in supporting PostgreSQL.
149157

150158
By contrast, this library:
151159

152-
- SqlAlchemy is the main dependency and registration point. The primary function of the library
153-
is to register into sqlalchemy's event system to ensure that a `metadata.create_all` performs
154-
the requisite statements to ensure the state of the database matches the declaration.
155-
156-
This library does **not** require alembic, but it does (optionally) perform a similar function
157-
by way of enabling autogeneration support for non-native objects.
158-
159-
- Perhaps a technical detail, but this library registers the declaratively stated objects directly
160-
on the metadata/declarative-base. This allows the library to automatically know the intended
161-
state of the world, rather than needing to discover objects.
162-
- The intended purpose of the supported objects is to declare what the state of the world **should**
163-
look like. Therefore the function of this library includes the (optional) **removal** of objects
164-
detected to exist which are not declared (much like alembic does for tables). Whereas alembic-utils
165-
only operates on objects you create entities for.
166-
- As much as possible, this library provides more abstracted interfaces for defining objects.
167-
This is particularly important for objects like roles/grants where not every operation is a create
168-
or delete (in contrast to something like a view).
160+
1. SqlAlchemy is the main dependency and registration point (Alembic is, in fact, an optional dependency).
161+
The primary function of the library is to declare the underlying objects. And then registration into
162+
sqlalchemy's event system, or registration into alembic's detection system are both optional features.
163+
164+
2. Perhaps a technical detail, but this library registers the declaratively stated objects directly
165+
on the metadata/declarative-base. This allows the library to automatically know the intended
166+
state of the world, rather than needing to discover objects.
167+
168+
3. The intended purpose of the supported objects is to declare what the state of the world **should**
169+
look like. Therefore the function of this library includes the (optional) **removal** of objects
170+
detected to exist which are not declared (much like alembic does for tables).
171+
172+
4. As much as possible, this library provides more abstracted interfaces for defining objects.
173+
This is particularly important for objects like roles/grants where not every operation is a create
174+
or delete (in contrast to something like a view), where a raw SQL string makes it impossible to
175+
diff two different a-like objects.
176+
177+
5. Tries to define functionality in cross-dialect terms and only where required farm details out to
178+
dialect-specific handlers. Not to claim that all dialects are treated equally (currently only
179+
PostgreSQL has first-class support), but technically, there should be no reason we wouldn't support
180+
any supportable dialect. Today SQLite (for whatever that's worth), and MySQL have **some** level
181+
of support.

docs/source/api.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
# API
22

3-
## Schemas
4-
53
```{eval-rst}
64
.. autoapimodule:: sqlalchemy_declarative_extensions.schema
7-
:members:
8-
:noindex:
5+
:members: Schema, Schemas
96
```
107

11-
## Roles
8+
```{eval-rst}
9+
.. autoapimodule:: sqlalchemy_declarative_extensions.view
10+
:members: Views, View, view, register_view
11+
```
1212

1313
```{eval-rst}
1414
.. autoapimodule:: sqlalchemy_declarative_extensions.role.base
@@ -20,16 +20,19 @@
2020
:members: Role
2121
```
2222

23-
## Grants
24-
2523
```{eval-rst}
2624
.. autoapimodule:: sqlalchemy_declarative_extensions.grant
2725
:members: Grants
2826
```
2927

30-
## Rows
31-
3228
```{eval-rst}
3329
.. autoapimodule:: sqlalchemy_declarative_extensions.row
30+
:members: Row, Rows
31+
```
32+
33+
## Alembic
34+
35+
```{eval-rst}
36+
.. autoapimodule:: sqlalchemy_declarative_extensions.alembic
3437
:members:
3538
```

docs/source/conf.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@
4141
"tasklist",
4242
]
4343

44-
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
44+
intersphinx_mapping = {
45+
"python": ("https://docs.python.org/3", None),
46+
"sqlalchemy": ("https://docs.sqlalchemy.org/en/14/", None),
47+
}
4548

4649
autoapi_type = "python"
4750
autoapi_dirs = ["../../src/sqlalchemy_declarative_extensions"]

docs/source/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ API <api>
1616
:hidden:
1717
1818
Schemas <schemas>
19+
Views <views>
1920
Roles <roles>
2021
Grants <grants>
2122
Rows <rows>

docs/source/views.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Views
2+
3+
Views definition and registration can be performed exactly as it is done with other object
4+
types, by defining the set of views on the `MetaData` or declarative base, like so:
5+
6+
```python
7+
from sqlalchemy.ext.declarative import declarative_base
8+
from sqlalchemy_declarative_extensions import declarative_database, View, Views
9+
10+
_Base = declarative_base()
11+
12+
13+
@declarative_database()
14+
class Base(_Base):
15+
__abstract__ = True
16+
17+
views = Views().are(
18+
View("foo", "select * from bar where id > 10", schema="baz"),
19+
)
20+
```
21+
22+
And if you want to define views using raw strings, or otherwise not reference the tables
23+
produced off the `MetaData`, then this is absolutely a valid way to organize.
24+
25+
## The `view` decorator
26+
27+
However views differ from most of the other object types, in that they are convenient to
28+
define **in terms of** the tables they reference (i.e. your existing set of models/tables).
29+
In fact personally, all of my views are produced from [select](sqlalchemy.sql.expression.select) expressions
30+
referencing the underlying [Table](sqlalchemy.schema.Table) object.
31+
32+
This commonly introduce a circular reference problem wherein your tables/models are defined
33+
through subclassing the declarative base, which means your declarative base cannot then
34+
have the views statically defined **on** the base (while simultaneously referencing those models).
35+
36+
```{note}
37+
There are ways of working around this in SQLAlchemy-land. For example by creating a ``MetaData``
38+
ahead of time and defining all models in terms of their underlying ``Table``.
39+
40+
Or perhaps by using SQLAlchemy's mapper apis such that you're not subclassing the declarative base
41+
for models.
42+
43+
In any case, these options are more complex and probably atypical. As such, we cannot assume
44+
you will adopt them.
45+
```
46+
47+
For everyone else, the [view](sqlalchemy_declarative_extensions.view) decorator is meant to be the
48+
solution to that problem.
49+
50+
This strategy allows one to organize their views alongside the models/tables those
51+
views happen to be referencing, without requiring the view be importable at MetaData/model base
52+
definition time.
53+
54+
### Option 1
55+
56+
```python
57+
from sqlalchemy import Column, types, select
58+
from sqlalchemy.ext.declarative import declarative_base
59+
from sqlalchemy_declarative_extensions import view
60+
61+
Base = declarative_base()
62+
63+
64+
class Foo(Base):
65+
__tablename__ = 'foo'
66+
67+
id = Column(types.Integer, primary_key=True)
68+
69+
70+
@view()
71+
class Bar1(Base):
72+
__tablename__ = 'bar'
73+
__view__ = select(Foo.__table__).where(Foo.__table__.id > 10)
74+
75+
id = Column(types.Integer, primary_key=True)
76+
```
77+
78+
The primary difference between Options 1 and 2 in the above example is of how the
79+
resulting classes are seen by SQLAlchemy/Alembic natively.
80+
81+
In the case of `Bar1`, SQLAlchemy/Alembic actually think that class is a normal table.
82+
Therefore querying the view looks identical to a real table: `session.query(Bar1).all()`
83+
84+
For alembic, this means that alembic thinks you defined a table and will attempt to
85+
autogenerate it (while this library will also notice it and attempt to autogenerate
86+
a conflicting view.
87+
88+
In order to use this option, we suggest you use one or both of some utility functions provided
89+
under the `sqlalchemy_declarative_extensions.alembic`: [ignore_view_tables](sqlalchemy_declarative_extensions.alembic.ignore_view_tables)
90+
and [compose_include_object_callbacks](sqlalchemy_declarative_extensions.alembic.compose_include_object_callbacks).
91+
92+
Somewhere in your Alembic `env.py`, you will have a block which looks like this:
93+
94+
```python
95+
with connectable.connect() as connection:
96+
context.configure(
97+
connection=connection,
98+
target_metadata=target_metadata,
99+
...
100+
)
101+
```
102+
103+
The above call to `configure` accepts an `include_object`, which tells alembic to include or ignore
104+
all detected objects.
105+
106+
```python
107+
from sqlalchemy_declarative_extensions.alembic import ignore_view_tables
108+
...
109+
context.configure(..., include_object=ignore_view_tables)
110+
```
111+
112+
If you happen to already be using `include_object` to perform filtering, we provide an additional
113+
utility to more easily compose our version with your own. Although you can certainly manually call
114+
`ignore_view_tables` directly, yourself.
115+
116+
```python
117+
from sqlalchemy_declarative_extensions.alembic import ignore_view_tables, compose_include_object_callbacks
118+
...
119+
def my_include_object(object, *_):
120+
if object.name != 'foo':
121+
return True
122+
return False
123+
124+
context.configure(..., include_object=compose_include_object_callbacks(my_include_object, ignore_view_tables))
125+
```
126+
127+
## Option 2
128+
129+
```python
130+
from sqlalchemy import Column, types, select
131+
from sqlalchemy.ext.declarative import declarative_base
132+
from sqlalchemy_declarative_extensions import view
133+
134+
Base = declarative_base()
135+
136+
137+
class Foo(Base):
138+
__tablename__ = 'foo'
139+
140+
id = Column(types.Integer, primary_key=True)
141+
142+
143+
@view(Base) # or `@view(Base.metadata)`
144+
class Bar2:
145+
__tablename__ = 'bar'
146+
__view__ = select(Foo.__table__).where(Foo.__table__.id > 10)
147+
```
148+
149+
By contrast, with Option 2, your class is not subclassing `Base`, therefore it's
150+
not registered as a real table by SQLAlchemy or Alembic. There's no additional
151+
work required to get them to ignore the table, because it's not one.
152+
153+
Unfortunately, that means you cannot **invisibly** treat it as though it's a normal model,
154+
largely because it doesn't have the columns enumerated out in the same way.
155+
156+
However we can provide some basic support for treating it as a table as far as the ORM is concerned.
157+
For example, you can still `session.query(Bar2).all()` directly.
158+
159+
However, in most cases views primarily benefit non-code consumers of the database, because there's
160+
no practical difference between querying a literal view, versus executing the underlying query
161+
of that view, through something like `session.execute(Bar2.__view__)`.

0 commit comments

Comments
 (0)