Upgrade to SQLAlchemy 2.0 (synchronous) (#803)

SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10
because newer releases need SQLAlchemy 2.  This is a mechanical port; the code
stays synchronous.

- SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0.
- Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped
  attributes); declarative_base moves to sqlalchemy.orm.
- Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql
  accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts
  works from the baseline migration again.
- Session.get replaces Query.get; session.in_transaction() replaces
  session.transaction; cache_ok on MediaPathType and FancyJSON.
- The tag-directory sync query joins through TagFile's relationships instead of
  the legacy two-entity outerjoin.
- Section-hint rows use attribute access (2.0 rows are tuples).
- Migrations run on an AUTOCOMMIT connection, matching 1.3, where each
  statement committed on its own.  2.0 autobegins, so the unique-stem
  migration's read snapshot could not become a writer after its helpers wrote
  on another connection ("database is locked").  The begin listener emits no
  BEGIN for AUTOCOMMIT connections.
- Newer alembic compares column types: WrolpiKV now describes the TEXT table
  its migration created.
- The test database installs its DDL inside engine.begin(); 2.0 connections
  no longer autocommit, so the FTS tables were rolled back.
- Dependabot now holds SQLAlchemy at 2.0 instead of 1.3.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-10-09 15:08:32 -06:00 • committed by Roland
parent bb8945e8ad
commit dab333124e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
25 changed files with 175 additions and 155 deletions

View file

@ -6,6 +6,7 @@ from abc import ABC
from dataclasses import dataclass
from typing import Callable, List, Optional, Tuple
from sqlalchemy import text
from sqlalchemy.orm import Session
from wrolpi.cmd import SINGLE_FILE_BIN, CHROMIUM, FIREFOX, DENO_BIN, SINGLE_FILE_DENO_SCRIPT
@ -405,7 +406,7 @@ def archive_cleanup():
with get_db_session(commit=True) as session:
# Remove any domain Collections without any Archives.
# Get all collection_ids that have archives
collection_ids = [i[0] for i in session.execute('SELECT DISTINCT collection_id FROM archive') if i[0]]
collection_ids = [i[0] for i in session.execute(text('SELECT DISTINCT collection_id FROM archive')) if i[0]]
# Find domain collections that have no archives
for collection in session.query(Collection).filter_by(kind='domain'):
if collection.id not in collection_ids:

View file

@ -5,7 +5,7 @@ from typing import Iterable, List, Optional
import pytz
from sqlalchemy import Column, Integer, ForeignKey, BigInteger, Index
from sqlalchemy.orm import relationship, Session, joinedload
from sqlalchemy.orm import Mapped, relationship, Session, joinedload
from wrolpi import dates
from wrolpi.collections import Collection
@ -36,9 +36,9 @@ class Archive(Base, ModelHelper):
id = Column(Integer, primary_key=True)
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'))
collection: Collection = relationship('Collection', primaryjoin='Archive.collection_id==Collection.id')
collection: Mapped[Collection] = relationship('Collection', primaryjoin='Archive.collection_id==Collection.id')
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False)
file_group: FileGroup = relationship('FileGroup')
file_group: Mapped[FileGroup] = relationship('FileGroup')
@staticmethod
def json_options() -> tuple:

View file

@ -135,7 +135,7 @@ async def test_model_archive_extracts_url_from_singlefile(async_client, test_ses
"collection_id should be set after URL extraction"
# Verify collection was created with correct domain
collection = test_session.query(Collection).get(archive.collection_id)
collection = test_session.get(Collection, archive.collection_id)
assert collection.name == domain
assert collection.kind == 'domain'

View file

@ -404,8 +404,7 @@ def _search_docs(session: Session, search_str=None, author=None, subject=None, l
from wrolpi.tags import Tag, TagFile
tagged_fg_ids = session.query(TagFile.file_group_id) \
.join(Tag, Tag.id == TagFile.tag_id) \
.filter(Tag.name.in_(tag_names)) \
.subquery()
.filter(Tag.name.in_(tag_names))
query = query.filter(FileGroup.id.in_(tagged_fg_ids))
unfiltered = not (search_str or author or subject or language or mimetype or tag_names)
@ -497,10 +496,10 @@ def _fetch_section_hints(session, file_group_ids, search_str):
rows = session.execute(sql, {'q': match, 'ids': list(file_group_ids)}).fetchall()
hints = {}
for row in rows:
hints[row['fg_id']] = {
'kind': row['kind'],
'ordinal': row['ordinal'],
'label': row['label'],
'snippet': row['snippet'],
hints[row.fg_id] = {
'kind': row.kind,
'ordinal': row.ordinal,
'label': row.label,
'snippet': row.snippet,
}
return hints

View file

@ -2,7 +2,7 @@ import pathlib
from typing import Optional
from sqlalchemy import Column, Integer, BigInteger, ForeignKey, String, Text, Index, or_
from sqlalchemy.orm import relationship, Session, joinedload
from sqlalchemy.orm import Mapped, relationship, Session, joinedload
from wrolpi.common import ModelHelper, Base
from wrolpi.db import get_db_session, serializer
@ -56,7 +56,7 @@ class Doc(ModelHelper, Base):
description = Column(Text)
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), nullable=False, unique=True)
file_group: FileGroup = relationship('FileGroup')
file_group: Mapped[FileGroup] = relationship('FileGroup')
sections = relationship('DocSection', back_populates='doc',
cascade='all, delete-orphan',
passive_deletes=True)

View file

@ -1259,7 +1259,7 @@ class VideoDownloader(Downloader, ABC):
# Update channel's collection's file_format to track the format used.
if executed.channel_id:
from modules.videos.lib import save_channels_config
channel_obj = session.query(Channel).get(executed.channel_id)
channel_obj = session.get(Channel, executed.channel_id)
if channel_obj and channel_obj.collection and not channel_obj.collection.file_format:
video_config = get_videos_downloader_config()
channel_obj.collection.file_format = video_config.file_name_format

View file

@ -3,8 +3,7 @@ import pathlib
from typing import Optional, Dict, List
from sqlalchemy import Column, Integer, String, Boolean, Date, ForeignKey, BigInteger, Index, text, JSON
from sqlalchemy.orm import relationship, Session, deferred, undefer, joinedload, selectinload
from sqlalchemy.orm.collections import InstrumentedList
from sqlalchemy.orm import Mapped, relationship, Session, deferred, undefer, joinedload, selectinload
from modules.videos.errors import UnknownVideo, UnknownChannel
from wrolpi.captions import read_captions, read_captions_with_timestamps
@ -49,7 +48,7 @@ class Video(ModelHelper, Base):
channel_id = Column(Integer, ForeignKey('channel.id'))
channel = relationship('Channel', primaryjoin='Video.channel_id==Channel.id', back_populates='videos')
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False)
file_group: FileGroup = relationship('FileGroup')
file_group: Mapped[FileGroup] = relationship('FileGroup')
def __repr__(self):
v = None
@ -628,7 +627,7 @@ class Channel(ModelHelper, Base):
info_json = deferred(Column(JSON))
info_date = Column(Date)
videos: InstrumentedList = relationship('Video', primaryjoin='Channel.id==Video.channel_id')
videos: Mapped[List['Video']] = relationship('Video', primaryjoin='Channel.id==Video.channel_id')
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'))
# Joined: a Channel's name, directory and Tag live on its Collection.
collection = relationship('Collection', foreign_keys=[collection_id], lazy='joined')

View file

@ -7,7 +7,7 @@ from typing import List, Tuple, OrderedDict as OrderedDictType, Dict, Optional,
from libzim import Archive, Searcher, Query, Entry, SuggestionSearcher
from sqlalchemy import Column, Integer, BigInteger, ForeignKey, Text, tuple_, Boolean, UniqueConstraint
from sqlalchemy.orm import relationship, Session, joinedload
from sqlalchemy.orm import Mapped, relationship, Session, joinedload
from sqlalchemy.orm.exc import NoResultFound # noqa
from modules.zim.errors import UnknownZimEntry, UnknownZimTagEntry, UnknownZim
@ -71,10 +71,10 @@ class Zim(Base, ModelHelper):
UniqueConstraint('path', name='zim_path_key'),
)
id = Column(Integer, primary_key=True)
path: pathlib.Path = Column(MediaPathType, nullable=False)
path: Mapped[pathlib.Path] = Column(MediaPathType, nullable=False)
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), nullable=False)
file_group: FileGroup = relationship('FileGroup')
file_group: Mapped[FileGroup] = relationship('FileGroup')
auto_search = Column(Boolean, default=True)
def __repr__(self):
@ -359,11 +359,11 @@ class TagZimEntry(Base):
)
tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True)
tag: Tag = relationship('Tag')
tag: Mapped[Tag] = relationship('Tag')
zim_id = Column(Integer, ForeignKey('zim.id', ondelete='CASCADE'), primary_key=True)
zim: Zim = relationship('Zim')
zim_entry: str = Column(Text, nullable=False, primary_key=True)
created_at: datetime = Column(TZDateTime, default=dates.now)
zim: Mapped[Zim] = relationship('Zim')
zim_entry: Mapped[str] = Column(Text, nullable=False, primary_key=True)
created_at: Mapped[datetime] = Column(TZDateTime, default=dates.now)
def __repr__(self):
tag = self.tag_id
@ -384,11 +384,11 @@ class TagZimEntry(Base):
class ZimSubscription(Base):
__tablename__ = 'zim_subscription'
id: int = Column(Integer, primary_key=True)
name: str = Column(Text, unique=True, nullable=False)
language: str = Column(Text, nullable=False)
download_id: int = Column(Integer, ForeignKey('download.id', ondelete='CASCADE'), nullable=False)
download: Download = relationship('Download', primaryjoin='ZimSubscription.download_id==Download.id')
id: Mapped[int] = Column(Integer, primary_key=True)
name: Mapped[str] = Column(Text, unique=True, nullable=False)
language: Mapped[str] = Column(Text, nullable=False)
download_id: Mapped[int] = Column(Integer, ForeignKey('download.id', ondelete='CASCADE'), nullable=False)
download: Mapped[Download] = relationship('Download', primaryjoin='ZimSubscription.download_id==Download.id')
def __repr__(self):
name = self.name