diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 4c449c5..3331510 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -20,6 +20,7 @@ from datetime import datetime from sqlalchemy import Select, and_, exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from ..models import Artist, ImageProvenance, ImageRecord, Post, Tag from ..models.tag import image_tag @@ -139,10 +140,17 @@ def _provenance_clause(post_id, artist_id): # path now drops NULL-source provenance rows (filesystem-imported # content) which would otherwise vanish from artist-filtered # gallery views. + # ALIAS Post: the gallery query outer-joins Post on + # ImageRecord.primary_post_id (`_outer_join_primary_post`). + # SQLAlchemy would otherwise correlate a bare `Post` reference + # in this EXISTS subquery to that outer Post (which is NULL for + # images with no primary post), and the filter would silently + # match nothing. + post_inner = aliased(Post) return exists().where( ImageProvenance.image_record_id == ImageRecord.id, - ImageProvenance.post_id == Post.id, - Post.artist_id == artist_id, + ImageProvenance.post_id == post_inner.id, + post_inner.artist_id == artist_id, ) return None diff --git a/tests/test_importer_provenance_race.py b/tests/test_importer_provenance_race.py index 68ba96c..970a76e 100644 --- a/tests/test_importer_provenance_race.py +++ b/tests/test_importer_provenance_race.py @@ -29,7 +29,6 @@ from backend.app.models import ( ImageProvenance, ImageRecord, ImportSettings, - Source, ) from backend.app.services.importer import Importer from backend.app.services.thumbnailer import Thumbnailer @@ -139,9 +138,11 @@ def test_apply_sidecar_recovers_from_integrity_error( r = importer.import_one(m) assert r.status == "imported" rec = importer.session.get(ImageRecord, r.image_id) - src = importer.session.execute(select(Source)).scalar_one() + # alembic 0030 stopped creating synthetic Source rows for filesystem + # sidecars; the Post sits null-source and the provenance row points at + # it directly. The race-recovery path tested below operates on + # ImageProvenance regardless of Source presence. assert rec is not None - assert src is not None # Monkeypatch session.execute so the FIRST select inside _apply_sidecar's # existence-check returns a "no row" wrapper. Subsequent selects (e.g. diff --git a/tests/test_phash_dedup.py b/tests/test_phash_dedup.py index 83b5725..da57ac2 100644 --- a/tests/test_phash_dedup.py +++ b/tests/test_phash_dedup.py @@ -15,7 +15,6 @@ from backend.app.models import ( ImageRecord, ImportSettings, Post, - Source, Tag, TagKind, ) @@ -197,10 +196,11 @@ def test_supersede_applies_new_file_sidecar(importer, import_layout): assert post.post_title == "Set 1" assert "big version" in (post.description or "") - source = importer.session.execute( - select(Source).where(Source.id == post.source_id) - ).scalar_one() - assert source.platform == "patreon" + # Filesystem sidecars no longer create a synthetic Source (alembic 0030). + # The Post sits null-source; the platform context is captured in the + # sidecar JSON / raw_metadata, not in a phantom Source row. + assert post.source_id is None + assert post.artist_id is not None prov_count = importer.session.execute( select(func.count(ImageProvenance.id)) diff --git a/tests/test_sidecar_import.py b/tests/test_sidecar_import.py index f9ebabc..1984d99 100644 --- a/tests/test_sidecar_import.py +++ b/tests/test_sidecar_import.py @@ -75,9 +75,15 @@ def test_sidecar_creates_provenance(importer, import_layout): r = importer.import_one(m) assert r.status == "imported" rec = importer.session.get(ImageRecord, r.image_id) - src = importer.session.execute(select(Source)).scalar_one() post = importer.session.execute(select(Post)).scalar_one() - assert src.platform == "patreon" + # Filesystem-imported sidecar posts no longer create a synthetic Source + # (alembic 0030 / nullable post.source_id refactor). The Post is linked + # to the artist via Post.artist_id; Post.source_id stays NULL until a + # real subscription for the (artist, platform) gets added. + assert post.source_id is None + assert importer.session.execute( + select(func.count()).select_from(Source) + ).scalar_one() == 0 assert post.external_post_id == "555" assert post.post_url == "https://patreon.com/posts/555" assert post.post_title == "Set 1" @@ -106,9 +112,11 @@ def test_reimport_same_post_idempotent(importer, import_layout): _sidecar(m2, payload) r2 = importer.import_one(m2) assert r2.status == "imported" + # No synthetic Source after alembic 0030; both imports still resolve to + # a single null-source Post (deduped by uq_post_artist_external_id_null_source). assert importer.session.execute( select(func.count()).select_from(Source) - ).scalar_one() == 1 + ).scalar_one() == 0 assert importer.session.execute( select(func.count()).select_from(Post) ).scalar_one() == 1 @@ -164,5 +172,11 @@ def test_sidecar_artist_used_when_no_folder_artist(importer, import_layout): a = importer.session.execute( select(Artist).where(Artist.slug == "yuki") ).scalar_one() - src = importer.session.execute(select(Source)).scalar_one() - assert src.artist_id == a.id + # No synthetic Source after alembic 0030; the artist linkage lives on + # Post.artist_id (NOT NULL FK). + post = importer.session.execute(select(Post)).scalar_one() + assert post.artist_id == a.id + assert post.source_id is None + assert importer.session.execute( + select(func.count()).select_from(Source) + ).scalar_one() == 0