Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Oracle connection errors no longer surface the driver's raw internal message; failures now explain the cause in plain language. (#483)
- AWS IAM authentication with a named profile now reads `~/.aws/config` (not just `~/.aws/credentials`) and supports `credential_process`, so profiles backed by SSO, IAM Identity Center, or assume-role work through `aws configure export-credentials`. (#1291)
- iOS: a connection's Safe Mode setting now survives relaunch. iCloud sync no longer drops the value, so a connection set to Confirm Writes or Read-Only no longer reverts to Off after reopening the app.
- iOS: Safe Mode "Confirm Writes" now prompts before saving a row edit or inserting a row, matching the query editor. Previously grid edits and inserts saved with no confirmation.

## [0.45.0] - 2026-05-26

Expand Down
12 changes: 12 additions & 0 deletions Packages/TableProCore/Sources/TableProModels/SafeModeLevel.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import Foundation

public enum WritePermission: Sendable {
case proceed
case requiresConfirmation
case blocked
}

public enum SafeModeLevel: String, Codable, Sendable, CaseIterable, Identifiable {
case off = "off"
case confirmWrites = "confirmWrites"
Expand All @@ -11,6 +17,12 @@ public enum SafeModeLevel: String, Codable, Sendable, CaseIterable, Identifiable

public var requiresConfirmation: Bool { self == .confirmWrites }

public var writePermission: WritePermission {
if blocksWrites { return .blocked }
if requiresConfirmation { return .requiresConfirmation }
return .proceed
}

public var displayName: String {
switch self {
case .off: return "Off"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Testing

@testable import TableProModels

@Suite("SafeModeLevel write permission")
struct SafeModeLevelTests {
@Test("off proceeds without confirmation")
func offProceeds() {
#expect(SafeModeLevel.off.writePermission == .proceed)
}

@Test("confirmWrites requires confirmation")
func confirmWritesRequiresConfirmation() {
#expect(SafeModeLevel.confirmWrites.writePermission == .requiresConfirmation)
}

@Test("readOnly blocks writes")
func readOnlyBlocks() {
#expect(SafeModeLevel.readOnly.writePermission == .blocked)
}
}
30 changes: 28 additions & 2 deletions TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ final class RowDetailViewModel {
private(set) var loadingCell: Int?
private(set) var fullValueOverrides: [Int: [Int: String?]] = [:]
private(set) var isSaving = false
private(set) var pendingWriteConfirmation = false
var operationError: AppError?
private(set) var showSaveSuccess = false

@ObservationIgnored private var pendingSaveSQL: String?

@ObservationIgnored let onSaved: (() -> Void)?
@ObservationIgnored let loadFullValueProvider: ((CellRef) async throws -> String?)?
@ObservationIgnored private var dismissSuccessTask: Task<Void, Never>?
Expand Down Expand Up @@ -140,8 +143,8 @@ final class RowDetailViewModel {
func saveChanges() async -> Bool {
guard let session, let table else { return false }

isSaving = true
defer { isSaving = false }
pendingWriteConfirmation = false
pendingSaveSQL = nil

let pkValues: [(column: String, value: String)] = columnDetails.compactMap { col in
guard col.isPrimaryKey else { return nil }
Expand Down Expand Up @@ -185,6 +188,29 @@ final class RowDetailViewModel {
primaryKeys: pkValues
)

switch safeModeLevel.writePermission {
case .blocked:
return false
case .requiresConfirmation:
pendingSaveSQL = sql
pendingWriteConfirmation = true
return false
case .proceed:
return await execute(sql: sql, session: session)
}
}

func executePendingSave() async -> Bool {
pendingWriteConfirmation = false
guard let session, let sql = pendingSaveSQL else { return false }
pendingSaveSQL = nil
return await execute(sql: sql, session: session)
}

private func execute(sql: String, session: ConnectionSession) async -> Bool {
isSaving = true
defer { isSaving = false }

do {
_ = try await session.driver.execute(query: sql)
guard currentIndex >= 0, currentIndex < rows.count else { return false }
Expand Down
1 change: 1 addition & 0 deletions TableProMobile/TableProMobile/Views/DataBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ struct DataBrowserView: View {
columnDetails: viewModel.columnDetails,
session: session,
databaseType: connection.type,
safeModeLevel: connection.safeModeLevel,
onInserted: { Task { await viewModel.load() } }
)
}
Expand Down
41 changes: 38 additions & 3 deletions TableProMobile/TableProMobile/Views/InsertRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct InsertRowView: View {
let columnDetails: [ColumnInfo]
let session: ConnectionSession?
let databaseType: DatabaseType
let safeModeLevel: SafeModeLevel
var onInserted: (() -> Void)?

@Environment(\.dismiss) private var dismiss
Expand All @@ -19,12 +20,14 @@ struct InsertRowView: View {
columnDetails: [ColumnInfo],
session: ConnectionSession?,
databaseType: DatabaseType,
safeModeLevel: SafeModeLevel = .off,
onInserted: (() -> Void)? = nil
) {
self.table = table
self.columnDetails = columnDetails
self.session = session
self.databaseType = databaseType
self.safeModeLevel = safeModeLevel
self.onInserted = onInserted
_values = State(initialValue: Array(repeating: "", count: columnDetails.count))
_isNullFlags = State(initialValue: columnDetails.map { col in
Expand All @@ -34,6 +37,8 @@ struct InsertRowView: View {
@State private var isSaving = false
@State private var operationError: AppError?
@State private var showOperationError = false
@State private var showInsertConfirmation = false
@State private var pendingInsertSQL: String?
@State private var hapticSuccess = false
@State private var hapticError = false

Expand Down Expand Up @@ -136,6 +141,14 @@ struct InsertRowView: View {
Text(operationError?.message ?? "")
}
}
.alert("Insert Row?", isPresented: $showInsertConfirmation) {
Button(String(localized: "Insert"), role: .destructive) {
Task { await executePendingInsert() }
}
Button(String(localized: "Cancel"), role: .cancel) {}
} message: {
Text(String(format: String(localized: "This will insert a row into %@. Continue?"), table.name))
}
}
}

Expand Down Expand Up @@ -172,9 +185,26 @@ struct InsertRowView: View {
private func insertRow() async {
guard let session else { return }

isSaving = true
defer { isSaving = false }
let sql = buildInsertSQL()

switch safeModeLevel.writePermission {
case .blocked:
return
case .requiresConfirmation:
pendingInsertSQL = sql
showInsertConfirmation = true
case .proceed:
await executeInsert(sql: sql, session: session)
}
}

private func executePendingInsert() async {
guard let session, let sql = pendingInsertSQL else { return }
pendingInsertSQL = nil
await executeInsert(sql: sql, session: session)
}

private func buildInsertSQL() -> String {
var insertColumns: [String] = []
var insertValues: [String?] = []

Expand All @@ -194,12 +224,17 @@ struct InsertRowView: View {
}
}

let sql = SQLBuilder.buildInsert(
return SQLBuilder.buildInsert(
table: table.name,
type: databaseType,
columns: insertColumns,
values: insertValues
)
}

private func executeInsert(sql: String, session: ConnectionSession) async {
isSaving = true
defer { isSaving = false }

do {
_ = try await session.driver.execute(query: sql)
Expand Down
8 changes: 5 additions & 3 deletions TableProMobile/TableProMobile/Views/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -376,14 +376,16 @@ struct QueryEditorView: View {
guard !trimmed.isEmpty else { return }

if isWriteQuery(trimmed) {
if safeModeLevel.blocksWrites {
switch safeModeLevel.writePermission {
case .blocked:
showWriteBlockedAlert = true
return
}
if safeModeLevel.requiresConfirmation {
case .requiresConfirmation:
pendingWriteQuery = trimmed
showWriteConfirmation = true
return
case .proceed:
break
}
}

Expand Down
22 changes: 22 additions & 0 deletions TableProMobile/TableProMobile/Views/RowDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct RowDetailView: View {
@State private var hapticSuccess = false
@State private var hapticError = false
@State private var hapticSelection = 0
@State private var showSaveConfirmation = false

init(
columns: [ColumnInfo],
Expand Down Expand Up @@ -89,6 +90,14 @@ struct RowDetailView: View {
Text(viewModel.operationError?.message ?? "")
}
}
.alert("Save Changes?", isPresented: $showSaveConfirmation) {
Button(String(localized: "Save"), role: .destructive) {
Task { await executePendingSave() }
}
Button(String(localized: "Cancel"), role: .cancel) {}
} message: {
Text(String(format: String(localized: "This will update a row in %@. Continue?"), viewModel.table?.name ?? ""))
}
.sheet(item: $fkPreviewItem) { item in
FKPreviewView(
fk: item.fk,
Expand Down Expand Up @@ -346,6 +355,19 @@ struct RowDetailView: View {

private func handleSave() async {
let success = await viewModel.saveChanges()
if viewModel.pendingWriteConfirmation {
showSaveConfirmation = true
return
}
if success {
hapticSuccess.toggle()
} else {
hapticError.toggle()
}
}

private func executePendingSave() async {
let success = await viewModel.executePendingSave()
if success {
hapticSuccess.toggle()
} else {
Expand Down
57 changes: 57 additions & 0 deletions TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,63 @@ struct RowDetailViewModelTests {
#expect(query.contains("WHERE"))
}

@Test("saveChanges under confirmWrites defers execution and requests confirmation")
func saveConfirmWritesDefers() async {
let driver = MockDatabaseDriver()
let vm = RowDetailViewModel(
columns: makeColumns(), rows: makeRows(), initialIndex: 0,
table: TableInfo(name: "users"), session: makeSession(driver: driver),
columnDetails: makeColumns(), safeModeLevel: .confirmWrites
)
vm.startEditing()
vm.setEditedValue("Charlie", at: 1)

let success = await vm.saveChanges()
#expect(success == false)
#expect(vm.pendingWriteConfirmation == true)
#expect(vm.isEditing == true, "stays in edit mode until confirmed")
#expect(driver.executedQueries.isEmpty, "no UPDATE runs before confirmation")
}

@Test("executePendingSave runs the deferred UPDATE after confirmation")
func executePendingSaveRunsUpdate() async {
let driver = MockDatabaseDriver()
driver.scriptedExecuteResults = [
.success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0))
]
let vm = RowDetailViewModel(
columns: makeColumns(), rows: makeRows(), initialIndex: 0,
table: TableInfo(name: "users"), session: makeSession(driver: driver),
columnDetails: makeColumns(), safeModeLevel: .confirmWrites
)
vm.startEditing()
vm.setEditedValue("Charlie", at: 1)
_ = await vm.saveChanges()

let success = await vm.executePendingSave()
#expect(success == true)
#expect(vm.pendingWriteConfirmation == false)
#expect(driver.executedQueries.count == 1)
#expect(driver.executedQueries[0].uppercased().hasPrefix("UPDATE"))
}

@Test("saveChanges under readOnly never executes")
func saveReadOnlyBlocks() async {
let driver = MockDatabaseDriver()
let vm = RowDetailViewModel(
columns: makeColumns(), rows: makeRows(), initialIndex: 0,
table: TableInfo(name: "users"), session: makeSession(driver: driver),
columnDetails: makeColumns(), safeModeLevel: .readOnly
)
vm.startEditing()
vm.setEditedValue("Charlie", at: 1)

let success = await vm.saveChanges()
#expect(success == false)
#expect(vm.pendingWriteConfirmation == false)
#expect(driver.executedQueries.isEmpty)
}

@Test("saveChanges fails when no primary key value present")
func saveWithoutPrimaryKey() async {
let driver = MockDatabaseDriver()
Expand Down
Loading