Skip to content

Commit 4c730d6

Browse files
committed
feat: D1 batch execution, DDL generation, skip transactions for unsupported drivers (#575)
1 parent 150ee97 commit 4c730d6

7 files changed

Lines changed: 167 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Cloudflare D1: batch query execution via REST API for multi-statement SQL
13+
- Cloudflare D1: schema editing — CREATE TABLE, ADD/DROP COLUMN, CREATE/DROP INDEX
14+
15+
### Fixed
16+
17+
- Multi-statement SQL execution fails on Cloudflare D1, ClickHouse, and other drivers that don't support transactions
18+
1019
### Changed
1120

1221
- Use Apple-standard `xcodebuild archive` + `exportArchive` build pipeline with dSYM collection

Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin {
2525
static let supportsSSL = false
2626
static let isDownloadable = true
2727
static let supportsImport = false
28-
static let supportsSchemaEditing = false
28+
static let supportsSchemaEditing = true
2929
static let databaseGroupingStrategy: GroupingStrategy = .flat
3030
static let brandColorHex = "#F6821F"
3131
static let urlSchemes: [String] = ["d1"]

Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,21 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
145145
return mapRawResult(payload, executionTime: executionTime)
146146
}
147147

148+
func executeBatch(queries: [String]) async throws -> [PluginQueryResult] {
149+
guard let client = getClient() else {
150+
throw CloudflareD1Error.notConnected
151+
}
152+
153+
let startTime = Date()
154+
let statements = queries.map { (sql: $0, params: nil as [Any?]?) }
155+
let payloads = try await client.executeBatchRaw(statements: statements)
156+
let elapsed = Date().timeIntervalSince(startTime)
157+
158+
return payloads.enumerated().map { _, payload in
159+
mapRawResult(payload, executionTime: payload.meta?.duration ?? (elapsed / Double(payloads.count)))
160+
}
161+
}
162+
148163
func cancelQuery() throws {
149164
lock.lock()
150165
httpClient?.cancelCurrentTask()
@@ -577,8 +592,111 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
577592
throw CloudflareD1Error(message: String(localized: "Transactions are not supported by Cloudflare D1"))
578593
}
579594

595+
// MARK: - DDL Generation
596+
597+
func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? {
598+
guard !definition.columns.isEmpty else { return nil }
599+
600+
let tableName = quoteIdentifier(definition.tableName)
601+
let pkColumns = definition.columns.filter { $0.isPrimaryKey }
602+
let inlinePK = pkColumns.count == 1
603+
var parts: [String] = definition.columns.map { d1ColumnDefinition($0, inlinePK: inlinePK) }
604+
605+
if pkColumns.count > 1 {
606+
let pkCols = pkColumns.map { quoteIdentifier($0.name) }.joined(separator: ", ")
607+
parts.append("PRIMARY KEY (\(pkCols))")
608+
}
609+
610+
for fk in definition.foreignKeys {
611+
parts.append(d1ForeignKeyDefinition(fk))
612+
}
613+
614+
let sql = "CREATE TABLE \(tableName) (\n " +
615+
parts.joined(separator: ",\n ") +
616+
"\n);"
617+
618+
return sql
619+
}
620+
621+
func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? {
622+
var def = "\(quoteIdentifier(column.name)) \(column.dataType)"
623+
if !column.isNullable { def += " NOT NULL" }
624+
if let defaultValue = column.defaultValue, !defaultValue.isEmpty {
625+
def += " DEFAULT \(d1DefaultValue(defaultValue))"
626+
}
627+
return "ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(def)"
628+
}
629+
630+
func generateDropColumnSQL(table: String, columnName: String) -> String? {
631+
"ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))"
632+
}
633+
634+
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
635+
let uniqueStr = index.isUnique ? "UNIQUE " : ""
636+
let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ")
637+
return "CREATE \(uniqueStr)INDEX \(quoteIdentifier(index.name)) ON \(quoteIdentifier(table)) (\(cols))"
638+
}
639+
640+
func generateDropIndexSQL(table: String, indexName: String) -> String? {
641+
"DROP INDEX IF EXISTS \(quoteIdentifier(indexName))"
642+
}
643+
644+
func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? {
645+
d1ColumnDefinition(column, inlinePK: column.isPrimaryKey)
646+
}
647+
648+
func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? {
649+
let uniqueStr = index.isUnique ? "UNIQUE " : ""
650+
let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ")
651+
let onClause = tableName.map { " ON \(quoteIdentifier($0))" } ?? ""
652+
return "CREATE \(uniqueStr)INDEX \(quoteIdentifier(index.name))\(onClause) (\(cols))"
653+
}
654+
655+
func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? {
656+
d1ForeignKeyDefinition(fk)
657+
}
658+
580659
// MARK: - Private Helpers
581660

661+
private func d1ColumnDefinition(_ col: PluginColumnDefinition, inlinePK: Bool) -> String {
662+
var def = "\(quoteIdentifier(col.name)) \(col.dataType)"
663+
if inlinePK && col.isPrimaryKey {
664+
def += " PRIMARY KEY"
665+
if col.autoIncrement {
666+
def += " AUTOINCREMENT"
667+
}
668+
}
669+
if !col.isNullable {
670+
def += " NOT NULL"
671+
}
672+
if let defaultValue = col.defaultValue {
673+
def += " DEFAULT \(d1DefaultValue(defaultValue))"
674+
}
675+
return def
676+
}
677+
678+
private func d1DefaultValue(_ value: String) -> String {
679+
let upper = value.uppercased()
680+
if upper == "NULL" || upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_DATE" || upper == "CURRENT_TIME"
681+
|| value.hasPrefix("'") || Int64(value) != nil || Double(value) != nil {
682+
return value
683+
}
684+
return "'\(escapeStringLiteral(value))'"
685+
}
686+
687+
private func d1ForeignKeyDefinition(_ fk: PluginForeignKeyDefinition) -> String {
688+
let cols = fk.columns.map { quoteIdentifier($0) }.joined(separator: ", ")
689+
let refCols = fk.referencedColumns.map { quoteIdentifier($0) }.joined(separator: ", ")
690+
var def = "FOREIGN KEY (\(cols)) REFERENCES \(quoteIdentifier(fk.referencedTable)) (\(refCols))"
691+
if fk.onDelete != "NO ACTION" {
692+
def += " ON DELETE \(fk.onDelete)"
693+
}
694+
if fk.onUpdate != "NO ACTION" {
695+
def += " ON UPDATE \(fk.onUpdate)"
696+
}
697+
return def
698+
}
699+
582700
private func getClient() -> D1HttpClient? {
583701
lock.lock()
584702
defer { lock.unlock() }

Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,24 @@ final class D1HttpClient: @unchecked Sendable {
200200
return first
201201
}
202202

203+
func executeBatchRaw(statements: [(sql: String, params: [Any?]?)]) async throws -> [D1RawResultPayload] {
204+
let dbId = databaseId
205+
let batch = statements.map { stmt -> [String: Any] in
206+
var entry: [String: Any] = ["sql": stmt.sql]
207+
if let params = stmt.params {
208+
entry["params"] = params.map { $0 ?? NSNull() }
209+
}
210+
return entry
211+
}
212+
let body = try JSONSerialization.data(withJSONObject: ["batch": batch])
213+
214+
let url = try baseURL(databaseId: dbId).appendingPathComponent("raw")
215+
let data = try await performRequest(url: url, method: "POST", body: body)
216+
let envelope = try JSONDecoder().decode(D1ApiResponse<[D1RawResultPayload]>.self, from: data)
217+
try checkApiSuccess(envelope)
218+
return envelope.result ?? []
219+
}
220+
203221
func getDatabaseDetails() async throws -> D1DatabaseInfo {
204222
let dbId = databaseId
205223
let url = try baseURL(databaseId: dbId)

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ protocol DatabaseDriver: AnyObject {
129129

130130
// MARK: - Transaction Management
131131

132+
/// Whether this driver supports transactions (e.g., Cloudflare D1, ClickHouse do not)
133+
var supportsTransactions: Bool { get }
134+
132135
/// Begin a transaction
133136
func beginTransaction() async throws
134137

@@ -308,6 +311,8 @@ extension DatabaseDriver {
308311
/// Default: no schema support (MySQL/SQLite don't use schemas in the same way)
309312
func fetchSchemas() async throws -> [String] { [] }
310313

314+
var supportsTransactions: Bool { true }
315+
311316
/// Default no-op implementation for drivers that don't support query cancellation
312317
func cancelQuery() throws {
313318
// No-op by default

TablePro/Core/Plugins/PluginDriverAdapter.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
279279

280280
// MARK: - Transaction Management
281281

282+
var supportsTransactions: Bool {
283+
pluginDriver.supportsTransactions
284+
}
285+
282286
func beginTransaction() async throws {
283287
try await pluginDriver.beginTransaction()
284288
}

TablePro/Views/Main/Extensions/MainContentCoordinator+MultiStatement.swift

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,17 @@ extension MainContentCoordinator {
4848
throw DatabaseError.notConnected
4949
}
5050

51-
// Wrap in a transaction for atomicity
52-
try await driver.beginTransaction()
51+
let useTransaction = driver.supportsTransactions
52+
53+
if useTransaction {
54+
try await driver.beginTransaction()
55+
}
5356

5457
/// Rollback transaction and reset executing state for early exits.
5558
@MainActor func rollbackAndResetState() async {
56-
try? await driver.rollbackTransaction()
59+
if useTransaction {
60+
try? await driver.rollbackTransaction()
61+
}
5762
if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) {
5863
tabManager.tabs[idx].isExecuting = false
5964
}
@@ -115,8 +120,9 @@ extension MainContentCoordinator {
115120
}
116121
}
117122

118-
// Commit the transaction
119-
try await driver.commitTransaction()
123+
if useTransaction {
124+
try await driver.commitTransaction()
125+
}
120126

121127
// All statements succeeded — update tab with results
122128
await MainActor.run {
@@ -131,8 +137,7 @@ extension MainContentCoordinator {
131137
)
132138
}
133139
} catch {
134-
// Rollback on failure
135-
if let driver = DatabaseManager.shared.driver(for: conn.id) {
140+
if let driver = DatabaseManager.shared.driver(for: conn.id), driver.supportsTransactions {
136141
try? await driver.rollbackTransaction()
137142
}
138143

0 commit comments

Comments
 (0)