Skip to content

Commit 74383e3

Browse files
committed
feat: swap IDs in tag_parents table; bump DB to v100
commit c1346e7 Author: Travis Abendshien <[email protected]> Date: Sat Aug 23 18:04:58 2025 -0700 docs: update DB v100 with tag_parents flip commit 7e5d938 Author: HeikoWasTaken <[email protected]> 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 <[email protected]> commit 1ce0269 Author: Travis Abendshien <[email protected]> Date: Sat Aug 23 14:47:39 2025 -0700 feat: add db minor versioning, bump to 100
1 parent 660a87b commit 74383e3

File tree

10 files changed

+104
-43
lines changed

10 files changed

+104
-43
lines changed

docs/updates/schema_changes.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,21 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
7171

7272
### Version 9
7373

74+
| Used From | Used Until | Format | Location |
75+
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------ | ----------------------------------------------- |
76+
| [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 |
77+
78+
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
79+
80+
---
81+
82+
### Version 100
83+
7484
| Used From | Used Until | Format | Location |
7585
| ----------------------------------------------------------------------- | ---------- | ------ | ----------------------------------------------- |
76-
| [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | _Current_ | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
86+
| [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | _Current_ | SQLite | `<Library Folder>`/.TagStudio/ts_library.sqlite |
7787

78-
- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results.
88+
- Introduces built-in minor versioning
89+
- 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.
90+
- 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.
91+
- Swaps `parent_id` and `child_id` values in the `tag_parents` table, which have erroneously been flipped since the first SQLite DB version.

src/tagstudio/core/enums.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ def value(self):
7979
raise AttributeError("access the value via .default property instead")
8080

8181

82+
# TODO: Remove DefaultEnum and LibraryPrefs classes once remaining values are removed.
8283
class LibraryPrefs(DefaultEnum):
8384
"""Library preferences with default value accessible via .default property."""
8485

8586
IS_EXCLUDE_LIST = True
8687
EXTENSION_LIST = [".json", ".xmp", ".aae"]
87-
DB_VERSION = 9

src/tagstudio/core/library/alchemy/library.py

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from datetime import UTC, datetime
1313
from os import makedirs
1414
from pathlib import Path
15-
from typing import TYPE_CHECKING
15+
from typing import TYPE_CHECKING, Any
1616
from uuid import uuid4
1717
from warnings import catch_warnings
1818

@@ -92,6 +92,8 @@
9292

9393
logger = structlog.get_logger(__name__)
9494

95+
DB_VERSION_KEY: str = "DB_VERSION"
96+
DB_VERSION: int = 100
9597

9698
TAG_CHILDREN_QUERY = text("""
9799
-- 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 @@ def migrate_json_to_sqlite(self, json_lib: JsonLibrary):
273275

274276
# Parent Tags (Previously known as "Subtags" in JSON)
275277
for tag in json_lib.tags:
276-
for child_id in tag.subtag_ids:
277-
self.add_parent_tag(parent_id=tag.id, child_id=child_id)
278+
for parent_id in tag.subtag_ids:
279+
self.add_parent_tag(parent_id=parent_id, child_id=tag.id)
278280

279281
# Entries
280282
self.add_entries(
@@ -365,7 +367,7 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
365367
# https://docs.sqlalchemy.org/en/20/changelog/migration_07.html
366368
# Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases
367369
poolclass = None if self.storage_path == ":memory:" else NullPool
368-
db_version: int = 0
370+
loaded_db_version: int = 0
369371

370372
logger.info(
371373
"[Library] Opening SQLite Library",
@@ -377,26 +379,34 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
377379
# dont check db version when creating new library
378380
if not is_new:
379381
db_result = session.scalar(
380-
select(Preferences).where(Preferences.key == LibraryPrefs.DB_VERSION.name)
382+
select(Preferences).where(Preferences.key == DB_VERSION_KEY)
381383
)
382384
if db_result:
383-
db_version = db_result.value
384-
385-
# NOTE: DB_VERSION 6 is the first supported SQL DB version.
386-
if db_version < 6 or db_version > LibraryPrefs.DB_VERSION.default:
385+
assert isinstance(db_result.value, int)
386+
loaded_db_version = db_result.value
387+
388+
# ======================== Library Database Version Checking =======================
389+
# DB_VERSION 6 is the first supported SQLite DB version.
390+
# If the DB_VERSION is >= 100, that means it's a compound major + minor version.
391+
# - Dividing by 100 and flooring gives the major (breaking changes) version.
392+
# - If a DB has major version higher than the current program, don't load it.
393+
# - If only the minor version is higher, it's still allowed to load.
394+
if loaded_db_version < 6 or (
395+
loaded_db_version >= 100 and loaded_db_version // 100 > DB_VERSION // 100
396+
):
387397
mismatch_text = Translations["status.library_version_mismatch"]
388398
found_text = Translations["status.library_version_found"]
389399
expected_text = Translations["status.library_version_expected"]
390400
return LibraryStatus(
391401
success=False,
392402
message=(
393403
f"{mismatch_text}\n"
394-
f"{found_text} v{db_version}, "
395-
f"{expected_text} v{LibraryPrefs.DB_VERSION.default}"
404+
f"{found_text} v{loaded_db_version}, "
405+
f"{expected_text} v{DB_VERSION}"
396406
),
397407
)
398408

399-
logger.info(f"[Library] DB_VERSION: {db_version}")
409+
logger.info(f"[Library] DB_VERSION: {loaded_db_version}")
400410
make_tables(self.engine)
401411

402412
# Add default tag color namespaces.
@@ -434,6 +444,15 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
434444
except IntegrityError:
435445
session.rollback()
436446

447+
# TODO: Completely rework this "preferences" system.
448+
with catch_warnings(record=True):
449+
try:
450+
session.add(Preferences(key=DB_VERSION_KEY, value=DB_VERSION))
451+
session.commit()
452+
except IntegrityError:
453+
logger.debug("preference already exists", pref=DB_VERSION_KEY)
454+
session.rollback()
455+
437456
for pref in LibraryPrefs:
438457
with catch_warnings(record=True):
439458
try:
@@ -474,28 +493,30 @@ def open_sqlite_library(self, library_dir: Path, is_new: bool) -> LibraryStatus:
474493
# Apply any post-SQL migration patches.
475494
if not is_new:
476495
# save backup if patches will be applied
477-
if LibraryPrefs.DB_VERSION.default != db_version:
496+
if loaded_db_version != DB_VERSION:
478497
self.library_dir = library_dir
479498
self.save_library_backup_to_disk()
480499
self.library_dir = None
481500

482501
# schema changes first
483-
if db_version < 8:
502+
if loaded_db_version < 8:
484503
self.apply_db8_schema_changes(session)
485-
if db_version < 9:
504+
if loaded_db_version < 9:
486505
self.apply_db9_schema_changes(session)
487506

488507
# now the data changes
489-
if db_version == 6:
508+
if loaded_db_version == 6:
490509
self.apply_repairs_for_db6(session)
491-
if db_version >= 6 and db_version < 8:
510+
if loaded_db_version >= 6 and loaded_db_version < 8:
492511
self.apply_db8_default_data(session)
493-
if db_version < 9:
512+
if loaded_db_version < 9:
494513
self.apply_db9_filename_population(session)
514+
if loaded_db_version < 100:
515+
self.apply_db100_parent_repairs(session)
495516

496517
# Update DB_VERSION
497-
if LibraryPrefs.DB_VERSION.default > db_version:
498-
self.set_prefs(LibraryPrefs.DB_VERSION, LibraryPrefs.DB_VERSION.default)
518+
if loaded_db_version < DB_VERSION:
519+
self.set_prefs(DB_VERSION_KEY, DB_VERSION)
499520

500521
# everything is fine, set the library path
501522
self.library_dir = library_dir
@@ -617,6 +638,20 @@ def apply_db9_filename_population(self, session: Session):
617638
session.commit()
618639
logger.info("[Library][Migration] Populated filename column in entries table")
619640

641+
def apply_db100_parent_repairs(self, session: Session):
642+
"""Apply database repairs introduced in DB_VERSION 100."""
643+
logger.info("[Library][Migration] Applying patches to DB_VERSION 100 library...")
644+
with session:
645+
# Repair parent-child tag relationships that are the wrong way around.
646+
stmt = update(TagParent).values(
647+
parent_id=TagParent.child_id,
648+
child_id=TagParent.parent_id,
649+
)
650+
session.execute(stmt)
651+
session.flush()
652+
653+
session.commit()
654+
620655
@property
621656
def default_fields(self) -> list[BaseField]:
622657
with Session(self.engine) as session:
@@ -1631,35 +1666,49 @@ def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session
16311666

16321667
# load all tag's parent tags to know which to remove
16331668
prev_parent_tags = session.scalars(
1634-
select(TagParent).where(TagParent.parent_id == tag.id)
1669+
select(TagParent).where(TagParent.child_id == tag.id)
16351670
).all()
16361671

16371672
for parent_tag in prev_parent_tags:
1638-
if parent_tag.child_id not in parent_ids:
1673+
if parent_tag.parent_id not in parent_ids:
16391674
session.delete(parent_tag)
16401675
else:
16411676
# no change, remove from list
1642-
parent_ids.remove(parent_tag.child_id)
1677+
parent_ids.remove(parent_tag.parent_id)
16431678

16441679
# create remaining items
16451680
for parent_id in parent_ids:
16461681
# add new parent tag
16471682
parent_tag = TagParent(
1648-
parent_id=tag.id,
1649-
child_id=parent_id,
1683+
parent_id=parent_id,
1684+
child_id=tag.id,
16501685
)
16511686
session.add(parent_tag)
16521687

1653-
def prefs(self, key: LibraryPrefs):
1688+
def prefs(self, key: str | LibraryPrefs):
16541689
# load given item from Preferences table
16551690
with Session(self.engine) as session:
1656-
return session.scalar(select(Preferences).where(Preferences.key == key.name)).value
1691+
if isinstance(key, LibraryPrefs):
1692+
return session.scalar(select(Preferences).where(Preferences.key == key.name)).value
1693+
else:
1694+
return session.scalar(select(Preferences).where(Preferences.key == key)).value
16571695

1658-
def set_prefs(self, key: LibraryPrefs, value) -> None:
1696+
def set_prefs(self, key: str | LibraryPrefs, value: Any) -> None:
16591697
# set given item in Preferences table
16601698
with Session(self.engine) as session:
16611699
# load existing preference and update value
1662-
pref = session.scalar(select(Preferences).where(Preferences.key == key.name))
1700+
pref: Preferences | None
1701+
1702+
stuff = session.scalars(select(Preferences))
1703+
logger.info([x.key for x in list(stuff)])
1704+
1705+
if isinstance(key, LibraryPrefs):
1706+
pref = session.scalar(select(Preferences).where(Preferences.key == key.name))
1707+
else:
1708+
pref = session.scalar(select(Preferences).where(Preferences.key == key))
1709+
1710+
logger.info("loading pref", pref=pref, key=key, value=value)
1711+
assert pref is not None
16631712
pref.value = value
16641713
session.add(pref)
16651714
session.commit()

src/tagstudio/core/library/alchemy/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ class Tag(Base):
9999
aliases: Mapped[set[TagAlias]] = relationship(back_populates="tag")
100100
parent_tags: Mapped[set["Tag"]] = relationship(
101101
secondary=TagParent.__tablename__,
102-
primaryjoin="Tag.id == TagParent.parent_id",
103-
secondaryjoin="Tag.id == TagParent.child_id",
102+
primaryjoin="Tag.id == TagParent.child_id",
103+
secondaryjoin="Tag.id == TagParent.parent_id",
104104
back_populates="parent_tags",
105105
)
106106
disambiguation_id: Mapped[int | None]

src/tagstudio/core/library/alchemy/visitors.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,15 @@
3131

3232
logger = structlog.get_logger(__name__)
3333

34-
# TODO: Reevaluate after subtags -> parent tags name change
3534
TAG_CHILDREN_ID_QUERY = text("""
36-
-- 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
3735
WITH RECURSIVE ChildTags AS (
38-
SELECT :tag_id AS child_id
36+
SELECT :tag_id AS tag_id
3937
UNION
40-
SELECT tp.parent_id AS child_id
41-
FROM tag_parents tp
42-
INNER JOIN ChildTags c ON tp.child_id = c.child_id
38+
SELECT tp.child_id AS tag_id
39+
FROM tag_parents tp
40+
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
4341
)
44-
SELECT child_id FROM ChildTags;
42+
SELECT tag_id FROM ChildTags;
4543
""") # noqa: E501
4644

4745

src/tagstudio/qt/widgets/migration_modal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ def check_subtag_parity(self) -> bool:
641641
for tag in self.sql_lib.tags:
642642
tag_id = tag.id # Tag IDs start at 0
643643
sql_parent_tags = set(
644-
session.scalars(select(TagParent.child_id).where(TagParent.parent_id == tag.id))
644+
session.scalars(select(TagParent.parent_id).where(TagParent.child_id == tag.id))
645645
)
646646

647647
# JSON tags allowed self-parenting; SQL tags no longer allow this.
96 KB
Binary file not shown.
0 Bytes
Binary file not shown.

tests/qt/test_build_tag_panel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def test_build_tag_panel_set_parent_tags(library, generate_tag):
7979
assert parent
8080
assert child
8181

82-
library.add_parent_tag(child.id, parent.id)
82+
library.add_parent_tag(parent.id, child.id)
8383

8484
child = library.get_tag(child.id)
8585

tests/test_db_migrations.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_7")),
2424
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")),
2525
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_9")),
26+
str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_100")),
2627
],
2728
)
2829
def test_library_migrations(path: str):

0 commit comments

Comments
 (0)