Skip to content

Commit 8379f2d

Browse files
Merge pull request #848 from doxxx/fk-delete-cascade
fix: cascade deletes for progress syncing tables
2 parents 3f55f12 + d153563 commit 8379f2d

4 files changed

Lines changed: 313 additions & 7 deletions

File tree

CONTRIBUTORS

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,6 @@ Copyright (C) 2024-2026 Calibre-Web Automated contributors
314314
- zelazna (1 commits)
315315
- zhiyue (1 commits)
316316
# Fork Contributors (crocodilestick/calibre-web-automated)
317-
318317
- crocodilestick (962 commits)
319318
- jmarmstrong1207 (73 commits)
320319
- demitrix (30 commits)

cps/progress_syncing/models.py

Lines changed: 157 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,66 @@
2424
log = 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+
2787
def 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)

tests/unit/test_generate_checksums.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def create_minimal_calibre_library(library_path: Path):
6262
format TEXT NOT NULL,
6363
uncompressed_size INTEGER DEFAULT 0,
6464
name TEXT NOT NULL,
65-
FOREIGN KEY (book) REFERENCES books(id)
65+
FOREIGN KEY (book) REFERENCES books(id) ON DELETE CASCADE
6666
)
6767
''')
6868

@@ -74,7 +74,7 @@ def create_minimal_calibre_library(library_path: Path):
7474
checksum TEXT NOT NULL,
7575
version TEXT NOT NULL DEFAULT 'koreader',
7676
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
77-
FOREIGN KEY (book) REFERENCES books(id)
77+
FOREIGN KEY (book) REFERENCES books(id) ON DELETE CASCADE
7878
)
7979
''')
8080

0 commit comments

Comments
 (0)