-
-
Notifications
You must be signed in to change notification settings - Fork 466
fix: close dangling session, migration corrections #1471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -221,7 +221,6 @@ def close(self): | |
| if self.engine: | ||
| self.engine.dispose() | ||
| self.library_dir = None | ||
| self.folder = None | ||
| self.included_files = set() | ||
|
|
||
| self.dupe_entries_count = -1 | ||
|
|
@@ -378,8 +377,7 @@ def open_library(self, library_dir: Path, in_memory: bool = False) -> LibrarySta | |
|
|
||
| return self.open_sqlite_library(library_dir, in_memory) | ||
|
|
||
| @staticmethod | ||
| def __get_engine(library_dir: Path, in_memory: bool, sql_filename: str): | ||
| def _get_engine(self, library_dir: Path, in_memory: bool, sql_filename: str): | ||
| connection_string = URL.create( | ||
| drivername="sqlite", | ||
| database=( | ||
|
|
@@ -407,7 +405,7 @@ def __get_engine(library_dir: Path, in_memory: bool, sql_filename: str): | |
| def create_sqlite_library( | ||
| self, library_dir: Path, in_memory: bool, sql_filename: str = SQL_FILENAME | ||
| ) -> LibraryStatus: | ||
| self.engine = self.__get_engine(library_dir, in_memory, sql_filename) | ||
| self.engine = self._get_engine(library_dir, in_memory, sql_filename) | ||
|
|
||
| logger.info( | ||
| "[Library] Opening SQLite Library", | ||
|
|
@@ -505,21 +503,21 @@ def open_sqlite_library( | |
| ) -> LibraryStatus: | ||
| logger.info("[Library] Opening SQLite Library", library_dir=library_dir) | ||
|
|
||
| self.engine = self.__get_engine(library_dir, in_memory, sql_filename) | ||
| self.engine = self._get_engine(library_dir, in_memory, sql_filename) | ||
| self.library_dir = library_dir | ||
|
|
||
| try: | ||
| migrations = DBMigrations(library_dir, self.engine) | ||
| migrations = DBMigrations(self) | ||
|
|
||
| # save backup if patches will be applied | ||
| if migrations.required: | ||
| Library.save_library_backup_to_disk(library_dir) | ||
| self.save_library_backup_to_disk() | ||
|
|
||
| migrations.run() | ||
| except MigrationError as e: | ||
| self.library_dir = None | ||
| return LibraryStatus(success=False, message=e.args[0]) | ||
|
|
||
| # everything is fine, set the library path | ||
| self.library_dir = library_dir | ||
| return LibraryStatus(success=True, library_path=library_dir) | ||
|
|
||
| @property | ||
|
|
@@ -669,37 +667,32 @@ def entries_count(self) -> int: | |
| with Session(self.engine) as session: | ||
| return unwrap(session.scalar(select(func.count(Entry.id)))) | ||
|
|
||
| @staticmethod | ||
| def _all_entries(session: Session, with_joins: bool = False) -> Iterator[Entry]: | ||
| def all_entries(self, with_joins: bool = False) -> Iterator[Entry]: | ||
| """Load entries without joins.""" | ||
| stmt = select(Entry) | ||
| if with_joins: | ||
| # load Entry with all joins and all tags | ||
| stmt = ( | ||
| stmt.outerjoin(Entry.text_fields) | ||
| .outerjoin(Entry.datetime_fields) | ||
| .outerjoin(Entry.tags) | ||
| ) | ||
| stmt = stmt.options( | ||
| contains_eager(Entry.text_fields), | ||
| contains_eager(Entry.datetime_fields), | ||
| contains_eager(Entry.tags), | ||
| ) | ||
|
|
||
| stmt = stmt.distinct() | ||
| with Session(self.engine) as session: | ||
| stmt = select(Entry) | ||
| if with_joins: | ||
| # load Entry with all joins and all tags | ||
| stmt = ( | ||
| stmt.outerjoin(Entry.text_fields) | ||
| .outerjoin(Entry.datetime_fields) | ||
| .outerjoin(Entry.tags) | ||
| ) | ||
| stmt = stmt.options( | ||
| contains_eager(Entry.text_fields), | ||
| contains_eager(Entry.datetime_fields), | ||
| contains_eager(Entry.tags), | ||
| ) | ||
|
|
||
| entries = session.execute(stmt).scalars() | ||
| if with_joins: | ||
| entries = entries.unique() | ||
| stmt = stmt.distinct() | ||
|
|
||
| for entry in entries: | ||
| yield entry | ||
| session.expunge(entry) | ||
| entries = session.execute(stmt).scalars() | ||
| if with_joins: | ||
| entries = entries.unique() | ||
|
|
||
| def all_entries(self, with_joins: bool = False) -> Iterator[Entry]: | ||
| """Load entries without joins.""" | ||
| with Session(self.engine) as session: | ||
| return Library._all_entries(session, with_joins) | ||
| for entry in entries: | ||
| yield entry | ||
| session.expunge(entry) | ||
|
|
||
| @property | ||
| def tags(self) -> list[Tag]: | ||
|
|
@@ -1447,17 +1440,16 @@ def delete_color(self, color: TagColorGroup): | |
| session.rollback() | ||
| return None | ||
|
|
||
| @staticmethod | ||
| def save_library_backup_to_disk(library_dir: Path) -> Path: | ||
|
Comment on lines
-1450
to
-1451
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had made this static since the idea is to call it when the library isn't loaded yet (and can't be loaded at all due to needing to be migrated), that's why it was static and why I still think it should be here. We could maybe also just move this to the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
This method is also used outside of the migrations, like in the UI - so there still needs to be a non-static version of it present in the Library class for instances to use without passing the argument of the instance's own
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah this is exactly what I meant; normally the
I think having a static method and a non-static method that calls the static one (with |
||
| assert isinstance(library_dir, Path) | ||
| makedirs(str(library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True) | ||
| def save_library_backup_to_disk(self) -> Path: | ||
| assert isinstance(self.library_dir, Path) | ||
| makedirs(str(self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME), exist_ok=True) | ||
|
|
||
| filename = f"ts_library_backup_{datetime.now(UTC).strftime('%Y_%m_%d_%H%M%S')}.sqlite" | ||
|
|
||
| target_path = library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename | ||
| target_path = self.library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename | ||
|
|
||
| shutil.copy2( | ||
| library_dir / TS_FOLDER_NAME / SQL_FILENAME, | ||
| self.library_dir / TS_FOLDER_NAME / SQL_FILENAME, | ||
| target_path, | ||
| ) | ||
|
|
||
|
|
@@ -1749,12 +1741,8 @@ def get_version(self, key: str) -> int: | |
| Args: | ||
| key(str): The key for the name of the version type to set. | ||
| """ | ||
| return Library._get_version(self.engine, key) | ||
|
|
||
| @staticmethod | ||
| def _get_version(engine, key: str) -> int: | ||
| with Session(engine) as session: | ||
| engine = sqlalchemy.inspect(engine) | ||
| with Session(self.engine) as session: | ||
| engine = sqlalchemy.inspect(self.engine) | ||
| try: | ||
| # "Version" table added in DB_VERSION 101 | ||
| if engine and engine.has_table("versions"): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,11 +4,11 @@ | |
|
|
||
| from collections.abc import Callable | ||
| from pathlib import Path | ||
| from typing import override | ||
| from typing import TYPE_CHECKING, override | ||
|
|
||
| import structlog | ||
| import ujson | ||
| from sqlalchemy import Engine, and_, delete, select, text, update | ||
| from sqlalchemy import and_, delete, select, text, update | ||
| from sqlalchemy.orm import Session | ||
|
|
||
| from tagstudio.core.constants import IGNORE_NAME, TAG_ARCHIVED, TS_FOLDER_NAME | ||
|
|
@@ -21,11 +21,14 @@ | |
| ) | ||
| from tagstudio.core.library.alchemy.fields import LEGACY_FIELD_MAP, DatetimeField, TextField | ||
| from tagstudio.core.library.alchemy.joins import TagParent | ||
| from tagstudio.core.library.alchemy.models import Tag, TagColorGroup, Version | ||
| from tagstudio.core.library.alchemy.models import Entry, Tag, TagColorGroup, Version | ||
| from tagstudio.core.library.ignore import migrate_ext_list | ||
| from tagstudio.core.utils.types import unwrap | ||
| from tagstudio.qt.translations import Translations | ||
|
|
||
| if TYPE_CHECKING: | ||
| from tagstudio.core.library.alchemy.library import Library | ||
|
|
||
| logger = structlog.get_logger(__name__) | ||
|
|
||
|
|
||
|
|
@@ -38,20 +41,19 @@ class DBMigration: | |
| initial_version: int | None = None | ||
|
|
||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log: Callable[[str], str]): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]) -> None: # pyright: ignore[reportUnusedParameter] | ||
| raise NotImplementedError | ||
|
|
||
|
|
||
| class DBMigrations: | ||
| def __init__(self, library_dir: Path, engine: Engine) -> None: | ||
| from tagstudio.core.library.alchemy.library import Library | ||
| def __init__(self, library: "Library") -> None: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had originally passed the library path and engine because the engine argument will be removed when the migrations are made sql alchemy independent (and thus fully independent of the model that the to-be-migrated lib doesn't yet follow). Once that is done the parameter would only be the library path which is a much looser coupling then passing the full library in1. For that reason I feel it is undesirable to pass the entire library to the constructor here. Note: Afaict the statements setting Footnotes
|
||
|
|
||
| self.library_dir = library_dir | ||
| self.engine = engine | ||
| self.lib = library | ||
| self.engine = self.lib.engine | ||
|
|
||
| # Don't check DB version when creating new library | ||
| self.loaded_db_version = Library._get_version(engine, DB_VERSION_CURRENT_KEY) | ||
| self.initial_db_version = Library._get_version(engine, DB_VERSION_INITIAL_KEY) | ||
| self.loaded_db_version = self.lib.get_version(DB_VERSION_CURRENT_KEY) | ||
| self.initial_db_version = self.lib.get_version(DB_VERSION_INITIAL_KEY) | ||
|
|
||
| # ======================== Library Database Version Checking ======================= | ||
| # DB_VERSION 6 is the first supported SQLite DB version. | ||
|
|
@@ -107,7 +109,7 @@ def run(self): | |
| # any error causes transaction to rollback | ||
| migration.run( | ||
| session, | ||
| self.library_dir, | ||
| self.lib, | ||
| lambda msg, v=migration.version: f"[Library][Migration][{v}] {msg}", | ||
| ) | ||
| self.loaded_db_version = migration.version | ||
|
|
@@ -146,7 +148,7 @@ class MigrationTo7(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like annotating the methods with the type for |
||
| """Migrate DB from DB_VERSION 6 to 7.""" | ||
| logger.info(fmt_log("Applying patches to DB_VERSION: 6 library...")) | ||
| # Repair tags that may have a disambiguation_id pointing towards a deleted tag. | ||
|
|
@@ -166,7 +168,7 @@ class MigrationTo8(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB from DB_VERSION 7 to 8.""" | ||
| # Add the missing color_border column to the TagColorGroups table. | ||
| session.execute( | ||
|
|
@@ -219,7 +221,7 @@ class MigrationTo9(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB from DB_VERSION 8 to 9.""" | ||
| # Apply database schema changes | ||
| add_filename_column = text( | ||
|
|
@@ -230,9 +232,8 @@ def run(cls, session: Session, library_dir: Path, fmt_log): | |
| logger.info(fmt_log("Added filename column to entries table")) | ||
|
|
||
| # Populate the new filename column. | ||
| from tagstudio.core.library.alchemy.library import Library | ||
|
|
||
| for entry in Library._all_entries(session): | ||
| entries = session.execute(select(Entry).distinct()).scalars() | ||
| for entry in entries: | ||
|
CyanVoxel marked this conversation as resolved.
|
||
| entry.filename = entry.path.name | ||
| session.merge(entry) | ||
| session.flush() | ||
|
|
@@ -244,7 +245,7 @@ class MigrationTo100(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB to DB_VERSION 100.""" | ||
| # Repair parent-child tag relationships that are the wrong way around. | ||
| stmt = update(TagParent).values( | ||
|
|
@@ -261,7 +262,7 @@ class MigrationTo101(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB to DB_VERSION 101.""" | ||
| # Create versions table | ||
| session.execute( | ||
|
|
@@ -284,7 +285,7 @@ class MigrationTo102(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB to DB_VERSION 102.""" | ||
| # delete TagParents with a dangling parent reference | ||
| stmt = delete(TagParent).where(TagParent.parent_id.not_in(select(Tag.id).distinct())) | ||
|
|
@@ -298,7 +299,7 @@ class MigrationTo103(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB from DB_VERSION 102 to 103.""" | ||
| # add the new hidden column for tags | ||
| session.execute(text("ALTER TABLE tags ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT 0")) | ||
|
|
@@ -316,17 +317,17 @@ class MigrationTo104(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB from DB_VERSION 103 to 104.""" | ||
| # Convert file extension list to ts_ignore file, if a .ts_ignore file does not exist | ||
| cls.__migrate_sql_to_ts_ignore(session, library_dir) | ||
| cls.__migrate_sql_to_ts_ignore(session, library) | ||
| session.execute(text("DROP TABLE preferences")) | ||
| session.flush() | ||
|
|
||
| @classmethod | ||
| def __migrate_sql_to_ts_ignore(cls, session: Session, library_dir: Path): | ||
| def __migrate_sql_to_ts_ignore(cls, session: Session, library: "Library"): | ||
| # Do not continue if existing '.ts_ignore' file is found | ||
| ts_ignore = library_dir / TS_FOLDER_NAME / IGNORE_NAME | ||
| ts_ignore = unwrap(library.library_dir) / TS_FOLDER_NAME / IGNORE_NAME | ||
| if Path(ts_ignore).exists(): | ||
| return | ||
|
|
||
|
|
@@ -349,7 +350,7 @@ class MigrationTo200(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB to DB_VERSION 200.""" | ||
| # Drop unused 'boolean_fields' and 'value_type' tables | ||
| logger.info(fmt_log("Dropping boolean_fields and value_type tables...")) | ||
|
|
@@ -463,7 +464,7 @@ class MigrationTo201(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB to DB_VERSION 201.""" | ||
| create_text_fields_table = text(""" | ||
| CREATE TABLE text_fields_new ( | ||
|
|
@@ -519,7 +520,7 @@ class MigrationTo202(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| """Migrate DB to DB_VERSION 202.""" | ||
| stmt = delete(TagParent).where(TagParent.child_id.not_in(select(Tag.id).distinct())) | ||
| session.execute(stmt) | ||
|
|
@@ -532,7 +533,7 @@ class MigrationTo300(DBMigration): | |
|
|
||
| @override | ||
| @classmethod | ||
| def run(cls, session: Session, library_dir: Path, fmt_log): | ||
| def run(cls, session: Session, library: "Library", fmt_log: Callable[[str], str]): | ||
| ## remove folder_id column from entries table | ||
| # create new table in the desired scheme (without folder_id column) | ||
| session.execute( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why make that non-static?