feat: swap IDs in tag_parents table; bump DB to v100

commit c1346e7df36b137cf88be284a96329fee9605a6a
Author: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date:   Sat Aug 23 18:04:58 2025 -0700

    docs: update DB v100 with tag_parents flip

commit 7e5d9381759b000533c809df9d9bc4f9d984e363
Author: HeikoWasTaken <heikowastaken@protonmail.com>
Date:   Sun Aug 24 00:31:21 2025 +0100

    fix: swap IDs in parent_tags DB table (#998)

    * fix: reorder child and parent IDs in TagParent constructor call

    * feat: add db10 migration

    * fix: SQL query returning parent IDs instead of children IDs

    * fix: stop assigning child tags as parents

    * fix: select and remove parent tags, instead of child tags

    * test/fix: correctly reorder child/parent args in broken test

    * fix: migrate json subtags as parent tags, instead of child tags (I see where it went wrong now lol)

    * fix: query parent tags instead of children

    * refactor: scooching this down below db9 migrations

    * test: add DB10 migration test

    ---------

    Co-authored-by: heiko <heiko_was_taken@protonmail.com>

commit 1ce02699ad9798800f9d98832b2a6377e3d79ed4
Author: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date:   Sat Aug 23 14:47:39 2025 -0700

    feat: add db minor versioning, bump to 100
This commit is contained in:
Travis Abendshien
2025-08-23 18:11:33 -07:00
parent 660a87bb94
commit 74383e3c3c
10 changed files with 104 additions and 43 deletions

View File

@@ -71,8 +71,21 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
### Version 9
| Used From | Used Until | Format | Location |
| ----------------------------------------------------------------------- | ---------- | ------ | ----------------------------------------------- |
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | _Current_ | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
| Used From | Used Until | Format | Location |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | [v9.5.3](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.3) | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
---
### Version 100
| Used From | Used Until | Format | Location |
| ----------------------------------------------------------------------- | ---------- | ------ | ----------------------------------------------- |
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | _Current_ | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
- Introduces built-in minor versioning
- The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in.
- Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased.
- Swaps `parent_id` and `child_id` values in the `tag_parents` table, which have erroneously been flipped since the first SQLite DB version.

View File

@@ -79,9 +79,9 @@ class DefaultEnum(enum.Enum):
raise AttributeError("access the value via .default property instead")
# TODO: Remove DefaultEnum and LibraryPrefs classes once remaining values are removed.
class LibraryPrefs(DefaultEnum):
"""Library preferences with default value accessible via .default property."""
IS_EXCLUDE_LIST = True
EXTENSION_LIST = [".json", ".xmp", ".aae"]
DB_VERSION = 9

View File

@@ -12,7 +12,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from os import makedirs
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from warnings import catch_warnings
@@ -92,6 +92,8 @@ if TYPE_CHECKING:
logger = structlog.get_logger(__name__)
DB_VERSION_KEY: str = "DB_VERSION"
DB_VERSION: int = 100
TAG_CHILDREN_QUERY = text("""
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
@@ -273,8 +275,8 @@ class Library:
# Parent Tags (Previously known as "Subtags" in JSON)
for tag in json_lib.tags:
for child_id in tag.subtag_ids:
self.add_parent_tag(parent_id=tag.id, child_id=child_id)
for parent_id in tag.subtag_ids:
self.add_parent_tag(parent_id=parent_id, child_id=tag.id)
# Entries
self.add_entries(
@@ -365,7 +367,7 @@ class Library:
# https://docs.sqlalchemy.org/en/20/changelog/migration_07.html
# Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases
poolclass = None if self.storage_path == ":memory:" else NullPool
db_version: int = 0
loaded_db_version: int = 0
logger.info(
"[Library] Opening SQLite Library",
@@ -377,13 +379,21 @@ class Library:
# dont check db version when creating new library
if not is_new:
db_result = session.scalar(
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
select(Preferences).where(Preferences.key == DB_VERSION_KEY)
)
if db_result:
db_version = db_result.value
assert isinstance(db_result.value, int)
loaded_db_version = db_result.value
# NOTE: DB_VERSION 6 is the first supported SQL DB version.
if db_version < 6 or db_version > LibraryPrefs.DB_VERSION.default:
# ======================== Library Database Version Checking =======================
# DB_VERSION 6 is the first supported SQLite DB version.
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
# - Dividing by 100 and flooring gives the major (breaking changes) version.
# - If a DB has major version higher than the current program, don't load it.
# - If only the minor version is higher, it's still allowed to load.
if loaded_db_version < 6 or (
loaded_db_version >= 100 and loaded_db_version // 100 > DB_VERSION // 100
):
mismatch_text = Translations["status.library_version_mismatch"]
found_text = Translations["status.library_version_found"]
expected_text = Translations["status.library_version_expected"]
@@ -391,12 +401,12 @@ class Library:
success=False,
message=(
f"{mismatch_text}\n"
f"{found_text} v{db_version}, "
f"{expected_text} v{LibraryPrefs.DB_VERSION.default}"
f"{found_text} v{loaded_db_version}, "
f"{expected_text} v{DB_VERSION}"
),
)
logger.info(f"[Library] DB_VERSION: {db_version}")
logger.info(f"[Library] DB_VERSION: {loaded_db_version}")
make_tables(self.engine)
# Add default tag color namespaces.
@@ -434,6 +444,15 @@ class Library:
except IntegrityError:
session.rollback()
# TODO: Completely rework this "preferences" system.
with catch_warnings(record=True):
try:
session.add(Preferences(key=DB_VERSION_KEY, value=DB_VERSION))
session.commit()
except IntegrityError:
logger.debug("preference already exists", pref=DB_VERSION_KEY)
session.rollback()
for pref in LibraryPrefs:
with catch_warnings(record=True):
try:
@@ -474,28 +493,30 @@ class Library:
# Apply any post-SQL migration patches.
if not is_new:
# save backup if patches will be applied
if LibraryPrefs.DB_VERSION.default != db_version:
if loaded_db_version != DB_VERSION:
self.library_dir = library_dir
self.save_library_backup_to_disk()
self.library_dir = None
# schema changes first
if db_version < 8:
if loaded_db_version < 8:
self.apply_db8_schema_changes(session)
if db_version < 9:
if loaded_db_version < 9:
self.apply_db9_schema_changes(session)
# now the data changes
if db_version == 6:
if loaded_db_version == 6:
self.apply_repairs_for_db6(session)
if db_version >= 6 and db_version < 8:
if loaded_db_version >= 6 and loaded_db_version < 8:
self.apply_db8_default_data(session)
if db_version < 9:
if loaded_db_version < 9:
self.apply_db9_filename_population(session)
if loaded_db_version < 100:
self.apply_db100_parent_repairs(session)
# Update DB_VERSION
if LibraryPrefs.DB_VERSION.default > db_version:
self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default)
if loaded_db_version < DB_VERSION:
self.set_prefs(DB_VERSION_KEY, DB_VERSION)
# everything is fine, set the library path
self.library_dir = library_dir
@@ -617,6 +638,20 @@ class Library:
session.commit()
logger.info("[Library][Migration] Populated filename column in entries table")
def apply_db100_parent_repairs(self, session: Session):
"""Apply database repairs introduced in DB_VERSION 100."""
logger.info("[Library][Migration] Applying patches to DB_VERSION 100 library...")
with session:
# Repair parent-child tag relationships that are the wrong way around.
stmt = update(TagParent).values(
parent_id=TagParent.child_id,
child_id=TagParent.parent_id,
)
session.execute(stmt)
session.flush()
session.commit()
@property
def default_fields(self) -> list[BaseField]:
with Session(self.engine) as session:
@@ -1631,35 +1666,49 @@ class Library:
# load all tag's parent tags to know which to remove
prev_parent_tags = session.scalars(
select(TagParent).where(TagParent.parent_id == tag.id)
select(TagParent).where(TagParent.child_id == tag.id)
).all()
for parent_tag in prev_parent_tags:
if parent_tag.child_id not in parent_ids:
if parent_tag.parent_id not in parent_ids:
session.delete(parent_tag)
else:
# no change, remove from list
parent_ids.remove(parent_tag.child_id)
parent_ids.remove(parent_tag.parent_id)
# create remaining items
for parent_id in parent_ids:
# add new parent tag
parent_tag = TagParent(
parent_id=tag.id,
child_id=parent_id,
parent_id=parent_id,
child_id=tag.id,
)
session.add(parent_tag)
def prefs(self, key: LibraryPrefs):
def prefs(self, key: str | LibraryPrefs):
# load given item from Preferences table
with Session(self.engine) as session:
return session.scalar(select(Preferences).where(Preferences.key == key.name)).value
if isinstance(key, LibraryPrefs):
return session.scalar(select(Preferences).where(Preferences.key == key.name)).value
else:
return session.scalar(select(Preferences).where(Preferences.key == key)).value
def set_prefs(self, key: LibraryPrefs, value) -> None:
def set_prefs(self, key: str | LibraryPrefs, value: Any) -> None:
# set given item in Preferences table
with Session(self.engine) as session:
# load existing preference and update value
pref = session.scalar(select(Preferences).where(Preferences.key == key.name))
pref: Preferences | None
stuff = session.scalars(select(Preferences))
logger.info([x.key for x in list(stuff)])
if isinstance(key, LibraryPrefs):
pref = session.scalar(select(Preferences).where(Preferences.key == key.name))
else:
pref = session.scalar(select(Preferences).where(Preferences.key == key))
logger.info("loading pref", pref=pref, key=key, value=value)
assert pref is not None
pref.value = value
session.add(pref)
session.commit()

View File

@@ -99,8 +99,8 @@ class Tag(Base):
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
parent_tags: Mapped[set["Tag"]] = relationship(
secondary=TagParent.__tablename__,
primaryjoin="Tag.id == TagParent.parent_id",
secondaryjoin="Tag.id == TagParent.child_id",
primaryjoin="Tag.id == TagParent.child_id",
secondaryjoin="Tag.id == TagParent.parent_id",
back_populates="parent_tags",
)
disambiguation_id: Mapped[int | None]

View File

@@ -31,17 +31,15 @@ else:
logger = structlog.get_logger(__name__)
# TODO: Reevaluate after subtags -> parent tags name change
TAG_CHILDREN_ID_QUERY = text("""
-- Note for this entire query that tag_parents.child_id is the parent id and tag_parents.parent_id is the child id due to bad naming
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS child_id
SELECT :tag_id AS tag_id
UNION
SELECT tp.parent_id AS child_id
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.child_id = c.child_id
SELECT tp.child_id AS tag_id
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
)
SELECT child_id FROM ChildTags;
SELECT tag_id FROM ChildTags;
""") # noqa: E501

View File

@@ -641,7 +641,7 @@ class JsonMigrationModal(QObject):
for tag in self.sql_lib.tags:
tag_id = tag.id # Tag IDs start at 0
sql_parent_tags = set(
session.scalars(select(TagParent.child_id).where(TagParent.parent_id == tag.id))
session.scalars(select(TagParent.parent_id).where(TagParent.child_id == tag.id))
)
# JSON tags allowed self-parenting; SQL tags no longer allow this.

View File

@@ -79,7 +79,7 @@ def test_build_tag_panel_set_parent_tags(library, generate_tag):
assert parent
assert child
library.add_parent_tag(child.id, parent.id)
library.add_parent_tag(parent.id, child.id)
child = library.get_tag(child.id)

View File

@@ -23,6 +23,7 @@ EMPTY_LIBRARIES = "empty_libraries"
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_7")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_9")),
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_100")),
],
)
def test_library_migrations(path: str):