-
Notifications
You must be signed in to change notification settings - Fork 346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
🤔 Discussion — SQLite: disabling foreign keys for migration/import? #2471
Comments
No, I don't want to give applications the ability to execute transaction/savepoint statements directly, as this will make it difficult to impossible for the runtime code to keep track of the current state of the transaction stack, which it needs to do in order to implement implicit transactions and such. Instead, perhaps we should provide a more explicit API for setting the |
Right, well I missed something here. The lack of state.blockConcurrencyWhile(async () => {
sql.exec(`
PRAGMA foreign_keys = OFF;
CREATE TABLE IF NOT EXISTS "A" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"bId" INTEGER NOT NULL REFERENCES "B" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO A VALUES(1,1);
CREATE TABLE IF NOT EXISTS "B" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT
);
INSERT INTO B VALUES(1);
`)
})
state.blockConcurrencyWhile(async () => {
sql.exec(`PRAGMA foreign_keys = ON;`)
}) I mistakenly thought that Gut feel says the simplest thing would be if D1 detects a migration/import starting with |
@kentonv seeing some strange behaviour here. In my test, the following all pass: state.blockConcurrencyWhile(async () => {
sql.exec(`PRAGMA foreign_keys = OFF;`)
// the SQL that needs foreign_keys=OFF to work
sql.exec(`CREATE A ...; INSERT INTO A ...; CREATE B ...;`)
}) state.blockConcurrencyWhile(async () => {
sql.exec(`PRAGMA foreign_keys = OFF;`)
})
state.blockConcurrencyWhile(async () => {
sql.exec(`CREATE A ...; INSERT INTO A ...; CREATE B ...;`)
}) state.blockConcurrencyWhile(async () => {
sql.exec(`PRAGMA foreign_keys = OFF;`)
})
await scheduler.wait(1)
sql.exec(`CREATE A ...; INSERT INTO A ...; CREATE B ...;`) But these fail with state.blockConcurrencyWhile(async () => {
sql.exec(`PRAGMA foreign_keys = OFF;`)
})
sql.exec(`CREATE A ...; INSERT INTO A ...; CREATE B ...;`) state.blockConcurrencyWhile(async () => {
sql.exec(`PRAGMA foreign_keys = OFF;`)
})
storage.transactionSync(() => {
sql.exec(`CREATE A ...; INSERT INTO A ...; CREATE B ...;`)
}) The lattermost was how I was going to implement this in D1. Is it obvious to you why that's not working? |
But if the inner callback doesn't do anything async anyway, then |
Oh of course, I'm so used to everything being synchronous. I've raised #2479 because I think that's the only workerd change we need to go and build this all in D1. |
We have a couple of related issues:
1. D1 export produces unimportable files
D1 Export follows SQL dump, which outputs SQL in the following order for a DB with two tables, A and B:
pragma defer_foreign_keys=TRUE
If table A has a column that references B, the INSERT on step 3 will fail with
Parse error: no such table: main.B
. If we reorder 3 and 4, thendefer_foreign_keys
does what you'd expect and allows the rows in A to be added with no corresponding rows in B to reference (withoutdefer_foreign_keys
, step 3 would fail withFOREIGN KEY constraint failed
).This appears to be an unavoidable difference between SQLite's default behaviour of setting
PRAGMA foreign_keys=OFF;
at the beginning of DB exports, and D1/workerd usingpragma defer_foreign_keys=TRUE
. From the SQLite docs, these are "DML errors" and are separate to the section on immediate/deferred constraints:The easy solution would be to reorder the output of
d1 export
so that all tables are generated first, then allINSERT
statements. That should make all foreign key violations deferrable, as there's no way for a dump to contain a DML error. But that doesn't solve the related issues.2. Dropping tables in migrations cannot prevent ON DELETE CASCADE (workerd-issue prisma-issue)
This is also an issue with
pragma defer_foreign_keys=TRUE
not being a substitute forPRAGMA foreign_keys=OFF;
. Tools like prisma, aware of SQLite's limitations, generate migrations like the following to migrate between table schemas (from this reproduction, thank you @hrueger!):It's not clear to me how much of the above is D1-specific (the absence of a
BEGIN TRANSACTION
is surely specific to us, for example), but since D1 always runs queries within transactions, thePRAGMA foreign_keys=OFF;
statement has no effect (which is why we usedefer_foreign_keys
in the first place). The result is that D1 runs allON DELETE
clauses when you drop the old table, resulting in lost data.3. No support for D1 export for databases containing FTS5 full-text search tables (workers-sdk issue)
We only support one kind of virtual table, FTS5, for full text search. But, when porting SQLite's
.dump
command we found that it would generate SQL requiringwritable_schema
, a pragma we don't support.This input SQL:
Generates the following SQL using SQLite's native
.dump
command:We chose to disallow D1 exports containing virtual tables until we could find a resolution, as we didn't want to allow
writable_schema
(it requiresSQLITE_DBCONFIG_DEFENSIVE
to be disabled). Our best option was to skip the create table statements for the generated tables (documents_fts_data
,documents_fts_idx
,documents_fts_content
documents_fts_docsize
anddocuments_fts_config
) and instead of theINSERT INTO sqlite_schema
call, instead recreate theCREATE VIRTUAL TABLE
that created it. The result won't necessarily be identical, but should be functionally equivalent for normal use. But maybe we should explore alternatives.Proposal: SQL unsafe mode for D1 migrations
The lack of
PRAGMA foreign_keys=OFF
stems from the fact that all workerd queries execute within a transaction, which is implicitly created whenever a SQL query includes a write statement. If we had an API that let us execute a pragma without this logic, we could support importing from unmodified SQL dumps.I propose the following api,
sql.unsafeExec
, which instead of implicitly creating a transaction, requires the user to submit SQL with multiple statements:unsafeExec
enforces these constraints:.execUnsafe
block, a different authorizer is invoked, allowingBEGIN
/COMMIT
/ROLLBACK
, as well asSAVEPOINT
/RELEASE
This doesn't give us
writable_schema
without disablingSQLITE_DBCONFIG_DEFENSIVE
(which I don't know what the implications of that would be, so I'm not suggesting it here), but we could wire up D1 to callexecUnsafe
on migrations/import and allow tools like Prisma to manage the schema without needing so much special treatment for D1.This solves problem 1 and 2, and gives us a potential way to solve problem 3 in the future. Thoughts?
The text was updated successfully, but these errors were encountered: