Skip to content

Commit 1bed08e

Browse files
MySQL schema change to support fractional time (#984)
# Pull Request ## Title MySQL schema change to support fractional time ______________________________________________________________________ ## Description - Changes the storage schema to support a variant for MySQL that can handle fractional time. - Adds a custom column comparator to be able to pick that up by default in the future. - Adjusts tests to check for fractional time support. ______________________________________________________________________ ## Type of Change - 🛠️ Bug fix - ✨ New feature - 🔄 Refactor - 🧪 Tests ______________________________________________________________________ ## Testing - A lot of manual testing with docker. - Additional CI tests - Existing CI tests ______________________________________________________________________ ## Additional Notes (optional) Part of a series of changes, should be merged after #983 ______________________________________________________________________ --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent c060039 commit 1bed08e

File tree

11 files changed

+508
-51
lines changed

11 files changed

+508
-51
lines changed

mlos_bench/mlos_bench/storage/sql/alembic.ini

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
6363
# output_encoding = utf-8
6464

6565
# See README.md for details.
66+
# Uncomment one of these:
6667
sqlalchemy.url = sqlite:///mlos_bench.sqlite
68+
#sqlalchemy.url = mysql+mysqlconnector://root:password@localhost:3306/mlos_bench
69+
#sqlalchemy.url = postgresql+psycopg2://root:password@localhost:5432/mlos_bench
6770

6871

6972
[post_write_hooks]
@@ -72,10 +75,10 @@ sqlalchemy.url = sqlite:///mlos_bench.sqlite
7275
# detail and examples
7376

7477
# format using "black" - use the console_scripts runner, against the "black" entrypoint
75-
# hooks = black
76-
# black.type = console_scripts
77-
# black.entrypoint = black
78-
# black.options = -l 79 REVISION_SCRIPT_FILENAME
78+
hooks = black
79+
black.type = console_scripts
80+
black.entrypoint = black
81+
black.options = REVISION_SCRIPT_FILENAME
7982

8083
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
8184
# hooks = ruff

mlos_bench/mlos_bench/storage/sql/alembic/README.md

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,49 +4,144 @@ This document contains some notes on how to use [`alembic`](https://alembic.sqla
44

55
## Overview
66

7-
1. Create a blank `mlos_bench.sqlite` database file in the [`mlos_bench/storage/sql`](../) directory with the current schema using the following command:
7+
1. Create a blank database instance in the [`mlos_bench/storage/sql`](../) directory with the current schema using the following command:
88

9-
```sh
10-
cd mlos_bench/storage/sql
11-
rm mlos_bench.sqlite
12-
mlos_bench --storage storage/sqlite.jsonc --create-update-storage-schema-only
13-
```
9+
This allows `alembic` to automatically generate a migration script from the current schema.
10+
11+
> NOTE: If your schema changes target a particular backend engine (e.g., using `with_variant`) you will need to use an engine with that config for this step.
12+
> \
13+
> In the remainder of this document we should some examples for different DB types.
14+
> Pick the one you're targeting and stick with it thru the example.
15+
> You may need to repeat the process several times to test all of them.
16+
>
17+
> - [ ] TODO: Add scripts to automatically do this for several different backend engines all at once.
18+
19+
For instance:
20+
21+
1. Start a temporary server either as a local file or in a docker instance
22+
23+
```sh
24+
# sqlite
25+
cd mlos_bench/storage/sql
26+
rm -f mlos_bench.sqlite
27+
```
28+
29+
```sh
30+
# mysql
31+
docker run -it --rm --name mysql-alembic --env MYSQL_ROOT_PASSWORD=password --env MYSQL_DATABASE=mlos_bench -p 3306:3306 mysql:latest
32+
```
33+
34+
```sh
35+
# postgres
36+
docker run -it --rm --name postgres-alembic --env POSTGRES_PASSWORD=password --env POSTGRES_DB=mlos_bench -p 5432:5432 postgres:latest
37+
```
38+
39+
1. Adjust the `sqlalchemy.url` in the [`alembic.ini`](../alembic.ini) file.
1440

15-
> This allows `alembic` to automatically generate a migration script from the current schema.
41+
```ini
42+
# Uncomment one of these.
43+
# See README.md for details.
1644
17-
1. Adjust the [`mlos_bench/storage/sql/schema.py`](../schema.py) file to reflect the new desired schema.
45+
#sqlalchemy.url = sqlite:///mlos_bench.sqlite
46+
sqlalchemy.url = mysql+pymysql://root:password@localhost:3306/mlos_bench
47+
#sqlalchemy.url = postgresql+psycopg2://root:password@localhost:5432/mlos_bench
48+
```
1849

50+
1. Prime the DB schema
51+
52+
> Note: you may want to `git checkout main` first to make sure you're using the current schema here.
53+
54+
```sh
55+
# sqlite
56+
mlos_bench --storage storage/sqlite.jsonc --create-update-storage-schema-only --password=password
57+
```
58+
59+
```sh
60+
# mysql
61+
mlos_bench --storage storage/mysql.jsonc --create-update-storage-schema-only --password=password
62+
```
63+
64+
```sh
65+
# postgres
66+
mlos_bench --storage storage/postgresql.jsonc --create-update-storage-schema-only --password=password
67+
```
68+
69+
1. Now, adjust the [`mlos_bench/storage/sql/schema.py`](../schema.py) file to reflect the new desired schema.
70+
71+
> Don't forget to do this on a new branch.
72+
> \
1973
> Keep each change small and atomic.
74+
> \
2075
> For example, if you want to add a new column, do that in one change.
2176
> If you want to rename a column, do that in another change.
2277

2378
1. Generate a new migration script with the following command:
2479

2580
```sh
26-
alembic revision --autogenerate -m "Descriptive text about the change."
81+
alembic revision --autogenerate -m "CHANGEME: Descriptive text about the change."
2782
```
2883

2984
1. Review the generated migration script in the [`mlos_bench/storage/sql/alembic/versions`](./versions/) directory.
3085

3186
1. Verify that the migration script works by running the following command:
3287

3388
```sh
89+
# sqlite
3490
mlos_bench --storage storage/sqlite.jsonc --create-update-storage-schema-only
3591
```
3692

93+
```sh
94+
# mysql:
95+
mlos_bench --storage storage/mysql.jsonc --create-update-storage-schema-only --password=password
96+
```
97+
98+
```sh
99+
# postgres:
100+
mlos_bench --storage storage/postgresql.jsonc --create-update-storage-schema-only --password=password
101+
```
102+
37103
> Normally this would be done with `alembic upgrade head`, but this command is convenient to ensure if will work with the `mlos_bench` command line interface as well.
38104

39105
Examine the results using something like:
40106

41107
```sh
108+
# For sqlite:
42109
sqlite3 mlos_bench.sqlite .schema
43110
sqlite3 mlos_bench.sqlite "SELECT * FROM alembic_version;"
44111
```
45112

113+
```sh
114+
# For mysql:
115+
mysql --user root --password=password --host localhost --protocol tcp --database mlos_bench -e "SHOW TABLES; SELECT * FROM alembic_version;"
116+
```
117+
118+
```sh
119+
# For postgres:
120+
PGPASSWORD=password psql -h localhost -p 5432 -U postgres mlos_bench -c "SELECT table_name FROM information_schema.tables WHERE table_schema='public' and table_catalog='mlos_bench'; SELECT * FROM alembic_version;"
121+
```
122+
123+
> Use different CLI clients for targeting other engines.
124+
46125
1. If the migration script works, commit the changes to the [`mlos_bench/storage/sql/schema.py`](../schema.py) and [`mlos_bench/storage/sql/alembic/versions`](./versions/) files.
47126

48127
> Be sure to update the latest version in the [`test_storage_schemas.py`](../../../tests/storage/sql/test_storage_schemas.py) file as well.
49128

129+
1. Cleanup any server instances you started.
130+
131+
For instance:
132+
133+
```sh
134+
rm mlos_bench/storage/sql/mlos_bench.sqlite
135+
```
136+
137+
```sh
138+
docker kill mysql-alembic
139+
```
140+
141+
```sh
142+
docker kill postgres-alembic
143+
```
144+
50145
1. Merge that to the `main` branch.
51146

52147
1. Might be good to cut a new `mlos_bench` release at this point as well.

mlos_bench/mlos_bench/storage/sql/alembic/env.py

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@
55
"""Alembic environment script."""
66
# pylint: disable=no-member
77

8+
import logging
89
import sys
910
from logging.config import fileConfig
1011

1112
from alembic import context
12-
from sqlalchemy import engine_from_config, pool
13+
from alembic.migration import MigrationContext
14+
from sqlalchemy import create_engine, engine_from_config, pool
15+
from sqlalchemy.dialects import mysql
16+
from sqlalchemy.schema import Column as SchemaColumn
17+
from sqlalchemy.sql.schema import Column as MetadataColumn
18+
from sqlalchemy.types import TypeEngine
1319

1420
from mlos_bench.storage.sql.schema import DbSchema
1521

@@ -22,17 +28,137 @@
2228
# Don't override the mlos_bench or pytest loggers though.
2329
if config.config_file_name is not None and "alembic" in sys.argv[0]:
2430
fileConfig(config.config_file_name)
31+
alembic_logger = logging.getLogger("alembic")
2532

2633
# add your model's MetaData object here
2734
# for 'autogenerate' support
28-
target_metadata = DbSchema(engine=None).meta
35+
# NOTE: We override the alembic.ini file programmatically in storage/sql/schema.py
36+
# However, the alembic.ini file value is used during alembic CLI operations
37+
# (e.g., dev ops extending the schema).
38+
sqlalchemy_url = config.get_main_option("sqlalchemy.url")
39+
if not sqlalchemy_url:
40+
raise ValueError("Missing sqlalchemy.url: schema changes may not be accurate.")
41+
engine = create_engine(sqlalchemy_url)
42+
alembic_logger.info("engine.url %s", str(engine.url))
43+
target_metadata = DbSchema(engine=engine).meta
2944

3045
# other values from the config, defined by the needs of env.py,
3146
# can be acquired:
3247
# my_important_option = config.get_main_option("my_important_option")
3348
# ... etc.
3449

3550

51+
def fq_class_name(t: object) -> str:
52+
"""Return the fully qualified class name of a type."""
53+
return t.__module__ + "." + t.__class__.__name__
54+
55+
56+
def custom_compare_types(
57+
migration_context: MigrationContext,
58+
inspected_column: SchemaColumn | None,
59+
metadata_column: MetadataColumn,
60+
inspected_type: TypeEngine,
61+
metadata_type: TypeEngine,
62+
) -> bool | None:
63+
"""
64+
Custom column type comparator.
65+
66+
See `Comparing Types
67+
<https://alembic.sqlalchemy.org/en/latest/autogenerate.html#comparing-types>`_
68+
documentation for more details.
69+
70+
Notes
71+
-----
72+
In the case of a MySQL DateTime variant, it makes sure that the floating
73+
point accuracy is met.
74+
75+
Returns
76+
-------
77+
result : bool | None
78+
Returns True if the column specifications don't match the column (i.e.,
79+
a change is needed).
80+
Returns False when the column specification and column match.
81+
Returns None to fallback to the default comparator logic.
82+
"""
83+
metadata_dialect_type = metadata_type.dialect_impl(migration_context.dialect)
84+
if alembic_logger.isEnabledFor(logging.DEBUG):
85+
alembic_logger.debug(
86+
(
87+
"Comparing columns: "
88+
"inspected_column: [%s] %s and "
89+
"metadata_column: [%s (%s)] %s "
90+
"inspected_column.__dict__: %s\n"
91+
"inspected_column.dialect_options: %s\n"
92+
"inspected_column.dialect_kwargs: %s\n"
93+
"inspected_type.__dict__: %s\n"
94+
"metadata_column.__dict__: %s\n"
95+
"metadata_type.__dict__: %s\n"
96+
"metadata_dialect_type.__dict__: %s\n"
97+
),
98+
fq_class_name(inspected_type),
99+
inspected_column,
100+
fq_class_name(metadata_type),
101+
fq_class_name(metadata_dialect_type),
102+
metadata_column,
103+
inspected_column.__dict__,
104+
dict(inspected_column.dialect_options) if inspected_column is not None else None,
105+
dict(inspected_column.dialect_kwargs) if inspected_column is not None else None,
106+
inspected_type.__dict__,
107+
metadata_column.__dict__,
108+
metadata_type.__dict__,
109+
metadata_dialect_type.__dict__,
110+
)
111+
112+
# Implement a more detailed DATETIME precision comparison for MySQL.
113+
# Note: Currently also handles MariaDB.
114+
if migration_context.dialect.name == "mysql":
115+
if isinstance(metadata_dialect_type, (mysql.DATETIME, mysql.TIMESTAMP)):
116+
if not isinstance(inspected_type, type(metadata_dialect_type)):
117+
alembic_logger.info(
118+
"inspected_type %s does not match metadata_dialect_type %s",
119+
fq_class_name(inspected_type),
120+
fq_class_name(metadata_dialect_type),
121+
)
122+
return True
123+
else:
124+
assert isinstance(
125+
inspected_type, (mysql.DATETIME, mysql.TIMESTAMP)
126+
), "Expected inspected_type to be a MySQL DATETIME or TIMESTAMP type."
127+
if inspected_type.fsp != metadata_dialect_type.fsp:
128+
alembic_logger.info(
129+
"inspected_type.fsp (%s) and metadata_dialect_type.fsp (%s) don't match",
130+
inspected_type.fsp,
131+
metadata_dialect_type.fsp,
132+
)
133+
return True
134+
135+
if inspected_type.timezone != metadata_dialect_type.timezone:
136+
alembic_logger.info(
137+
(
138+
"inspected_type.timezone (%s) and "
139+
"metadata_dialect_type.timezone (%s) don't match"
140+
),
141+
inspected_type.timezone,
142+
metadata_dialect_type.timezone,
143+
)
144+
return True
145+
146+
if alembic_logger.isEnabledFor(logging.DEBUG):
147+
alembic_logger.debug(
148+
(
149+
"Using default compare_type behavior for "
150+
"inspected_column: [%s] %s and "
151+
"metadata_column: [%s (%s)] %s (see above for details).\n"
152+
),
153+
fq_class_name(inspected_type),
154+
inspected_column,
155+
fq_class_name(metadata_type),
156+
fq_class_name(metadata_dialect_type),
157+
metadata_column,
158+
)
159+
return None # fallback to default comparison behavior
160+
161+
36162
def run_migrations_offline() -> None:
37163
"""
38164
Run migrations in 'offline' mode.
@@ -49,6 +175,7 @@ def run_migrations_offline() -> None:
49175
target_metadata=target_metadata,
50176
literal_binds=True,
51177
dialect_opts={"paramstyle": "named"},
178+
compare_type=custom_compare_types,
52179
)
53180

54181
with context.begin_transaction():
@@ -74,12 +201,20 @@ def run_migrations_online() -> None:
74201
)
75202

76203
with connectable.connect() as connection:
77-
context.configure(connection=connection, target_metadata=target_metadata)
204+
context.configure(
205+
connection=connection,
206+
target_metadata=target_metadata,
207+
compare_type=custom_compare_types,
208+
)
78209

79210
with context.begin_transaction():
80211
context.run_migrations()
81212
else:
82-
context.configure(connection=connectable, target_metadata=target_metadata)
213+
context.configure(
214+
connection=connectable,
215+
target_metadata=target_metadata,
216+
compare_type=custom_compare_types,
217+
)
83218

84219
with context.begin_transaction():
85220
context.run_migrations()

0 commit comments

Comments
 (0)