Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c8ee8e7
fix(copy): turn off foreign key checks while Copy To writes rows
MrWeb Oct 27, 2026
0899278
chore(changelog): name the fragment after the PR
MrWeb Oct 27, 2026
ccde7e3
Merge remote-tracking branch 'origin/main' into fix/copy-to-foreign-keys
datlechin Oct 27, 2026
4bd09fa
fix(copy): write rows with foreign key checks off and put back what t…
datlechin Oct 27, 2026
ef8e76b
fix(copy): keep foreign key checks on for clears and skip, and match …
datlechin Oct 27, 2026
0282e5b
fix(copy): restore foreign keys when a SQLite relax fails and roll ba…
datlechin Oct 27, 2026
5542ce2
fix(copy): relax keys only for a whole copy in one transaction, and k…
datlechin Oct 27, 2026
8761437
fix(copy): keep checks on for tables the target keeps, and count what…
datlechin Oct 27, 2026
b31f221
fix(copy): check every key before a relaxed copy commits, lease the t…
datlechin Oct 27, 2026
8f00641
Merge remote-tracking branch 'origin/main' into fix/copy-to-foreign-keys
datlechin Oct 27, 2026
ecc5395
fix(copy): report the rows of a table the target keeps through a roll…
datlechin Oct 27, 2026
d2027a3
fix(copy): keep the checks on when the target account cannot run the …
datlechin Oct 27, 2026
c536639
fix(postgresql): put a setting saved inside a transaction back for th…
datlechin Oct 27, 2026
5fcab4b
revert(postgresql): restore saved settings session-wide, as the sessi…
datlechin Oct 27, 2026
03b4ac9
fix(datawrite): save no session settings inside the user's transaction
datlechin Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import TableProPluginKit
import Testing

private struct ReadFailure: Error {}

// The connection's foreign_keys as each statement leaves it, and the read-back that follows the assignment.
private final class ScriptedConnection: @unchecked Sendable {
private let lock = NSLock()
private var value = "1"
private var reads = 0
private let secondRead: Result<String, ReadFailure>
private var log: [String] = []

init(secondRead: Result<String, ReadFailure>) {
self.secondRead = secondRead
}

var foreignKeys: String { lock.withLock { value } }
var statements: [String] { lock.withLock { log } }

func execute(_ query: String) throws -> PluginQueryResult {
try lock.withLock {
log.append(query)
switch query {
case "PRAGMA foreign_keys = ON": value = "1"
case "PRAGMA foreign_keys = OFF": value = "0"
default:
reads += 1
let answer = reads == 2 ? try secondRead.get() : value
return PluginQueryResult(
columns: ["foreign_keys"], columnTypeNames: [], rows: [[.text(answer)]], rowsAffected: 0, executionTime: 0
)
}
return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
}
}
}

struct SQLiteSessionScopeTests {
@Test("A relax whose read-back fails turns foreign keys back on before it throws")
func failedReadBackRestores() async {
let connection = ScriptedConnection(secondRead: .failure(ReadFailure()))

await #expect(throws: ReadFailure.self) {
_ = try await SQLiteSessionScope.open(for: .relaxedForeignKeys) { try connection.execute($0) }
}

#expect(connection.foreignKeys == "1")
#expect(connection.statements.last == "PRAGMA foreign_keys = ON")
}

@Test("A relax whose read-back is unreadable turns foreign keys back on and opens no scope")
func unreadableReadBackRestores() async throws {
let connection = ScriptedConnection(secondRead: .success("unknown"))

let scope = try await SQLiteSessionScope.open(for: .relaxedForeignKeys) { try connection.execute($0) }

#expect(scope == nil)
#expect(connection.foreignKeys == "1")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsSSL = false
static let isDownloadable = true
static let supportsImport = false
// D1 runs each request in a transaction of its own, where user queries cannot change foreign key enforcement.
static let supportsForeignKeyDisable = false
static let supportsSchemaEditing = true
static let supportsTriggers = true
static let supportsDatabaseTriggerBrowse = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
[
.parameterizedQueries,
.alterTableDDL,
.foreignKeyToggle,
.truncateTable,
.cancelQuery,
.schemaCompare,
Expand Down Expand Up @@ -545,16 +544,6 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
return "DROP VIEW IF EXISTS \(quoted);\nCREATE VIEW \(quoted) AS\nSELECT * FROM table_name;"
}

// MARK: - Foreign Key Checks

func foreignKeyDisableStatements() -> [String]? {
["PRAGMA foreign_keys = OFF"]
}

func foreignKeyEnableStatements() -> [String]? {
["PRAGMA foreign_keys = ON"]
}

// MARK: - Table Operations

func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? {
Expand Down
19 changes: 16 additions & 3 deletions apple/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
var base: PluginCapabilities = [
.parameterizedQueries,
.alterTableDDL,
.foreignKeyToggle,
.truncateTable,
.cancelQuery,
.schemaCompare,
Expand All @@ -54,6 +53,7 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
if isLocalMode {
base.insert(.transactions)
base.insert(.batchExecute)
base.insert(.foreignKeyToggle)
}
return base
}
Expand Down Expand Up @@ -581,12 +581,25 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

// MARK: - Foreign Key Checks

// Remote runs every statement on a stream of its own, so a pragma is gone before the next statement runs.
func foreignKeyDisableStatements() -> [String]? {
["PRAGMA foreign_keys = OFF"]
isLocalMode ? ["PRAGMA foreign_keys = OFF"] : nil
}

func foreignKeyEnableStatements() -> [String]? {
["PRAGMA foreign_keys = ON"]
isLocalMode ? ["PRAGMA foreign_keys = ON"] : nil
}

// MARK: - Session Scope

func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? {
guard isLocalMode else { return nil }
return try await SQLiteSessionScope.open(for: purpose) { try await self.execute(query: $0) }
}

func closeSessionScope(_ scope: PluginSessionScope) async throws {
guard isLocalMode else { return }
try await SQLiteSessionScope.close(scope) { try await self.execute(query: $0) }
}

// MARK: - Table Operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import TableProMySQLCore
import TableProPluginKit

internal extension MySQLPluginDriver {
/// Databend has no `sql_mode` to hold, and a server before 5.0.3 cannot filter `SHOW` with `WHERE`.
/// Databend has no `sql_mode` or foreign key checks to hold, and a server before 5.0.3 cannot filter `SHOW` with
/// `WHERE`.
func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? {
let names = MySQLSessionScopePlan.savedNames(for: purpose)
guard !flavor.isDatabend,
Expand Down
4 changes: 4 additions & 0 deletions apple/Plugins/MySQLDriverPlugin/MySQLSessionScopePlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ internal enum MySQLSessionScopePlan {
return preservedNames
case .verbatimRead, .verbatimWrite:
return verbatimNames
case .relaxedForeignKeys:
return ["foreign_key_checks"]
@unknown default:
return []
}
Expand All @@ -57,6 +59,8 @@ internal enum MySQLSessionScopePlan {
PluginSessionSetting(name: "sql_mode", value: writeMode(saved: saved)),
PluginSessionSetting(name: "time_zone", value: "+00:00"),
]
case .relaxedForeignKeys:
return [PluginSessionSetting(name: "foreign_key_checks", value: "OFF")]
@unknown default:
return nil
}
Expand Down
18 changes: 17 additions & 1 deletion apple/Plugins/MySQLDriverTests/MySQLSessionScopeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import TableProMySQLCore
import TableProPluginKit
import Testing

Expand Down Expand Up @@ -105,6 +106,21 @@ struct MySQLSessionScopeTests {
#expect(MySQLSessionScopePlan.writeMode(saved: saved) == writeMode)
}

/// `SET SESSION foreign_key_checks = 'OFF'` and `'ON'` measured on MySQL 8.4.13, inside a transaction too.
@Test("Relaxing foreign keys saves foreign_key_checks alone, turns it off, and closing turns it back on")
func relaxedForeignKeysPlan() {
let saved = [PluginSessionSetting(name: "foreign_key_checks", value: "ON")]
#expect(MySQLSessionScopePlan.savedNames(for: .relaxedForeignKeys) == ["foreign_key_checks"])
let applied = MySQLSessionScopePlan.applied(for: .relaxedForeignKeys, saved: saved)
#expect(applied == [PluginSessionSetting(name: "foreign_key_checks", value: "OFF")])
#expect(applied?.compactMap { MySQLSessionStatements.assignment(name: $0.name, value: $0.value) } == [
"SET SESSION foreign_key_checks = 'OFF'",
])
let restore = MySQLSessionScopePlan.restore(saved: saved, current: applied ?? [], isTransactionOpen: true)
#expect(restore.statements == ["SET SESSION foreign_key_checks = 'ON'"])
#expect(restore.isComplete)
}

@Test("A write scope with no saved mode adds only NO_AUTO_VALUE_ON_ZERO, and reading ignores the session's mode")
func writeModeWithoutSavedMode() {
#expect(MySQLSessionScopePlan.writeMode(saved: []) == "NO_AUTO_VALUE_ON_ZERO")
Expand Down Expand Up @@ -216,7 +232,7 @@ struct MySQLSessionScopeTests {
host: "", port: 0, username: "", password: "", database: "",
additionalFields: ["driverVariant": MySQLServerFlavor.databendVariant]
))
for purpose in [PluginSessionPurpose.preserve, .verbatimRead, .verbatimWrite] {
for purpose in [PluginSessionPurpose.preserve, .verbatimRead, .verbatimWrite, .relaxedForeignKeys] {
#expect(try await driver.openSessionScope(for: purpose) == nil)
}
let routine = PluginRoutineInfo(name: "cleanup", kind: .procedure)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ internal extension PostgreSQLPluginDriver {
case .verbatimWrite:
// What the read pins reads back the same under any settings; nil would make the host drop the read too.
return PluginSessionScope(purpose: .verbatimWrite, saved: [], applied: [])
case .relaxedForeignKeys:
// `session_replication_role = replica` would also stop the target's user triggers, and needs a superuser.
return nil
@unknown default:
return nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,17 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
// MARK: - Foreign Keys

func foreignKeyDisableStatements() -> [String]? {
["SET session_replication_role = replica"]
[Self.settingReplicationRole("replica")]
}

func foreignKeyEnableStatements() -> [String]? {
["SET session_replication_role = DEFAULT"]
[Self.settingReplicationRole("DEFAULT")]
}

/// Setting it takes a superuser or a `SET` grant, and a bare refusal aborts the transaction it runs in. The block
/// swallows the refusal and keeps the transaction, and an allowed `SET` outlives it (measured on 17.11 and 18.6).
private static func settingReplicationRole(_ value: String) -> String {
"DO $$ BEGIN SET session_replication_role = \(value); EXCEPTION WHEN insufficient_privilege THEN NULL; END $$"
}

/// A duplicated database arrives with `public` alone, so every other schema its tables are
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,26 @@ struct PostgreSQLSessionScopeSQLTests {
func readScopeRefusesOtherwise(state: PluginSessionTransactionState) {
#expect(!PostgreSQLSessionScopeSQL.canOpenReadScope(in: state))
}

private static func unconnectedDriver() -> PostgreSQLPluginDriver {
PostgreSQLPluginDriver(config: DriverConnectionConfig(host: "", port: 0, username: "", password: "", database: ""))
}

/// Measured on PostgreSQL 17.11 and 18.6: for a role without the privilege the block answers DO and the
/// transaction keeps its rows, where the bare `SET` aborts it.
@Test("The foreign key switch swallows a refused SET, both ways")
func foreignKeySwitchSwallowsRefusal() {
let driver = Self.unconnectedDriver()
#expect(driver.foreignKeyDisableStatements() == [
"DO $$ BEGIN SET session_replication_role = replica; EXCEPTION WHEN insufficient_privilege THEN NULL; END $$",
])
#expect(driver.foreignKeyEnableStatements() == [
"DO $$ BEGIN SET session_replication_role = DEFAULT; EXCEPTION WHEN insufficient_privilege THEN NULL; END $$",
])
}

@Test("Relaxing foreign keys opens no scope, since replica would also stop the target's user triggers")
func relaxedForeignKeysOpensNoScope() async throws {
#expect(try await Self.unconnectedDriver().openSessionScope(for: .relaxedForeignKeys) == nil)
}
}
10 changes: 10 additions & 0 deletions apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,16 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
["PRAGMA foreign_keys = ON"]
}

// MARK: - Session Scope

func openSessionScope(for purpose: PluginSessionPurpose) async throws -> PluginSessionScope? {
try await SQLiteSessionScope.open(for: purpose) { try await self.execute(query: $0) }
}

func closeSessionScope(_ scope: PluginSessionScope) async throws {
try await SQLiteSessionScope.close(scope) { try await self.execute(query: $0) }
}

// MARK: - User Query

func executeBoundedQuery(query: String, rowCap: Int) async throws -> PluginQueryResult? {
Expand Down
96 changes: 96 additions & 0 deletions apple/Plugins/SQLiteDriverTests/SQLiteSessionScopeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// SQLiteSessionScopeTests.swift
// SQLiteDriverTests
//

import Foundation
import TableProPluginKit
import Testing

struct SQLiteSessionScopeTests {
private func withDriver(_ body: (SQLitePluginDriver) async throws -> Void) async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("sqlite-scope-\(UUID().uuidString).sqlite")
defer { try? FileManager.default.removeItem(at: url) }
let driver = SQLitePluginDriver(config: DriverConnectionConfig(
host: "", port: 0, username: "", password: "", database: url.path
))
try await driver.connect()
defer { driver.disconnect() }
try await body(driver)
}

private func foreignKeys(_ driver: SQLitePluginDriver) async throws -> String? {
try await driver.execute(query: "PRAGMA foreign_keys").rows.first?.first?.asText
}

@Test("Closing a preserve scope turns foreign keys back off after the statements inside turned them on")
func preserveRestoresForeignKeys() async throws {
try await withDriver { driver in
#expect(try await foreignKeys(driver) == "0")
let scope = try #require(try await driver.openSessionScope(for: .preserve))
#expect(scope.applied.isEmpty)
_ = try await driver.execute(query: "PRAGMA foreign_keys = ON")
try await driver.closeSessionScope(scope)
#expect(try await foreignKeys(driver) == "0")
}
}

@Test("A preserve scope that cannot put foreign keys back inside a transaction says so")
func preserveCloseInsideTransactionThrows() async throws {
try await withDriver { driver in
let scope = try #require(try await driver.openSessionScope(for: .preserve))
_ = try await driver.execute(query: "PRAGMA foreign_keys = ON")
_ = try await driver.execute(query: "BEGIN")
await #expect(throws: (any Error).self) { try await driver.closeSessionScope(scope) }
_ = try await driver.execute(query: "ROLLBACK")
try await driver.closeSessionScope(scope)
#expect(try await foreignKeys(driver) == "0")
}
}

@Test("Relaxing turns foreign keys off for the writes and closing turns them back on")
func relaxTurnsForeignKeysOffAndBack() async throws {
try await withDriver { driver in
_ = try await driver.execute(query: "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
_ = try await driver.execute(query: "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent (id))")
_ = try await driver.execute(query: "PRAGMA foreign_keys = ON")
let scope = try #require(try await driver.openSessionScope(for: .relaxedForeignKeys))
#expect(scope.applied == [PluginSessionSetting(name: "foreign_keys", value: "0")])
#expect(try await foreignKeys(driver) == "0")
_ = try await driver.execute(query: "INSERT INTO child VALUES (1, 7)")
try await driver.closeSessionScope(scope)
#expect(try await foreignKeys(driver) == "1")
}
}

/// sqlite.org/pragma.html#pragma_foreign_keys: the pragma is a no-op while a transaction is open.
@Test("Relaxing inside a transaction opens no scope and leaves foreign keys on")
func relaxInsideTransactionIsNil() async throws {
try await withDriver { driver in
_ = try await driver.execute(query: "PRAGMA foreign_keys = ON")
_ = try await driver.execute(query: "BEGIN")
#expect(try await driver.openSessionScope(for: .relaxedForeignKeys) == nil)
#expect(try await foreignKeys(driver) == "1")
_ = try await driver.execute(query: "ROLLBACK")
}
}

@Test("Relaxing a connection whose foreign keys are already off opens no scope")
func relaxWhenAlreadyOffIsNil() async throws {
try await withDriver { driver in
let scope = try await driver.openSessionScope(for: .relaxedForeignKeys)
let value = try await foreignKeys(driver)
#expect(scope == nil)
#expect(value == "0")
}
}

@Test("The verbatim purposes open no scope", arguments: [PluginSessionPurpose.verbatimRead, .verbatimWrite])
func verbatimPurposesAreNil(_ purpose: PluginSessionPurpose) async throws {
try await withDriver { driver in
let scope = try await driver.openSessionScope(for: purpose)
#expect(scope == nil)
}
}
}
2 changes: 2 additions & 0 deletions apple/Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,9 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
// Table operations (optional — return nil to use app-level fallback)
func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]?
func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String?
/// Turns the session's foreign key checks off. Runs outside a transaction, on the connection that writes.
func foreignKeyDisableStatements() -> [String]?
/// Turns them on whatever they were before; a caller that needs the previous value back holds a session scope.
func foreignKeyEnableStatements() -> [String]?

/// `UPDATE <table> SET <assignments> WHERE <predicate>` in this engine's own form, or nil for that form. The host
Expand Down
4 changes: 4 additions & 0 deletions apple/Plugins/TableProPluginKit/PluginSessionScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ public enum PluginSessionPurpose: Sendable, Equatable {
/// Rows read under `verbatimRead` are written back: the target side of a copy. A driver pins the same
/// representation for both purposes, so a value read under one is written under the other unchanged.
case verbatimWrite

/// Rows arrive in an order the target's foreign keys may refuse. The driver turns off the checks it can for this
/// session, changing nothing else the writes do, and closing puts back what it saved. Nil when it cannot.
case relaxedForeignKeys
}

/// One session setting, named and spelled the way its driver reads and writes it. A nil value is SQL `NULL`.
Expand Down
Loading
Loading