Skip to content

Commit 5679c16

Browse files
authored
Merge pull request #108 from UW-Macrostrat/maps-migrations
Maps migrations
2 parents ba8ba26 + a3851c2 commit 5679c16

File tree

8 files changed

+188
-88
lines changed

8 files changed

+188
-88
lines changed

cli/macrostrat/cli/database/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from pathlib import Path
23
from sys import exit, stderr, stdin, stdout
34
from typing import Any, Callable, Iterable
45

@@ -10,6 +11,7 @@
1011

1112
from macrostrat.core import MacrostratSubsystem, app
1213
from macrostrat.core.migrations import run_migrations
14+
from macrostrat.database import Database
1315
from macrostrat.database.transfer import pg_dump_to_file, pg_restore_from_file
1416
from macrostrat.database.transfer.utils import raw_database_url
1517
from macrostrat.database.utils import get_sql_files
@@ -20,7 +22,7 @@
2022

2123
# First, register all migrations
2224
# NOTE: right now, this is quite implicit.
23-
from .migrations import *
25+
from .migrations import load_migrations
2426
from .utils import engine_for_db_name
2527

2628
log = get_logger(__name__)
@@ -32,6 +34,9 @@
3234
DBCallable = Callable[[Database], None]
3335

3436

37+
load_migrations()
38+
39+
3540
class SubsystemSchemaDefinition(BaseModel):
3641
"""A schema definition managed by a Macrostrat subsystem"""
3742

Lines changed: 8 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,14 @@
1+
from importlib import import_module
12
from pathlib import Path
23

3-
from macrostrat.core.migrations import ApplicationStatus, Migration
4-
from macrostrat.database import Database
5-
6-
from . import (
7-
api_v3,
8-
baseline,
9-
column_builder,
10-
macrostrat_mariadb,
11-
map_source_slugs,
12-
map_sources,
13-
maps_scale_custom_type,
14-
maps_source_operations,
15-
partition_carto,
16-
partition_maps,
17-
points,
18-
tileserver,
19-
update_macrostrat,
20-
)
21-
224
__dir__ = Path(__file__).parent
235

6+
# Import all submodules within this directory using importlib
247

25-
class StorageSchemeMigration(Migration):
26-
name = "storage-scheme"
27-
28-
depends_on = ["api-v3"]
29-
30-
def apply(self, db: Database):
31-
db.run_sql(
32-
"""
33-
CREATE TYPE storage.scheme AS ENUM ('s3', 'https', 'http');
34-
ALTER TYPE storage.scheme ADD VALUE 'https' AFTER 's3';
35-
ALTER TYPE storage.scheme ADD VALUE 'http' AFTER 'https';
36-
37-
-- Lock the table to prevent concurrent updates
38-
LOCK TABLE storage.object IN ACCESS EXCLUSIVE MODE;
39-
40-
ALTER TABLE storage.object
41-
ALTER COLUMN scheme
42-
TYPE storage.scheme USING scheme::text::storage.scheme;
43-
44-
-- Unlock the table
45-
COMMIT;
46-
47-
DROP TYPE IF EXISTS macrostrat.schemeenum;
48-
"""
49-
)
50-
51-
def should_apply(self, db: Database):
52-
if has_enum(db, "schemeenum", schema="macrostrat"):
53-
return ApplicationStatus.CAN_APPLY
54-
else:
55-
return ApplicationStatus.APPLIED
56-
57-
58-
def has_enum(db: Database, name: str, schema: str = None):
59-
sql = "select 1 from pg_type where typname = :name"
60-
if schema is not None:
61-
sql += (
62-
" and typnamespace = (select oid from pg_namespace where nspname = :schema)"
63-
)
648

65-
return db.run_query(
66-
f"select exists ({sql})", dict(name=name, schema=schema)
67-
).scalar()
9+
def load_migrations():
10+
for module in __dir__.iterdir():
11+
if module.is_file() and module.suffix == ".py" and module.stem != "__init__":
12+
import_module(f".{module.stem}", package=__name__)
13+
elif module.is_dir() and (module / "__init__.py").exists():
14+
import_module(f".{module.stem}", package=__name__)

cli/macrostrat/cli/database/migrations/map_sources/01-maps-sources.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
ALTER TABLE maps.sources ADD COLUMN IF NOT EXISTS raster_url text;
22

3+
DROP VIEW IF EXISTS macrostrat_api.sources_metadata CASCADE;
34
DROP VIEW IF EXISTS maps.sources_metadata CASCADE;
5+
46
CREATE OR REPLACE VIEW maps.sources_metadata AS
57
SELECT
68
s.source_id,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from macrostrat.core.migrations import Migration, _not, custom_type_exists
2+
from macrostrat.database import Database
3+
4+
5+
class MapsIngestStateCustomTypeMigration(Migration):
6+
name = "ingest-state-type"
7+
subsystem = "maps"
8+
description = """
9+
- Relocate custom types that drives the map ingestion process.
10+
- Remove duplicate custom types from the public schema.
11+
"""
12+
13+
depends_on = ["baseline", "macrostrat-mariadb"]
14+
15+
postconditions = [
16+
custom_type_exists("maps", "ingest_state"),
17+
custom_type_exists("maps", "ingest_type"),
18+
_not(custom_type_exists("public", "ingest_state")),
19+
_not(custom_type_exists("public", "ingest_type")),
20+
]
21+
22+
preconditions = []
23+
24+
def apply(self, db: Database):
25+
# Handle edge case where the MariaDB migration has already been applied
26+
db.run_sql("ALTER TYPE macrostrat_backup.ingest_state SET SCHEMA macrostrat")
27+
db.run_sql("ALTER TYPE macrostrat.ingest_state SET SCHEMA maps")
28+
29+
db.run_sql("ALTER TYPE macrostrat_backup.ingest_type SET SCHEMA macrostrat")
30+
db.run_sql("ALTER TYPE macrostrat.ingest_type SET SCHEMA maps")
31+
32+
db.run_sql("DROP TYPE IF EXISTS public.ingest_state")
33+
db.run_sql("DROP TYPE IF EXISTS public.ingest_type")
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from macrostrat.core.migrations import ApplicationStatus, Migration
2+
from macrostrat.database import Database
3+
4+
5+
class StorageSchemeMigration(Migration):
6+
name = "storage-scheme"
7+
8+
depends_on = ["api-v3"]
9+
10+
def apply(self, db: Database):
11+
db.run_sql(
12+
"""
13+
CREATE TYPE storage.scheme AS ENUM ('s3', 'https', 'http');
14+
ALTER TYPE storage.scheme ADD VALUE 'https' AFTER 's3';
15+
ALTER TYPE storage.scheme ADD VALUE 'http' AFTER 'https';
16+
17+
-- Lock the table to prevent concurrent updates
18+
LOCK TABLE storage.object IN ACCESS EXCLUSIVE MODE;
19+
20+
ALTER TABLE storage.object
21+
ALTER COLUMN scheme
22+
TYPE storage.scheme USING scheme::text::storage.scheme;
23+
24+
-- Unlock the table
25+
COMMIT;
26+
27+
DROP TYPE IF EXISTS macrostrat.schemeenum;
28+
"""
29+
)
30+
31+
def should_apply(self, db: Database):
32+
if has_enum(db, "schemeenum", schema="macrostrat"):
33+
return ApplicationStatus.CAN_APPLY
34+
else:
35+
return ApplicationStatus.APPLIED
36+
37+
38+
def has_enum(db: Database, name: str, schema: str = None):
39+
sql = "select 1 from pg_type where typname = :name"
40+
if schema is not None:
41+
sql += (
42+
" and typnamespace = (select oid from pg_namespace where nspname = :schema)"
43+
)
44+
45+
return db.run_query(
46+
f"select exists ({sql})", dict(name=name, schema=schema)
47+
).scalar()

cli/macrostrat/cli/entrypoint.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
add_completion=True,
5858
rich_markup_mode="rich",
5959
help=help_text,
60+
backend=app.settings.backend,
6061
)
6162

6263
main.add_typer(

cli/macrostrat/cli/subsystems/macrostrat_api/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,13 @@ def on_schema_update(self):
5858
# fully reloaded the schema.
5959
if self.app.settings.get("compose_root", None) is not None:
6060
compose("kill -s SIGUSR1 postgrest")
61+
62+
63+
def check_view_is_changed(db, schema, view_name, new_statement):
64+
pass
65+
66+
67+
def get_view_definition(db, schema, view_name):
68+
"""Get the definition of a view in a schema"""
69+
_sql = "SELECT view_definition FROM information_schema.views WHERE table_schema = :schema AND table_name = :view_name"
70+
return db.run_query(_sql, dict(schema=schema, view_name=view_name)).scalar()

core/macrostrat/core/migrations/__init__.py

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ def exists(schema: str, *table_names: str) -> DbEvaluator:
2121

2222
def not_exists(schema: str, *table_names: str) -> DbEvaluator:
2323
"""Return a function that evaluates to true when every given table in the given schema doesn't exist"""
24-
return lambda db: all(
25-
not db.inspector.has_table(t, schema=schema) for t in table_names
26-
)
24+
return _not(exists(schema, *table_names))
2725

2826

2927
def schema_exists(schema: str) -> DbEvaluator:
@@ -50,6 +48,11 @@ def custom_type_exists(schema: str, *type_names: str) -> DbEvaluator:
5048
return lambda db: all(db.inspector.has_type(t, schema=schema) for t in type_names)
5149

5250

51+
def _not(f: DbEvaluator) -> DbEvaluator:
52+
"""Return a function that evaluates to true when the given function evaluates to false"""
53+
return lambda db: not f(db)
54+
55+
5356
class ApplicationStatus(Enum):
5457
"""Enum for the possible"""
5558

@@ -109,6 +112,16 @@ def apply(self, database: Database):
109112
database.run_fixtures(child_cls_dir)
110113

111114

115+
class MigrationState(Enum):
116+
"""Enum for the possible states of a migration before application"""
117+
118+
COMPLETE = "complete"
119+
UNMET_DEPENDENCIES = "unmet_dependencies"
120+
CANNOT_APPLY = "cannot_apply"
121+
SHOULD_APPLY = "should_apply"
122+
DISALLOWED = "disallowed"
123+
124+
112125
def run_migrations(
113126
apply: bool = False,
114127
name: str = None,
@@ -136,6 +149,11 @@ def run_migrations(
136149
# While iterating over migrations, keep track of which have already applied
137150
completed_migrations = []
138151

152+
# Get max width of migration names for formatting
153+
name_max_width = max(len(m.name) for m in instances)
154+
155+
print("Migrations:")
156+
139157
for _migration in instances:
140158
_name = _migration.name
141159
_subsystem = getattr(_migration, "subsystem", None)
@@ -153,39 +171,34 @@ def run_migrations(
153171
if subsystem is not None and subsystem != _subsystem:
154172
continue
155173

174+
_status = _get_status(_migration, completed_migrations)
175+
176+
_print_status(_name, _status, name_max_width=name_max_width)
177+
156178
# By default, don't run migrations that depend on other non-applied migrations
157179
dependencies_met = all(d in completed_migrations for d in _migration.depends_on)
158180
if not dependencies_met and not force:
159-
print(f"Dependencies not met for migration [cyan]{_name}[/cyan]")
160181
continue
161182

162-
if force or apply_status == ApplicationStatus.CAN_APPLY:
163-
if not apply:
164-
print(f"Would apply migration [cyan]{_name}[/cyan]")
165-
else:
166-
if _migration.destructive and not data_changes and not force:
167-
print(
168-
f"Migration [cyan]{_name}[/cyan] would alter data in the database. Run with --force or --data-changes"
169-
)
170-
return
171-
172-
print(f"Applying migration [cyan]{_name}[/cyan]")
173-
_migration.apply(db)
174-
# After running migration, reload the database and confirm that application was sucessful
175-
db = refresh_database()
176-
if _migration.should_apply(db) == ApplicationStatus.APPLIED:
177-
completed_migrations.append(_migration.name)
178-
elif apply_status == ApplicationStatus.APPLIED:
179-
print(f"Migration [cyan]{_name}[/cyan] already applied")
180-
else:
181-
print(f"Migration [cyan]{_name}[/cyan] cannot apply")
183+
if (force or apply_status == ApplicationStatus.CAN_APPLY) and apply:
184+
if _migration.destructive and not data_changes and not force:
185+
return
186+
187+
_migration.apply(db)
188+
# After running migration, reload the database and confirm that application was sucessful
189+
db = refresh_database()
190+
if _migration.should_apply(db) == ApplicationStatus.APPLIED:
191+
completed_migrations.append(_migration.name)
182192

183193
# Short circuit after applying the migration specified by --name
184194
if name is not None and name == _name:
185195
break
186196

187-
# Notify PostgREST to reload the schema cache
188-
db.run_sql("NOTIFY pgrst, 'reload schema';")
197+
if apply:
198+
# Notify PostgREST to reload the schema cache
199+
db.run_sql("NOTIFY pgrst, 'reload schema';")
200+
else:
201+
print("\n[dim]To apply the migrations, run with --apply")
189202

190203

191204
def migration_has_been_run(*names: str):
@@ -202,3 +215,45 @@ def migration_has_been_run(*names: str):
202215
if apply_status != ApplicationStatus.APPLIED:
203216
return True
204217
return False
218+
219+
220+
def _get_status(
221+
_migration: Migration, completed_migrations: set[str]
222+
) -> MigrationState:
223+
"""Get the status of a migration"""
224+
name = _migration.name
225+
226+
# By default, don't run migrations that depend on other non-applied migrations
227+
dependencies_met = all(d in completed_migrations for d in _migration.depends_on)
228+
if not dependencies_met and not force:
229+
return MigrationState.UNMET_DEPENDENCIES
230+
231+
if name in completed_migrations:
232+
return MigrationState.COMPLETE
233+
234+
if force or apply_status == ApplicationStatus.CAN_APPLY:
235+
if not apply:
236+
return MigrationState.SHOULD_APPLY
237+
else:
238+
if _migration.destructive and not data_changes and not force:
239+
return MigrationState.DISALLOWED
240+
return MigrationState.SHOULD_APPLY
241+
242+
return MigrationState.CANNOT_APPLY
243+
244+
245+
def _print_status(name, status: MigrationState, *, name_max_width=40):
246+
padding = " " * (name_max_width - len(name))
247+
print(f"- [bold cyan]{name}[/]: " + padding, end="")
248+
if status == MigrationState.COMPLETE:
249+
print("[green]already applied[/green]")
250+
elif status == MigrationState.UNMET_DEPENDENCIES:
251+
print("[yellow]has unmet dependencies[/yellow]")
252+
elif status == MigrationState.CANNOT_APPLY:
253+
print("[red]cannot be applied[/red]")
254+
elif status == MigrationState.SHOULD_APPLY:
255+
print("[yellow]should be applied[/yellow]")
256+
elif status == MigrationState.DISALLOWED:
257+
print("[red]cannot be applied without --force or --data-changes[/red]")
258+
else:
259+
raise ValueError(f"Unknown migration status: {status}")

0 commit comments

Comments
 (0)