Skip to content

Commit 4d5a17c

Browse files
author
Jim Fulton
authored
feat: Alembic support (#183)
1 parent 6d17084 commit 4d5a17c

File tree

6 files changed

+271
-7
lines changed

6 files changed

+271
-7
lines changed

docs/alembic.rst

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
Alembic support
2+
---------------
3+
4+
`Alembic <https://alembic.sqlalchemy.org>`_ is a lightweight database
5+
migration tool for usage with the SQLAlchemy Database Toolkit for
6+
Python. It can use this BigQuery SQLAlchemy support to manage
7+
BigQuery shemas.
8+
9+
Some features, like management of constrains and indexes, aren't
10+
supported because `BigQuery doesn't support them
11+
<https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language>`_.
12+
13+
Supported operations:
14+
15+
`add_column(table_name, column, schema=None)
16+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.add_column>`_
17+
18+
`alter_column(table_name, column_name, nullable=None, schema=None)
19+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.alter_column>`_
20+
21+
`bulk_insert(table, rows, multiinsert=True)
22+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.bulk_insert>`_
23+
24+
`create_table(table_name, *columns, **kw)
25+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.create_table>`_
26+
27+
`create_table_comment(table_name, comment, schema=None)
28+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.create_table_comment>`_
29+
30+
`drop_column(table_name, column_name, schema=None)
31+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.drop_column>`_
32+
33+
`drop_table(table_name, schema=None)
34+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.drop_table>`_
35+
36+
`drop_table_comment(table_name, schema=None)
37+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.drop_table_comment>`_
38+
39+
`execute(sqltext, execution_options=None)
40+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.execute>`_
41+
42+
`rename_table(old_table_name, new_table_name, schema=None)
43+
<https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.rename_table>`_
44+
45+
Note that some of the operations above have limited capability, again
46+
do to `BigQuery limitations
47+
<https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language>`_.
48+
49+
The `execute` operation allows access to BigQuery-specific
50+
`data-definition-language
51+
<https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language>`_.

docs/index.rst

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
.. include:: multiprocessing.rst
44

5+
.. include:: alembic.rst
6+
57
API Reference
68
-------------
79
.. toctree::

noxfile.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,30 @@ def lint_setup_py(session):
8282
session.run("python", "setup.py", "check", "--restructuredtext", "--strict")
8383

8484

85+
def install_alembic_for_python_38(session, constraints_path):
86+
"""
87+
install alembic for Python 3.8 unit and system tests
88+
89+
We don't require alembic and most tests should run without it, however
90+
91+
- We run some unit tests (Python 3.8) to cover the alembic
92+
registration that happens when alembic is installed.
93+
94+
- We have a system test that demonstrates working with alembic and
95+
proves that the things we think should work do work. :)
96+
"""
97+
if session.python == "3.8":
98+
session.install("alembic", "-c", constraints_path)
99+
100+
85101
def default(session):
86102
# Install all test dependencies, then install this package in-place.
87103

88104
constraints_path = str(
89105
CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt"
90106
)
91107
session.install("mock", "pytest", "pytest-cov", "-c", constraints_path)
92-
108+
install_alembic_for_python_38(session, constraints_path)
93109
session.install("-e", ".", "-c", constraints_path)
94110

95111
# Run py.test against the unit tests.
@@ -142,6 +158,7 @@ def system(session):
142158
# Install all test dependencies, then install this package into the
143159
# virtualenv's dist-packages.
144160
session.install("mock", "pytest", "google-cloud-testutils", "-c", constraints_path)
161+
install_alembic_for_python_38(session, constraints_path)
145162
session.install("-e", ".", "-c", constraints_path)
146163

147164
# Run py.test against the system tests.

pybigquery/sqlalchemy_bigquery.py

+11
Original file line numberDiff line numberDiff line change
@@ -966,3 +966,14 @@ def get_view_definition(self, connection, view_name, schema=None, **kw):
966966
view_name = f"{self.dataset_id}.{view_name}"
967967
view = client.get_table(view_name)
968968
return view.view_query
969+
970+
971+
try:
972+
import alembic # noqa
973+
except ImportError:
974+
pass
975+
else:
976+
from alembic.ddl import impl
977+
978+
class PyBigQueryImpl(impl.DefaultImpl):
979+
__dialect__ = "bigquery"

tests/system/conftest.py

+19-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1-
# Copyright 2021 The PyBigQuery Authors
1+
# Copyright (c) 2021 The PyBigQuery Authors
22
#
3-
# Use of this source code is governed by an MIT-style
4-
# license that can be found in the LICENSE file or at
5-
# https://opensource.org/licenses/MIT.
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy of
4+
# this software and associated documentation files (the "Software"), to deal in
5+
# the Software without restriction, including without limitation the rights to
6+
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7+
# the Software, and to permit persons to whom the Software is furnished to do so,
8+
# subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in all
11+
# copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15+
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16+
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17+
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18+
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
619

720
import datetime
821
import pathlib
@@ -55,7 +68,7 @@ def bigquery_schema(bigquery_client: bigquery.Client):
5568
return bigquery_client.schema_from_json(DATA_DIR / "schema.json")
5669

5770

58-
@pytest.fixture(scope="session", autouse=True)
71+
@pytest.fixture(scope="session")
5972
def bigquery_dataset(
6073
bigquery_client: bigquery.Client, bigquery_schema: List[bigquery.SchemaField]
6174
):
@@ -96,7 +109,7 @@ def bigquery_empty_table(
96109
return table_id
97110

98111

99-
@pytest.fixture(scope="session", autouse=True)
112+
@pytest.fixture(scope="session")
100113
def bigquery_regional_dataset(bigquery_client, bigquery_schema):
101114
project_id = bigquery_client.project
102115
dataset_id = f"test_pybigquery_location_{temp_suffix()}"

tests/system/test_alembic.py

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Copyright (c) 2021 The PyBigQuery Authors
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy of
4+
# this software and associated documentation files (the "Software"), to deal in
5+
# the Software without restriction, including without limitation the rights to
6+
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7+
# the Software, and to permit persons to whom the Software is furnished to do so,
8+
# subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in all
11+
# copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15+
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16+
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17+
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18+
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19+
20+
import contextlib
21+
22+
import pytest
23+
from sqlalchemy import Column, DateTime, Integer, String
24+
25+
try:
26+
import alembic # noqa
27+
except ImportError:
28+
alembic = None
29+
30+
import google.api_core.exceptions
31+
32+
33+
@pytest.fixture
34+
def alembic_table(bigquery_dataset, bigquery_client):
35+
import sqlalchemy
36+
import alembic.migration
37+
import alembic.operations
38+
39+
def get_table(table_name, data="table"):
40+
try:
41+
table_id = f"{bigquery_dataset}.{table_name}"
42+
if data == "rows":
43+
return [dict(r) for r in bigquery_client.list_rows(table_id)]
44+
else:
45+
table = bigquery_client.get_table(table_id)
46+
if data == "table":
47+
return table
48+
elif data == "schema":
49+
return [
50+
repr(s).replace(", (), None)", ")").replace(", None)", ")")
51+
for s in table.schema
52+
]
53+
else:
54+
raise ValueError(data)
55+
except google.api_core.exceptions.NotFound:
56+
return None
57+
58+
engine = sqlalchemy.create_engine(f"bigquery:///{bigquery_dataset}")
59+
with contextlib.closing(engine.connect()) as conn:
60+
migration_context = alembic.migration.MigrationContext.configure(conn, {})
61+
with alembic.operations.Operations.context(migration_context):
62+
yield get_table
63+
64+
65+
@pytest.mark.skipif(alembic is None, reason="Alembic isn't installed.")
66+
def test_alembic_scenario(alembic_table):
67+
"""
68+
Exercise all of the operations we support.
69+
70+
It's a little awkward because we have to avoid doing too many
71+
operations on the same table to avoid tripping over limits on
72+
table mods within a short time.
73+
"""
74+
from alembic import op
75+
76+
assert alembic_table("account") is None
77+
78+
account = op.create_table(
79+
"account",
80+
Column("id", Integer, nullable=False),
81+
Column("name", String(50), nullable=False, comment="The name"),
82+
Column("description", String(200)),
83+
)
84+
assert alembic_table("account", "schema") == [
85+
"SchemaField('id', 'INTEGER', 'REQUIRED')",
86+
"SchemaField('name', 'STRING(50)', 'REQUIRED', 'The name')",
87+
"SchemaField('description', 'STRING(200)', 'NULLABLE')",
88+
]
89+
90+
op.bulk_insert(
91+
account,
92+
[
93+
dict(id=1, name="home", description="the home account"),
94+
dict(id=2, name="operations", description="the ops account"),
95+
dict(id=3, name="savings", description=None),
96+
],
97+
)
98+
99+
assert alembic_table("account", "rows") == [
100+
{"description": "the home account", "id": 1, "name": "home"},
101+
{"description": "the ops account", "id": 2, "name": "operations"},
102+
{"description": None, "id": 3, "name": "savings"},
103+
]
104+
105+
op.add_column(
106+
"account", Column("last_transaction_date", DateTime, comment="when updated")
107+
)
108+
109+
assert alembic_table("account", "schema") == [
110+
"SchemaField('id', 'INTEGER', 'REQUIRED')",
111+
"SchemaField('name', 'STRING(50)', 'REQUIRED', 'The name')",
112+
"SchemaField('description', 'STRING(200)', 'NULLABLE')",
113+
"SchemaField('last_transaction_date', 'DATETIME', 'NULLABLE', 'when updated')",
114+
]
115+
116+
op.create_table(
117+
"account_w_comment",
118+
Column("id", Integer, nullable=False),
119+
Column("name", String(50), nullable=False, comment="The name"),
120+
Column("description", String(200)),
121+
comment="This table has comments",
122+
)
123+
assert alembic_table("account_w_comment").description == "This table has comments"
124+
op.drop_table_comment("account_w_comment")
125+
assert alembic_table("account_w_comment").description is None
126+
127+
op.drop_column("account_w_comment", "description")
128+
assert alembic_table("account_w_comment", "schema") == [
129+
"SchemaField('id', 'INTEGER', 'REQUIRED')",
130+
"SchemaField('name', 'STRING(50)', 'REQUIRED', 'The name')",
131+
]
132+
133+
op.drop_table("account_w_comment")
134+
assert alembic_table("account_w_comment") is None
135+
136+
op.rename_table("account", "accounts")
137+
assert alembic_table("account") is None
138+
assert alembic_table("accounts", "schema") == [
139+
"SchemaField('id', 'INTEGER', 'REQUIRED')",
140+
"SchemaField('name', 'STRING(50)', 'REQUIRED', 'The name')",
141+
"SchemaField('description', 'STRING(200)', 'NULLABLE')",
142+
"SchemaField('last_transaction_date', 'DATETIME', 'NULLABLE', 'when updated')",
143+
]
144+
op.drop_table("accounts")
145+
assert alembic_table("accounts") is None
146+
147+
op.execute(
148+
"""
149+
create table transactions(
150+
account INT64 NOT NULL,
151+
transaction_time DATETIME NOT NULL,
152+
amount NUMERIC(11, 2) NOT NULL
153+
)
154+
partition by DATE(transaction_time)
155+
"""
156+
)
157+
158+
# The only thing we can alter about a column is we can make it
159+
# nullable:
160+
op.alter_column("transactions", "amount", True)
161+
assert alembic_table("transactions", "schema") == [
162+
"SchemaField('account', 'INTEGER', 'REQUIRED')",
163+
"SchemaField('transaction_time', 'DATETIME', 'REQUIRED')",
164+
"SchemaField('amount', 'NUMERIC(11, 2)', 'NULLABLE')",
165+
]
166+
167+
op.create_table_comment("transactions", "Transaction log")
168+
assert alembic_table("transactions").description == "Transaction log"
169+
170+
op.drop_table("transactions")

0 commit comments

Comments
 (0)