2424log = logger .create ()
2525
2626
27+ def check_cascade_delete_on_fk (conn , table_name , expected_cascades , db_name = None ):
28+ """
29+ Check if foreign keys on a table have ON DELETE CASCADE.
30+
31+ Args:
32+ conn: SQLAlchemy connection object or sqlite3 connection
33+ table_name: Name of the table to check
34+ expected_cascades: Dict mapping column name to expected foreign key table
35+ e.g., {'book': 'books', 'user_id': 'user'}
36+
37+ Returns:
38+ bool: True if all foreign keys have CASCADE, False otherwise
39+ """
40+ try :
41+ # Detect connection type
42+ is_sqlalchemy = hasattr (conn , 'execute' ) and hasattr (conn .execute .__self__ , 'dialect' )
43+
44+ def execute_sql (sql ):
45+ if is_sqlalchemy :
46+ return conn .execute (text (sql ))
47+ else :
48+ return conn .execute (sql )
49+
50+ db_prefix = f"{ db_name } ." if db_name else ""
51+
52+ # Check if table exists
53+ result = execute_sql (
54+ f"SELECT name FROM { db_prefix } sqlite_master WHERE type='table' AND name='{ table_name } '"
55+ )
56+ if not result .fetchone ():
57+ return True # Table doesn't exist, no migration needed
58+
59+ # Get foreign key info
60+ pragma_prefix = f"{ db_name } ." if db_name else ""
61+ fk_info = execute_sql (f"PRAGMA { pragma_prefix } foreign_key_list({ table_name } )" ).fetchall ()
62+
63+ if not fk_info :
64+ return True # No foreign keys defined
65+
66+ # Check each foreign key for CASCADE
67+ for fk in fk_info :
68+ # PRAGMA foreign_key_list returns: (id, seq, table, from, to, on_update, on_delete, match)
69+ from_column = fk [3 ] # Column in this table
70+ to_table = fk [2 ] # Referenced table
71+ on_delete = fk [6 ] # ON DELETE action
72+
73+ # Check if this is an expected foreign key
74+ if from_column in expected_cascades :
75+ expected_table = expected_cascades [from_column ]
76+ if to_table == expected_table and on_delete != 'CASCADE' :
77+ log .debug (f"Table { table_name } .{ from_column } FK to { to_table } missing CASCADE (has: { on_delete } )" )
78+ return False
79+
80+ return True
81+
82+ except Exception as e :
83+ log .error (f"Error checking CASCADE on { table_name } : { e } " )
84+ return True # Assume OK to avoid breaking startup
85+
86+
2787def ensure_calibre_db_tables (conn ):
2888 """
2989 Ensure progress syncing tables for metadata.db (Calibre library) exist.
@@ -67,6 +127,8 @@ def ensure_checksum_table(conn):
67127 This function is called during database initialization to create the checksum
68128 table if it doesn't exist. If the table exists with a different schema, a
69129 warning is logged and the table is left as-is to allow for proper migration.
130+ If the schema matches but the foreign key is missing ON DELETE CASCADE,
131+ the table is rebuilt in-place to add CASCADE and orphan rows are dropped.
70132
71133 Args:
72134 conn: SQLAlchemy connection object or sqlite3 connection
@@ -119,6 +181,55 @@ def execute_sql(sql):
119181 )
120182 return # Skip creation, table exists but needs migration
121183
184+ # Check if foreign keys have CASCADE DELETE
185+ cascade_db_name = "calibre" if is_calibre_attached else None
186+ has_cascade = check_cascade_delete_on_fk (
187+ conn ,
188+ "book_format_checksums" ,
189+ {'book' : 'books' },
190+ db_name = cascade_db_name ,
191+ )
192+ if not has_cascade :
193+ log .warning (
194+ "book_format_checksums table missing ON DELETE CASCADE. "
195+ "Attempting in-place migration to add CASCADE..."
196+ )
197+ try :
198+ temp_table = f"{ table_prefix } book_format_checksums_new"
199+ books_table = f"{ table_prefix } books"
200+
201+ execute_sql (f"DROP TABLE IF EXISTS { temp_table } " )
202+ execute_sql (f"""
203+ CREATE TABLE { temp_table } (
204+ id INTEGER PRIMARY KEY AUTOINCREMENT,
205+ book INTEGER NOT NULL,
206+ format TEXT NOT NULL COLLATE NOCASE,
207+ checksum TEXT NOT NULL,
208+ version TEXT NOT NULL DEFAULT 'koreader',
209+ created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
210+ FOREIGN KEY (book) REFERENCES books(id) ON DELETE CASCADE
211+ )
212+ """ )
213+ execute_sql (f"""
214+ INSERT INTO { temp_table } (id, book, format, checksum, version, created)
215+ SELECT bfc.id, bfc.book, bfc.format, bfc.checksum, bfc.version, bfc.created
216+ FROM { table_name } AS bfc
217+ WHERE EXISTS (
218+ SELECT 1 FROM { books_table } AS b WHERE b.id = bfc.book
219+ )
220+ """ )
221+ execute_sql (f"DROP TABLE { table_name } " )
222+ execute_sql (f"ALTER TABLE { temp_table } RENAME TO book_format_checksums" )
223+ execute_sql (f"CREATE INDEX { table_prefix } idx_checksum ON book_format_checksums(checksum)" )
224+ execute_sql (f"CREATE INDEX { table_prefix } idx_checksum_version ON book_format_checksums(checksum, version)" )
225+ execute_sql (f"CREATE INDEX { table_prefix } idx_book_format ON book_format_checksums(book, format)" )
226+ execute_sql (f"CREATE INDEX { table_prefix } idx_created ON book_format_checksums(created)" )
227+ conn .commit ()
228+ log .info ("Migrated book_format_checksums to add ON DELETE CASCADE" )
229+ except Exception as migration_error :
230+ log .error (f"Failed to migrate book_format_checksums for CASCADE: { migration_error } " )
231+ table_exists = True
232+
122233 if not table_exists :
123234 # Create table for book format checksums
124235 execute_sql (f"""
@@ -129,7 +240,7 @@ def execute_sql(sql):
129240 checksum TEXT NOT NULL,
130241 version TEXT NOT NULL DEFAULT 'koreader',
131242 created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
132- FOREIGN KEY (book) REFERENCES books(id)
243+ FOREIGN KEY (book) REFERENCES books(id) ON DELETE CASCADE
133244 )
134245 """ )
135246 execute_sql (f"CREATE INDEX { table_prefix } idx_checksum ON book_format_checksums(checksum)" )
@@ -152,6 +263,8 @@ def ensure_kosync_progress_table(conn):
152263 This function is called during database initialization to create the KOSync
153264 progress table if it doesn't exist. If the table exists with a different schema,
154265 a warning is logged and the table is left as-is to allow for proper migration.
266+ If the schema matches but the foreign key is missing ON DELETE CASCADE,
267+ the table is rebuilt in-place to add CASCADE and orphan rows are dropped.
155268
156269 Args:
157270 conn: SQLAlchemy connection object or sqlite3 connection
@@ -191,6 +304,46 @@ def execute_sql(sql):
191304 )
192305 return # Skip creation, table exists but needs migration
193306
307+ # Check if foreign keys have CASCADE DELETE
308+ has_cascade = check_cascade_delete_on_fk (conn , "kosync_progress" , {'user_id' : 'user' })
309+ if not has_cascade :
310+ log .warning (
311+ "kosync_progress table missing ON DELETE CASCADE. "
312+ "Attempting in-place migration to add CASCADE..."
313+ )
314+ try :
315+ execute_sql ("DROP TABLE IF EXISTS kosync_progress_new" )
316+ execute_sql ("""
317+ CREATE TABLE kosync_progress_new (
318+ id INTEGER PRIMARY KEY AUTOINCREMENT,
319+ user_id INTEGER NOT NULL,
320+ document TEXT NOT NULL,
321+ progress TEXT NOT NULL,
322+ percentage REAL NOT NULL,
323+ device TEXT NOT NULL,
324+ device_id TEXT,
325+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
326+ FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
327+ )
328+ """ )
329+ execute_sql ("""
330+ INSERT INTO kosync_progress_new (id, user_id, document, progress, percentage, device, device_id, timestamp)
331+ SELECT kp.id, kp.user_id, kp.document, kp.progress, kp.percentage, kp.device, kp.device_id, kp.timestamp
332+ FROM kosync_progress AS kp
333+ WHERE EXISTS (
334+ SELECT 1 FROM user AS u WHERE u.id = kp.user_id
335+ )
336+ """ )
337+ execute_sql ("DROP TABLE kosync_progress" )
338+ execute_sql ("ALTER TABLE kosync_progress_new RENAME TO kosync_progress" )
339+ execute_sql ("CREATE INDEX idx_kosync_user_document ON kosync_progress(user_id, document)" )
340+ execute_sql ("CREATE INDEX idx_kosync_document ON kosync_progress(document)" )
341+ conn .commit ()
342+ log .info ("Migrated kosync_progress to add ON DELETE CASCADE" )
343+ except Exception as migration_error :
344+ log .error (f"Failed to migrate kosync_progress for CASCADE: { migration_error } " )
345+ table_exists = True
346+
194347 if not table_exists :
195348 # Create table for KOSync reading progress
196349 execute_sql ("""
@@ -203,7 +356,7 @@ def execute_sql(sql):
203356 device TEXT NOT NULL,
204357 device_id TEXT,
205358 timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
206- FOREIGN KEY (user_id) REFERENCES user(id)
359+ FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
207360 )
208361 """ )
209362 execute_sql ("CREATE INDEX idx_kosync_user_document ON kosync_progress(user_id, document)" )
@@ -241,7 +394,7 @@ class BookFormatChecksum(CalibreBase):
241394 __tablename__ = 'book_format_checksums'
242395
243396 id = Column (Integer , primary_key = True , autoincrement = True )
244- book = Column (Integer , ForeignKey ('books.id' ), nullable = False )
397+ book = Column (Integer , ForeignKey ('books.id' , ondelete = 'CASCADE' ), nullable = False )
245398 format = Column (String (collation = 'NOCASE' ), nullable = False )
246399 checksum = Column (String (32 ), nullable = False ) # MD5 hex digest is always 32 chars
247400 version = Column (String , nullable = False , default = 'koreader' ) # Algorithm version identifier
@@ -281,7 +434,7 @@ class KOSyncProgress(AppBase):
281434 __tablename__ = 'kosync_progress'
282435
283436 id = Column (Integer , primary_key = True )
284- user_id = Column (Integer , ForeignKey ('user.id' ), nullable = False )
437+ user_id = Column (Integer , ForeignKey ('user.id' , ondelete = 'CASCADE' ), nullable = False )
285438 document = Column (String , nullable = False )
286439 progress = Column (String , nullable = False )
287440 percentage = Column (Float , nullable = False )
0 commit comments