mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
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:
parent
bb8945e8ad
commit
dab333124e
25 changed files with 175 additions and 155 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue