mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-02-02 08:09:13 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
BIN
tests/fixtures/empty_libraries/DB_VERSION_100/.TagStudio/ts_library.sqlite
vendored
Normal file
BIN
tests/fixtures/empty_libraries/DB_VERSION_100/.TagStudio/ts_library.sqlite
vendored
Normal file
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user