From d36996a131f1cb4b6f2ae075e097cc20ae14bc95 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Wed, 22 Jul 2026 10:39:48 +0530 Subject: [PATCH 1/8] fix(backend): enforce foreign key cascading deletes for embeddings and semantic labels --- backend/app/database/image_embeddings.py | 94 ++++++++---------------- backend/app/database/semantic_labels.py | 51 ++----------- backend/tests/test_image_embeddings.py | 2 + 3 files changed, 41 insertions(+), 106 deletions(-) diff --git a/backend/app/database/image_embeddings.py b/backend/app/database/image_embeddings.py index 5698a1355..100f9dbb3 100644 --- a/backend/app/database/image_embeddings.py +++ b/backend/app/database/image_embeddings.py @@ -1,12 +1,10 @@ from typing import List, Tuple import numpy as np -from app.database.images import _connect +from app.database.connection import get_db_connection def db_create_image_embeddings_table(): - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() cursor.execute( """ @@ -31,28 +29,21 @@ def db_create_image_embeddings_table(): cursor.execute( "ALTER TABLE image_embeddings ADD COLUMN scored_signature TEXT" ) - conn.commit() - finally: - if conn: - conn.close() def db_upsert_image_embeddings(rows: List[Tuple[str, str, np.ndarray]]): - conn = None - try: - conn = _connect() - cursor = conn.cursor() - - # Convert each embedding - db_rows = [ - ( - image_id, - model_version, - np.ascontiguousarray(embedding, dtype=np.float32).tobytes(), - ) - for image_id, model_version, embedding in rows - ] + # Convert each embedding + db_rows = [ + ( + image_id, + model_version, + np.ascontiguousarray(embedding, dtype=np.float32).tobytes(), + ) + for image_id, model_version, embedding in rows + ] + with get_db_connection() as conn: + cursor = conn.cursor() cursor.executemany( """ INSERT INTO image_embeddings (image_id, model_version, embedding) @@ -64,18 +55,11 @@ def db_upsert_image_embeddings(rows: List[Tuple[str, str, np.ndarray]]): """, db_rows, ) - conn.commit() - finally: - if conn: - conn.close() def db_get_all_embeddings(model_version: str) -> Tuple[List[str], np.ndarray]: - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() - cursor.execute( """ SELECT image_id, embedding FROM image_embeddings @@ -83,23 +67,20 @@ def db_get_all_embeddings(model_version: str) -> Tuple[List[str], np.ndarray]: """, (model_version,), ) - rows = cursor.fetchall() - if not rows: - return [], np.empty((0, 0), dtype=np.float32) - image_ids = [] - embeddings_list = [] + if not rows: + return [], np.empty((0, 0), dtype=np.float32) + + image_ids = [] + embeddings_list = [] - for image_id, blob in rows: - image_ids.append(image_id) - embeddings_list.append(np.frombuffer(blob, dtype=np.float32)) + for image_id, blob in rows: + image_ids.append(image_id) + embeddings_list.append(np.frombuffer(blob, dtype=np.float32)) - matrix = np.vstack(embeddings_list) - return image_ids, matrix - finally: - if conn: - conn.close() + matrix = np.vstack(embeddings_list) + return image_ids, matrix def db_get_embeddings_needing_scoring( @@ -107,9 +88,7 @@ def db_get_embeddings_needing_scoring( ) -> Tuple[List[str], np.ndarray]: """Embeddings whose semantic scores are missing or from another vocabulary/label state. Returns up to `limit` (image_ids, matrix).""" - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() cursor.execute( """ @@ -120,23 +99,18 @@ def db_get_embeddings_needing_scoring( (model_version, signature, limit), ) rows = cursor.fetchall() - if not rows: - return [], np.empty((0, 0), dtype=np.float32) - image_ids = [image_id for image_id, _ in rows] - matrix = np.vstack([np.frombuffer(blob, dtype=np.float32) for _, blob in rows]) - return image_ids, matrix - finally: - if conn: - conn.close() + if not rows: + return [], np.empty((0, 0), dtype=np.float32) + + image_ids = [image_id for image_id, _ in rows] + matrix = np.vstack([np.frombuffer(blob, dtype=np.float32) for _, blob in rows]) + return image_ids, matrix def db_count_embeddings(model_version: str | None = None) -> int: - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() - if model_version is not None: cursor.execute( "SELECT COUNT(*) FROM image_embeddings WHERE model_version = ?", @@ -144,9 +118,5 @@ def db_count_embeddings(model_version: str | None = None) -> int: ) else: cursor.execute("SELECT COUNT(*) FROM image_embeddings") - result = cursor.fetchone() return result[0] if result else 0 - finally: - if conn: - conn.close() diff --git a/backend/app/database/semantic_labels.py b/backend/app/database/semantic_labels.py index 18d1c0c24..e9e6bffc6 100644 --- a/backend/app/database/semantic_labels.py +++ b/backend/app/database/semantic_labels.py @@ -1,9 +1,7 @@ import json from typing import List, Tuple - import numpy as np - -from app.database.images import _connect +from app.database.connection import get_db_connection from app.logging.setup_logging import get_logger logger = get_logger(__name__) @@ -13,9 +11,7 @@ def db_create_semantic_labels_table(): - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() # Migrate the pre-vocabulary shell schema. It shipped with no writer, @@ -71,11 +67,6 @@ def db_create_semantic_labels_table(): """ ) - conn.commit() - finally: - if conn: - conn.close() - def db_upsert_semantic_vocabulary(labels: List[dict]) -> None: """Idempotently sync the seed vocabulary into mappings + semantic_labels. @@ -85,9 +76,7 @@ def db_upsert_semantic_vocabulary(labels: List[dict]) -> None: missing from the seed are deactivated (rows kept -- image_classes may reference them). """ - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT class_id, name FROM mappings") @@ -186,15 +175,11 @@ def db_upsert_semantic_vocabulary(labels: List[dict]) -> None: ) deactivated += 1 - conn.commit() if added or updated or skipped or deactivated: logger.info( f"Semantic vocabulary sync: {added} added, {updated} updated, " f"{deactivated} deactivated, {skipped} skipped" ) - finally: - if conn: - conn.close() def db_get_labels_needing_embeddings( @@ -202,9 +187,7 @@ def db_get_labels_needing_embeddings( ) -> List[Tuple[int, List[str]]]: """Active labels whose cached embedding is missing or belongs to a different checkpoint. Returns (class_id, descriptions) pairs.""" - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() cursor.execute( """ @@ -219,9 +202,6 @@ def db_get_labels_needing_embeddings( (class_id, json.loads(descriptions)) for class_id, descriptions in cursor.fetchall() ] - finally: - if conn: - conn.close() def db_update_label_embeddings( @@ -231,9 +211,7 @@ def db_update_label_embeddings( Same raw-float32 blob format as image_embeddings.embedding. """ - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() cursor.executemany( """ @@ -250,10 +228,6 @@ def db_update_label_embeddings( for class_id, embedding, model_version in rows ], ) - conn.commit() - finally: - if conn: - conn.close() def db_get_active_label_embeddings( @@ -266,9 +240,7 @@ def db_get_active_label_embeddings( matrix, so row order must be deterministic); threshold is None where the label has no per-label override. """ - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() cursor.execute( """ @@ -292,9 +264,6 @@ def db_get_active_label_embeddings( [np.frombuffer(blob, dtype=np.float32) for _, _, _, blob in rows] ) return meta, matrix - finally: - if conn: - conn.close() def db_write_image_semantic_scores( @@ -303,9 +272,7 @@ def db_write_image_semantic_scores( """Replace each image's semantic tag rows with the given (class_id, score) pairs and stamp its scored_signature. YOLO rows (class_id below the offset) are never touched.""" - conn = None - try: - conn = _connect() + with get_db_connection() as conn: cursor = conn.cursor() for image_id, pairs in batch: cursor.execute( @@ -322,7 +289,3 @@ def db_write_image_semantic_scores( "WHERE image_id = ?", (signature, image_id), ) - conn.commit() - finally: - if conn: - conn.close() diff --git a/backend/tests/test_image_embeddings.py b/backend/tests/test_image_embeddings.py index 0871953b2..b17d02458 100644 --- a/backend/tests/test_image_embeddings.py +++ b/backend/tests/test_image_embeddings.py @@ -4,6 +4,7 @@ import app.database.images as images_module import app.database.folders as folders_module import app.database.yolo_mapping as yolo_mapping_module +import app.database.connection as connection_module from app.database.images import _connect, db_create_images_table from app.database.folders import db_create_folders_table from app.database.yolo_mapping import db_create_YOLO_classes_table @@ -36,6 +37,7 @@ def _isolated_db(tmp_path, monkeypatch): monkeypatch.setattr(images_module, "DATABASE_PATH", db_path) monkeypatch.setattr(folders_module, "DATABASE_PATH", db_path) monkeypatch.setattr(yolo_mapping_module, "DATABASE_PATH", db_path) + monkeypatch.setattr(connection_module, "DATABASE_PATH", db_path) # images' schema FK-references folders/mappings; SQLite validates that # the referenced tables exist at INSERT time even for a NULL FK value, From c14ec24eb45ed723907775b3f9d348e8041b5134 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Wed, 22 Jul 2026 21:55:12 +0530 Subject: [PATCH 2/8] test(backend): add database connection isolation to test_semantic_labels.py --- backend/tests/test_semantic_labels.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/tests/test_semantic_labels.py b/backend/tests/test_semantic_labels.py index 965a515af..a8ae7bd72 100644 --- a/backend/tests/test_semantic_labels.py +++ b/backend/tests/test_semantic_labels.py @@ -4,6 +4,7 @@ import app.database.images as images_module import app.database.folders as folders_module import app.database.yolo_mapping as yolo_mapping_module +import app.database.connection as connection_module from app.database.images import _connect, db_create_images_table from app.database.folders import db_create_folders_table from app.database.yolo_mapping import db_create_YOLO_classes_table @@ -32,6 +33,7 @@ def _isolated_db(tmp_path, monkeypatch): monkeypatch.setattr(images_module, "DATABASE_PATH", db_path) monkeypatch.setattr(folders_module, "DATABASE_PATH", db_path) monkeypatch.setattr(yolo_mapping_module, "DATABASE_PATH", db_path) + monkeypatch.setattr(connection_module, "DATABASE_PATH", db_path) db_create_YOLO_classes_table() db_create_folders_table() From c66a73c1bf317eadff84149f3b43d386b4add827 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Thu, 23 Jul 2026 09:37:43 +0530 Subject: [PATCH 3/8] fix(backend): use get_db_connection for production image deletions and cascading delete test --- backend/app/database/images.py | 61 ++++++++++---------------- backend/tests/test_image_embeddings.py | 7 ++- 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 5c6057b98..1c034b649 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -10,6 +10,7 @@ DATABASE_PATH, ) from app.logging.setup_logging import get_logger +from app.database.connection import get_db_connection # Initialize logger logger = get_logger(__name__) @@ -63,8 +64,7 @@ def db_create_images_table() -> None: cursor = conn.cursor() # Create new images table with merged fields including Memories feature columns - cursor.execute( - """ + cursor.execute(""" CREATE TABLE IF NOT EXISTS images ( id TEXT PRIMARY KEY, path VARCHAR UNIQUE, @@ -79,8 +79,7 @@ def db_create_images_table() -> None: captured_at DATETIME, FOREIGN KEY (folder_id) REFERENCES folders(folder_id) ON DELETE CASCADE ) - """ - ) + """) # Create indexes for Memories feature queries cursor.execute("CREATE INDEX IF NOT EXISTS ix_images_latitude ON images(latitude)") @@ -95,8 +94,7 @@ def db_create_images_table() -> None: ) # Create new image_classes junction table - cursor.execute( - """ + cursor.execute(""" CREATE TABLE IF NOT EXISTS image_classes ( image_id TEXT, class_id INTEGER, @@ -105,8 +103,7 @@ def db_create_images_table() -> None: FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE, FOREIGN KEY (class_id) REFERENCES mappings(class_id) ON DELETE CASCADE ) - """ - ) + """) # score: semantic-label match score (NULL for YOLO rows). Guarded ALTER # because shipped databases predate the column and CREATE IF NOT EXISTS @@ -279,15 +276,13 @@ def db_get_untagged_images() -> List[UntaggedImageRecord]: cursor = conn.cursor() try: - cursor.execute( - """ + cursor.execute(""" SELECT i.id, i.path, i.folder_id, i.thumbnailPath, i.metadata FROM images i JOIN folders f ON i.folder_id = f.folder_id WHERE f.AI_Tagging = TRUE AND i.isTagged = FALSE - """ - ) + """) results = cursor.fetchall() @@ -326,15 +321,13 @@ def db_get_unembedded_images() -> List[UntaggedImageRecord]: cursor = conn.cursor() try: - cursor.execute( - """ + cursor.execute(""" SELECT i.id, i.path, i.folder_id, i.thumbnailPath, i.metadata FROM images i JOIN folders f ON i.folder_id = f.folder_id WHERE f.AI_Tagging = TRUE AND i.isEmbedded = FALSE - """ - ) + """) results = cursor.fetchall() @@ -473,25 +466,21 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: if not image_ids: return True - conn = _connect() - cursor = conn.cursor() - try: - # Create placeholders for the IN clause - placeholders = ",".join("?" for _ in image_ids) - cursor.execute( - f"DELETE FROM images WHERE id IN ({placeholders})", - image_ids, - ) - conn.commit() - logger.info(f"Deleted {cursor.rowcount} obsolete image(s) from database") + with get_db_connection() as conn: + cursor = conn.cursor() + # Create placeholders for the IN clause + placeholders = ",".join("?" for _ in image_ids) + cursor.execute( + f"DELETE FROM images WHERE id IN ({placeholders})", + image_ids, + ) + row_count = cursor.rowcount + logger.info(f"Deleted {row_count} obsolete image(s) from database") return True except sqlite3.Error as e: logger.error(f"Error deleting images: {e}") - conn.rollback() return False - finally: - conn.close() def db_toggle_image_favourite_status(image_id: str) -> bool: @@ -977,8 +966,7 @@ def db_get_images_with_location() -> List[dict]: cursor = conn.cursor() try: - cursor.execute( - """ + cursor.execute(""" SELECT i.id, i.path, @@ -998,8 +986,7 @@ def db_get_images_with_location() -> List[dict]: AND i.longitude IS NOT NULL GROUP BY i.id ORDER BY i.captured_at DESC - """ - ) + """) results = cursor.fetchall() @@ -1044,8 +1031,7 @@ def db_get_all_images_for_memories() -> List[dict]: cursor = conn.cursor() try: - cursor.execute( - """ + cursor.execute(""" SELECT i.id, i.path, @@ -1063,8 +1049,7 @@ def db_get_all_images_for_memories() -> List[dict]: LEFT JOIN mappings m ON ic.class_id = m.class_id GROUP BY i.id ORDER BY i.captured_at DESC - """ - ) + """) results = cursor.fetchall() diff --git a/backend/tests/test_image_embeddings.py b/backend/tests/test_image_embeddings.py index b17d02458..0f31ab7de 100644 --- a/backend/tests/test_image_embeddings.py +++ b/backend/tests/test_image_embeddings.py @@ -6,6 +6,7 @@ import app.database.yolo_mapping as yolo_mapping_module import app.database.connection as connection_module from app.database.images import _connect, db_create_images_table +from app.database.connection import get_db_connection from app.database.folders import db_create_folders_table from app.database.yolo_mapping import db_create_YOLO_classes_table from app.database.image_embeddings import ( @@ -164,10 +165,8 @@ def test_deleting_image_cascades_to_its_embedding(self): ) assert db_count_embeddings("siglip2-base-patch16-224") == 1 - conn = _connect() - conn.execute("DELETE FROM images WHERE id = ?", ("img7",)) - conn.commit() - conn.close() + with get_db_connection() as conn: + conn.execute("DELETE FROM images WHERE id = ?", ("img7",)) ids, _ = db_get_all_embeddings("siglip2-base-patch16-224") assert "img7" not in ids From cdb3dee7915a929cd6191a97cec8d16ac300d644 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Thu, 23 Jul 2026 09:53:56 +0530 Subject: [PATCH 4/8] fix(backend): bind DATABASE_PATH dynamically from connection module in images.py --- backend/app/database/images.py | 44 ++++++++++++++++------------------ 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 1c034b649..a8a8bb077 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -6,12 +6,13 @@ from datetime import datetime # App-specific imports -from app.config.settings import ( - DATABASE_PATH, -) -from app.logging.setup_logging import get_logger +import app.database.connection as connection_module from app.database.connection import get_db_connection +DATABASE_PATH = connection_module.DATABASE_PATH + +from app.logging.setup_logging import get_logger + # Initialize logger logger = get_logger(__name__) @@ -53,7 +54,7 @@ class UntaggedImageRecord(TypedDict): def _connect() -> sqlite3.Connection: - conn = sqlite3.connect(DATABASE_PATH) + conn = sqlite3.connect(connection_module.DATABASE_PATH) # Ensure ON DELETE CASCADE and other FKs are enforced conn.execute("PRAGMA foreign_keys = ON") return conn @@ -484,28 +485,25 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: def db_toggle_image_favourite_status(image_id: str) -> bool: - conn = sqlite3.connect(DATABASE_PATH) - cursor = conn.cursor() try: - cursor.execute("SELECT id FROM images WHERE id = ?", (image_id,)) - if not cursor.fetchone(): - return False - cursor.execute( - """ - UPDATE images - SET isFavourite = CASE WHEN isFavourite = 1 THEN 0 ELSE 1 END - WHERE id = ? - """, - (image_id,), - ) - conn.commit() - return cursor.rowcount > 0 + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT id FROM images WHERE id = ?", (image_id,)) + if not cursor.fetchone(): + return False + cursor.execute( + """ + UPDATE images + SET isFavourite = CASE WHEN isFavourite = 1 THEN 0 ELSE 1 END + WHERE id = ? + """, + (image_id,), + ) + row_count = cursor.rowcount + return row_count > 0 except sqlite3.Error as e: logger.error(f"Database error: {e}") - conn.rollback() return False - finally: - conn.close() def db_get_image_by_id(image_id: str) -> Optional[dict]: From ccede6531a60ae6aebfa6c90e84f7d6137f4869b Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Thu, 23 Jul 2026 10:04:12 +0530 Subject: [PATCH 5/8] fix(backend): correct imports order to resolve Ruff E402 linter error --- backend/app/database/images.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/app/database/images.py b/backend/app/database/images.py index a8a8bb077..ebcdd21f8 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -8,11 +8,10 @@ # App-specific imports import app.database.connection as connection_module from app.database.connection import get_db_connection +from app.logging.setup_logging import get_logger DATABASE_PATH = connection_module.DATABASE_PATH -from app.logging.setup_logging import get_logger - # Initialize logger logger = get_logger(__name__) From 6c56416eeb11b096cf540f2e3bb12d148ca7f34e Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Thu, 23 Jul 2026 10:24:27 +0530 Subject: [PATCH 6/8] fix(backend): format images.py and add end-to-end cascade deletion regression test --- backend/app/database/images.py | 36 +++++++++++++------- backend/tests/test_image_embeddings.py | 47 ++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/backend/app/database/images.py b/backend/app/database/images.py index ebcdd21f8..de5754426 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -64,7 +64,8 @@ def db_create_images_table() -> None: cursor = conn.cursor() # Create new images table with merged fields including Memories feature columns - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS images ( id TEXT PRIMARY KEY, path VARCHAR UNIQUE, @@ -79,7 +80,8 @@ def db_create_images_table() -> None: captured_at DATETIME, FOREIGN KEY (folder_id) REFERENCES folders(folder_id) ON DELETE CASCADE ) - """) + """ + ) # Create indexes for Memories feature queries cursor.execute("CREATE INDEX IF NOT EXISTS ix_images_latitude ON images(latitude)") @@ -94,7 +96,8 @@ def db_create_images_table() -> None: ) # Create new image_classes junction table - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS image_classes ( image_id TEXT, class_id INTEGER, @@ -103,7 +106,8 @@ def db_create_images_table() -> None: FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE, FOREIGN KEY (class_id) REFERENCES mappings(class_id) ON DELETE CASCADE ) - """) + """ + ) # score: semantic-label match score (NULL for YOLO rows). Guarded ALTER # because shipped databases predate the column and CREATE IF NOT EXISTS @@ -276,13 +280,15 @@ def db_get_untagged_images() -> List[UntaggedImageRecord]: cursor = conn.cursor() try: - cursor.execute(""" + cursor.execute( + """ SELECT i.id, i.path, i.folder_id, i.thumbnailPath, i.metadata FROM images i JOIN folders f ON i.folder_id = f.folder_id WHERE f.AI_Tagging = TRUE AND i.isTagged = FALSE - """) + """ + ) results = cursor.fetchall() @@ -321,13 +327,15 @@ def db_get_unembedded_images() -> List[UntaggedImageRecord]: cursor = conn.cursor() try: - cursor.execute(""" + cursor.execute( + """ SELECT i.id, i.path, i.folder_id, i.thumbnailPath, i.metadata FROM images i JOIN folders f ON i.folder_id = f.folder_id WHERE f.AI_Tagging = TRUE AND i.isEmbedded = FALSE - """) + """ + ) results = cursor.fetchall() @@ -963,7 +971,8 @@ def db_get_images_with_location() -> List[dict]: cursor = conn.cursor() try: - cursor.execute(""" + cursor.execute( + """ SELECT i.id, i.path, @@ -983,7 +992,8 @@ def db_get_images_with_location() -> List[dict]: AND i.longitude IS NOT NULL GROUP BY i.id ORDER BY i.captured_at DESC - """) + """ + ) results = cursor.fetchall() @@ -1028,7 +1038,8 @@ def db_get_all_images_for_memories() -> List[dict]: cursor = conn.cursor() try: - cursor.execute(""" + cursor.execute( + """ SELECT i.id, i.path, @@ -1046,7 +1057,8 @@ def db_get_all_images_for_memories() -> List[dict]: LEFT JOIN mappings m ON ic.class_id = m.class_id GROUP BY i.id ORDER BY i.captured_at DESC - """) + """ + ) results = cursor.fetchall() diff --git a/backend/tests/test_image_embeddings.py b/backend/tests/test_image_embeddings.py index 0f31ab7de..08df8d043 100644 --- a/backend/tests/test_image_embeddings.py +++ b/backend/tests/test_image_embeddings.py @@ -170,3 +170,50 @@ def test_deleting_image_cascades_to_its_embedding(self): ids, _ = db_get_all_embeddings("siglip2-base-patch16-224") assert "img7" not in ids + + def test_deleting_image_cascades_to_embeddings_and_classes_regression(self): + # 1. Insert two dummy images + _insert_dummy_image("img7") + _insert_dummy_image("img8") + + # 2. Insert embeddings for both + db_upsert_image_embeddings( + [ + ("img7", "siglip2-base-patch16-224", np.ones(3, dtype=np.float32)), + ("img8", "siglip2-base-patch16-224", np.ones(3, dtype=np.float32)), + ] + ) + + # 3. Insert image classes (semantic scores) for both + with get_db_connection() as conn: + conn.execute( + "INSERT INTO image_classes (image_id, class_id, score) VALUES (?, 1, 0.85)", + ("img7",), + ) + conn.execute( + "INSERT INTO image_classes (image_id, class_id, score) VALUES (?, 1, 0.95)", + ("img8",), + ) + + # Verify initial state + assert db_count_embeddings("siglip2-base-patch16-224") == 2 + with get_db_connection() as conn: + res = conn.execute("SELECT COUNT(*) FROM image_classes").fetchone() + assert res[0] == 2 + + # 4. Call production delete logic for img7 + from app.database.images import db_delete_images_by_ids + + db_delete_images_by_ids(["img7"]) + + # 5. Assert img7 cascades deleted, but img8 remains intact + ids, _ = db_get_all_embeddings("siglip2-base-patch16-224") + assert "img7" not in ids + assert "img8" in ids + + with get_db_connection() as conn: + remaining_classes = conn.execute( + "SELECT image_id FROM image_classes" + ).fetchall() + assert len(remaining_classes) == 1 + assert remaining_classes[0][0] == "img8" From dc343ae61279f35ab7147e44ae6d764fc4ebba85 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Wed, 29 Jul 2026 15:44:20 +0530 Subject: [PATCH 7/8] fix(backend): dynamically resolve database path in connection manager and images module --- backend/app/database/connection.py | 14 ++++++++++++-- backend/app/database/images.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/app/database/connection.py b/backend/app/database/connection.py index d82ad6950..e585ba249 100644 --- a/backend/app/database/connection.py +++ b/backend/app/database/connection.py @@ -1,11 +1,21 @@ import sqlite3 from contextlib import contextmanager from typing import Generator -from app.config.settings import DATABASE_PATH +import app.config.settings as settings from app.logging.setup_logging import get_logger logger = get_logger(__name__) +ORIGINAL_DATABASE_PATH = settings.DATABASE_PATH +DATABASE_PATH = settings.DATABASE_PATH + + +def get_database_path() -> str: + """Resolve the active database path dynamically, supporting various test patching styles.""" + if DATABASE_PATH != ORIGINAL_DATABASE_PATH: + return DATABASE_PATH + return settings.DATABASE_PATH + @contextmanager def get_db_connection() -> Generator[sqlite3.Connection, None, None]: @@ -16,7 +26,7 @@ def get_db_connection() -> Generator[sqlite3.Connection, None, None]: - Works for both single and multi-step transactions - Automatically commits on success or rolls back on failure """ - conn = sqlite3.connect(DATABASE_PATH) + conn = sqlite3.connect(get_database_path()) # --- Strict enforcement of all relational and logical rules --- conn.execute("PRAGMA foreign_keys = ON;") # Enforce FK constraints diff --git a/backend/app/database/images.py b/backend/app/database/images.py index c254bc38e..740bc0a8a 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -57,7 +57,7 @@ class UntaggedImageRecord(TypedDict): def _connect() -> sqlite3.Connection: - conn = sqlite3.connect(connection_module.DATABASE_PATH) + conn = sqlite3.connect(connection_module.get_database_path()) # Ensure ON DELETE CASCADE and other FKs are enforced conn.execute("PRAGMA foreign_keys = ON") return conn From 7417b6b7dfd9d6e830f4b8c726f6d71db3450ba3 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Wed, 29 Jul 2026 18:23:55 +0530 Subject: [PATCH 8/8] fix(backend): batch image deletions and wrap connection docstring to adhere to PEP 8 --- backend/app/database/connection.py | 4 +++- backend/app/database/images.py | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/backend/app/database/connection.py b/backend/app/database/connection.py index e585ba249..8eb04e8aa 100644 --- a/backend/app/database/connection.py +++ b/backend/app/database/connection.py @@ -11,7 +11,9 @@ def get_database_path() -> str: - """Resolve the active database path dynamically, supporting various test patching styles.""" + """Resolve the active database path dynamically, supporting + various test patching styles. + """ if DATABASE_PATH != ORIGINAL_DATABASE_PATH: return DATABASE_PATH return settings.DATABASE_PATH diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 740bc0a8a..02584d762 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -482,16 +482,18 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: return True try: + total_deleted = 0 with get_db_connection() as conn: cursor = conn.cursor() - # Create placeholders for the IN clause - placeholders = ",".join("?" for _ in image_ids) - cursor.execute( - f"DELETE FROM images WHERE id IN ({placeholders})", - image_ids, - ) - row_count = cursor.rowcount - logger.info(f"Deleted {row_count} obsolete image(s) from database") + for start in range(0, len(image_ids), SQLITE_ID_CHUNK): + chunk = image_ids[start : start + SQLITE_ID_CHUNK] + placeholders = ",".join("?" for _ in chunk) + cursor.execute( + f"DELETE FROM images WHERE id IN ({placeholders})", + chunk, + ) + total_deleted += cursor.rowcount + logger.info(f"Deleted {total_deleted} obsolete image(s) from database") return True except sqlite3.Error as e: logger.error(f"Error deleting images: {e}")