|
| 1 | +# Investigation: Foreign Key Constraints During transform() |
| 2 | + |
| 3 | +This document summarizes the investigation into how the `transform()` method handles |
| 4 | +foreign key constraints that **reference** the table being transformed (incoming FKs). |
| 5 | + |
| 6 | +## Background |
| 7 | + |
| 8 | +The `transform()` method in sqlite-utils performs the following steps: |
| 9 | +1. Create a new temporary table with the desired schema |
| 10 | +2. Copy data from the old table to the new table |
| 11 | +3. Drop the old table |
| 12 | +4. Rename the new table to the original table name |
| 13 | +5. Recreate indexes |
| 14 | + |
| 15 | +This raises the question: what happens to foreign key constraints in **other tables** |
| 16 | +that reference the table being transformed? |
| 17 | + |
| 18 | +## Key Finding: SQLite's ALTER TABLE RENAME Behavior |
| 19 | + |
| 20 | +SQLite's `ALTER TABLE ... RENAME TO` command automatically updates all foreign key |
| 21 | +references in the schema. This is documented in the SQLite documentation: |
| 22 | + |
| 23 | +> "The RENAME command renames the table, and also updates all references to the |
| 24 | +> table within the schema" - https://www.sqlite.org/lang_altertable.html |
| 25 | +
|
| 26 | +This means: |
| 27 | +- When `authors_new_xxx` is renamed to `authors`, all FK constraints that reference |
| 28 | + `authors` continue to work correctly |
| 29 | +- The FK references are stored by table **name**, and the rename updates them |
| 30 | + |
| 31 | +## Test Scenarios and Results |
| 32 | + |
| 33 | +### Scenario 1: Simple transform (no column rename) |
| 34 | +``` |
| 35 | +Setup: books.author_id REFERENCES authors(id) |
| 36 | +Action: db["authors"].transform(types={"name": str}) |
| 37 | +Result: FK constraints survive intact (both with FK ON and OFF) |
| 38 | +``` |
| 39 | + |
| 40 | +### Scenario 2: Rename a non-referenced column |
| 41 | +``` |
| 42 | +Setup: books.author_id REFERENCES authors(id) |
| 43 | +Action: db["authors"].transform(rename={"name": "author_name"}) |
| 44 | +Result: FK constraints survive intact (both with FK ON and OFF) |
| 45 | +``` |
| 46 | + |
| 47 | +### Scenario 3: Rename the referenced column (FK ON) |
| 48 | +``` |
| 49 | +Setup: books.author_id REFERENCES authors(id) |
| 50 | +Action: db["authors"].transform(rename={"id": "author_pk"}) |
| 51 | +Result: FAILS - "foreign key mismatch" error, transaction rolled back |
| 52 | +``` |
| 53 | +The transform correctly detects the FK violation via `PRAGMA foreign_key_check` |
| 54 | +and rolls back the transaction, preserving the original schema. |
| 55 | + |
| 56 | +### Scenario 4: Rename the referenced column (FK OFF) |
| 57 | +``` |
| 58 | +Setup: books.author_id REFERENCES authors(id) |
| 59 | +Action: db["authors"].transform(rename={"id": "author_pk"}) |
| 60 | +Result: Transform succeeds, but FK constraint is now BROKEN |
| 61 | +``` |
| 62 | +The FK in `books` still references `authors(id)` but that column no longer exists. |
| 63 | +Running `PRAGMA foreign_key_check` produces a "foreign key mismatch" error. |
| 64 | + |
| 65 | +### Scenario 5: Self-referential FK |
| 66 | +``` |
| 67 | +Setup: employees.manager_id REFERENCES employees(id) |
| 68 | +Action: db["employees"].transform(types={"name": str}) |
| 69 | +Result: FK constraint survives intact |
| 70 | +``` |
| 71 | + |
| 72 | +### Scenario 6: Multiple tables referencing the transformed table |
| 73 | +``` |
| 74 | +Setup: books.author_id, articles.writer_id, quotes.speaker_id all REFERENCE authors(id) |
| 75 | +Action: db["authors"].transform(types={"name": str}) |
| 76 | +Result: All FK constraints survive intact |
| 77 | +``` |
| 78 | + |
| 79 | +## How transform() Ensures FK Safety |
| 80 | + |
| 81 | +The `transform()` method (db.py lines 1853-1917) implements the following safety measures: |
| 82 | + |
| 83 | +1. **Saves FK enforcement state**: Checks `PRAGMA foreign_keys` before starting |
| 84 | +2. **Disables FK enforcement**: Sets `PRAGMA foreign_keys=0` during the transform |
| 85 | +3. **Executes transform SQL**: Within a transaction (`with self.db.conn:`) |
| 86 | +4. **Validates FK integrity**: Runs `PRAGMA foreign_key_check` after the transform |
| 87 | +5. **Rolls back on failure**: If FK check fails, the transaction is rolled back |
| 88 | +6. **Restores FK state**: Re-enables FK enforcement if it was originally on |
| 89 | + |
| 90 | +## Summary Table |
| 91 | + |
| 92 | +| Scenario | FK ON | FK OFF | |
| 93 | +|-------------------------------------|--------------------|--------------------| |
| 94 | +| Simple transform | Works, FKs intact | Works, FKs intact | |
| 95 | +| Rename non-referenced column | Works, FKs intact | Works, FKs intact | |
| 96 | +| Rename referenced column | FAILS (rollback) | Works, FKs BROKEN! | |
| 97 | +| Drop referenced column | FAILS (rollback) | Works, FKs BROKEN! | |
| 98 | +| Self-referential FK | Works, FKs intact | Works, FKs intact | |
| 99 | +| Multiple tables with FKs | Works, FKs intact | Works, FKs intact | |
| 100 | + |
| 101 | +## Known Issue: Leftover Temp Table on Failure |
| 102 | + |
| 103 | +When `transform()` fails (e.g., due to FK check failure), there may be a leftover |
| 104 | +temporary table (e.g., `authors_new_xxx`). This appears to be because the error |
| 105 | +occurs after some statements have executed but before the transaction fully commits. |
| 106 | + |
| 107 | +The original table remains intact, so this is a minor cosmetic issue rather than |
| 108 | +a data integrity problem. |
| 109 | + |
| 110 | +## Recommendations |
| 111 | + |
| 112 | +1. **Always use FK enforcement** (`PRAGMA foreign_keys=ON`) when working with |
| 113 | + relational data to ensure transform() catches FK violations early |
| 114 | + |
| 115 | +2. **Be cautious when renaming columns**: If a column is referenced by FKs from |
| 116 | + other tables, you'll need to update those FKs as well. Consider: |
| 117 | + - First transforming the referencing tables to update their FK constraints |
| 118 | + - Then transforming the referenced table to rename the column |
| 119 | + |
| 120 | +3. **Use `foreign_key_check`** after bulk operations with FK enforcement off to |
| 121 | + verify data integrity |
| 122 | + |
| 123 | +## Code References |
| 124 | + |
| 125 | +- `transform()` method: sqlite_utils/db.py:1853-1917 |
| 126 | +- `transform_sql()` method: sqlite_utils/db.py:1919-2127 |
| 127 | +- FK handling in transform_sql: sqlite_utils/db.py:1957-1993 |
| 128 | +- Related tests: tests/test_transform.py:301-500 |
0 commit comments