Skip to content

Commit

Permalink
SqliteDosStorage: Make the migrator compatible with SQLite
Browse files Browse the repository at this point in the history
The majority of the `SqliteDosStorage` piggy-backs off of the
`PsqlDosStorage` plugin. It also uses the `PsqlDosMigrator` as-is to
perform the database migrations. This is not safe however, as PostgreSQL
and SQLite do not have exactly the same syntax.

An example is the `main_0002` revision which was added to drop the
hashes of certain nodes. This uses the `#-` operator which is JSONB
specific syntax of PostgreSQL and is not supported by SQLite. Since this
migration was added before the `SqliteDosStorage` plugin was added, this
has never caused a problems as all profiles would be new, would not have
any nodes and therefore the SQL code of the migration would not actually
be executed.

In preparation for any future migrations that may need to be added, the
`SqliteDosStorage` now uses the `SqliteDosMigrator`. This subclasses the
`PsqlDosMigrator` as it can still use most of the functionality, but it
changes a few critical things. Most notably the location of the schema
versions which now are kept individually and are no longer lent from the
`core.psql_dos` plugin.

The initial version `main_0001_initial.py` is taken from the migration
`main_0000_initial.py` of the `core.sqlite_zip` storage plugin. The only
difference is that UUID fields are declared as `String(32)` instead of
`CHAR(32)`. The SQLAlchemy models that are automatically generated for
SQLite from the PostgreSQL-based models actually use the latter type.
See `aiida.storage.sqlite_zip.models:pg_to_sqlite`.
  • Loading branch information
sphuber committed May 30, 2024
1 parent 06f8f4c commit 32543b0
Show file tree
Hide file tree
Showing 12 changed files with 1,354 additions and 18 deletions.
8 changes: 8 additions & 0 deletions src/aiida/storage/migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Module with common resources related to storage migrations."""

TEMPLATE_INVALID_SCHEMA_VERSION = """
Database schema version `{schema_version_database}` is incompatible with the required schema version `{schema_version_code}`.
To migrate the database schema version to the current one, run the following command:
verdi -p {profile_name} storage migrate
""" # noqa: E501
8 changes: 1 addition & 7 deletions src/aiida/storage/psql_dos/migrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from aiida.common import exceptions
from aiida.manage.configuration.profile import Profile
from aiida.storage.log import MIGRATE_LOGGER
from aiida.storage.migrations import TEMPLATE_INVALID_SCHEMA_VERSION
from aiida.storage.psql_dos.models.settings import DbSetting
from aiida.storage.psql_dos.utils import create_sqlalchemy_engine

Expand All @@ -46,13 +47,6 @@
verdi -p {profile_name} storage migrate
"""

TEMPLATE_INVALID_SCHEMA_VERSION = """
Database schema version `{schema_version_database}` is incompatible with the required schema version `{schema_version_code}`.
To migrate the database schema version to the current one, run the following command:
verdi -p {profile_name} storage migrate
""" # noqa: E501

ALEMBIC_REL_PATH = 'migrations'

REPOSITORY_UUID_KEY = 'repository|uuid'
Expand Down
114 changes: 104 additions & 10 deletions src/aiida/storage/sqlite_dos/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,36 @@

from __future__ import annotations

import pathlib
from functools import cached_property, lru_cache
from pathlib import Path
from shutil import rmtree
from typing import TYPE_CHECKING, Optional
from uuid import uuid4

from alembic.config import Config
from disk_objectstore import Container, backup_utils
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import insert
from sqlalchemy import insert, inspect, select
from sqlalchemy.orm import scoped_session, sessionmaker

from aiida.common import exceptions
from aiida.common.log import AIIDA_LOGGER
from aiida.manage import Profile
from aiida.manage.configuration.profile import Profile
from aiida.manage.configuration.settings import AIIDA_CONFIG_FOLDER
from aiida.orm.implementation import BackendEntity
from aiida.storage.log import MIGRATE_LOGGER
from aiida.storage.psql_dos.models.settings import DbSetting
from aiida.storage.sqlite_zip import models, orm
from aiida.storage.sqlite_zip.migrator import get_schema_version_head
from aiida.storage.sqlite_zip.utils import create_sqla_engine

from ..migrations import TEMPLATE_INVALID_SCHEMA_VERSION
from ..psql_dos import PsqlDosBackend
from ..psql_dos.migrator import REPOSITORY_UUID_KEY, PsqlDosMigrator
from ..psql_dos.migrator import PsqlDosMigrator

if TYPE_CHECKING:
from disk_objectstore import Container

Check warning on line 41 in src/aiida/storage/sqlite_dos/backend.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/storage/sqlite_dos/backend.py#L41

Added line #L41 was not covered by tests

from aiida.orm.entities import EntityTypes
from aiida.repository.backend import DiskObjectStoreRepositoryBackend

Expand All @@ -45,7 +50,19 @@
FILENAME_CONTAINER = 'container'


ALEMBIC_REL_PATH = 'migrations'

REPOSITORY_UUID_KEY = 'repository|uuid'


class SqliteDosMigrator(PsqlDosMigrator):
"""Class for validating and migrating `sqlite_dos` storage instances.
.. important:: This class should only be accessed via the storage backend class (apart from for test purposes)
"""

alembic_version_tbl_name = 'alembic_version'

"""Storage implementation using Sqlite database and disk-objectstore container.
This storage backend is not recommended for use in production. The sqlite database is not the most performant and it
Expand Down Expand Up @@ -91,6 +108,86 @@ def initialise_database(self) -> None:
context.stamp(context.script, 'main@head') # type: ignore[arg-type]
self.connection.commit()

def get_schema_version_profile(self) -> Optional[str]: # type: ignore[override]
"""Return the schema version of the backend instance for this profile.
Note, the version will be None if the database is empty or is a legacy django database.
"""
with self._migration_context() as context:
return context.get_current_revision()

@staticmethod
def _alembic_config():
"""Return an instance of an Alembic `Config`."""
dirpath = pathlib.Path(__file__).resolve().parent
config = Config()
config.set_main_option('script_location', str(dirpath / ALEMBIC_REL_PATH))
return config

def validate_storage(self) -> None:
"""Validate that the storage for this profile
1. That the database schema is at the head version, i.e. is compatible with the code API.
2. That the repository ID is equal to the UUID set in the database
:raises: :class:`aiida.common.exceptions.UnreachableStorage` if the storage cannot be connected to
:raises: :class:`aiida.common.exceptions.IncompatibleStorageSchema`
if the storage is not compatible with the code API.
:raises: :class:`aiida.common.exceptions.CorruptStorage`
if the repository ID is not equal to the UUID set in thedatabase.
"""
# check there is an alembic_version table from which to get the schema version
if not inspect(self.connection).has_table(self.alembic_version_tbl_name):
raise exceptions.IncompatibleStorageSchema('The database has no known version.')

Check warning on line 141 in src/aiida/storage/sqlite_dos/backend.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/storage/sqlite_dos/backend.py#L141

Added line #L141 was not covered by tests

# now we can check that the alembic version is the latest
schema_version_code = self.get_schema_version_head()
schema_version_database = self.get_schema_version_profile()
if schema_version_database != schema_version_code:
raise exceptions.IncompatibleStorageSchema(

Check warning on line 147 in src/aiida/storage/sqlite_dos/backend.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/storage/sqlite_dos/backend.py#L147

Added line #L147 was not covered by tests
TEMPLATE_INVALID_SCHEMA_VERSION.format(
schema_version_database=schema_version_database,
schema_version_code=schema_version_code,
profile_name=self.profile.name,
)
)

# finally, we check that the ID set within the disk-objectstore is equal to the one saved in the database,
# i.e. this container is indeed the one associated with the db
repository_uuid = self.get_repository_uuid()
stmt = select(DbSetting.val).where(DbSetting.key == REPOSITORY_UUID_KEY)
database_repository_uuid = self.connection.execute(stmt).scalar_one_or_none()
if database_repository_uuid is None:
raise exceptions.CorruptStorage('The database has no repository UUID set.')

Check warning on line 161 in src/aiida/storage/sqlite_dos/backend.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/storage/sqlite_dos/backend.py#L161

Added line #L161 was not covered by tests
if database_repository_uuid != repository_uuid:
raise exceptions.CorruptStorage(

Check warning on line 163 in src/aiida/storage/sqlite_dos/backend.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/storage/sqlite_dos/backend.py#L163

Added line #L163 was not covered by tests
f'The database has a repository UUID configured to {database_repository_uuid} '
f"but the disk-objectstore's is {repository_uuid}."
)

@property
def is_database_initialised(self) -> bool:
"""Return whether the database is initialised.
This is the case if it contains the table that holds the schema version for alembic.
:returns: ``True`` if the database is initialised, ``False`` otherwise.
"""
return inspect(self.connection).has_table(self.alembic_version_tbl_name)

Check warning on line 176 in src/aiida/storage/sqlite_dos/backend.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/storage/sqlite_dos/backend.py#L176

Added line #L176 was not covered by tests

def migrate(self) -> None:
"""Migrate the storage for this profile to the head version.
:raises: :class:`~aiida.common.exceptions.UnreachableStorage` if the storage cannot be accessed.
:raises: :class:`~aiida.common.exceptions.StorageMigrationError` if the storage is not initialised.
"""
if not inspect(self.connection).has_table(self.alembic_version_tbl_name):
raise exceptions.StorageMigrationError('storage is uninitialised, cannot migrate.')

Check warning on line 185 in src/aiida/storage/sqlite_dos/backend.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/storage/sqlite_dos/backend.py#L185

Added line #L185 was not covered by tests

MIGRATE_LOGGER.report('Migrating to the head of the main branch')
self.migrate_up('main@head')
self.connection.commit()


class SqliteDosStorage(PsqlDosBackend):
"""A lightweight backend intended for demos and testing.
Expand Down Expand Up @@ -177,12 +274,9 @@ def get_repository(self) -> 'DiskObjectStoreRepositoryBackend':
return DiskObjectStoreRepositoryBackend(container=self.get_container())

@classmethod
def version_head(cls) -> str:
return get_schema_version_head()

@classmethod
def version_profile(cls, profile: Profile) -> str | None:
return get_schema_version_head()
def version_profile(cls, profile: Profile) -> Optional[str]:
with cls.migrator_context(profile) as migrator:
return migrator.get_schema_version_profile()

Check warning on line 279 in src/aiida/storage/sqlite_dos/backend.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/storage/sqlite_dos/backend.py#L278-L279

Added lines #L278 - L279 were not covered by tests

def query(self) -> orm.SqliteQueryBuilder:
return orm.SqliteQueryBuilder(self)
Expand Down
54 changes: 54 additions & 0 deletions src/aiida/storage/sqlite_dos/migrations/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
###########################################################################
# Copyright (c), The AiiDA team. All rights reserved. #
# This file is part of the AiiDA code. #
# #
# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core #
# For further information on the license, see the LICENSE.txt file #
# For further information please visit http://www.aiida.net #
###########################################################################
"""Upper level SQLAlchemy migration functions."""

from alembic import context


def run_migrations_online():
"""Run migrations in 'online' mode.
The connection should have been passed to the config, which we use to configue the migration context.
"""
from aiida.storage.sqlite_zip.models import SqliteBase

config = context.config

connection = config.attributes.get('connection', None)
aiida_profile = config.attributes.get('aiida_profile', None)
on_version_apply = config.attributes.get('on_version_apply', None)

if connection is None:
from aiida.common.exceptions import ConfigurationError

raise ConfigurationError('An initialized connection is expected for the AiiDA online migrations.')
if aiida_profile is None:
from aiida.common.exceptions import ConfigurationError

raise ConfigurationError('An aiida_profile is expected for the AiiDA online migrations.')

context.configure(
connection=connection,
target_metadata=SqliteBase.metadata,
transaction_per_migration=True,
aiida_profile=aiida_profile,
on_version_apply=on_version_apply,
)

context.run_migrations()


try:
if context.is_offline_mode():
raise NotImplementedError('This feature is not currently supported.')

run_migrations_online()
except NameError:
# This will occur in an environment that is just compiling the documentation
pass
Loading

0 comments on commit 32543b0

Please sign in to comment.