From 122c3b0c43021c6d0279bd1de72863619d02a7f0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 27 May 2026 19:16:48 +0700 Subject: [PATCH 1/2] fix(ios): confirm Safe Mode writes before data-grid row saves and inserts --- CHANGELOG.md | 1 + .../TableProModels/SafeModeLevel.swift | 12 ++++++ .../SafeModeLevelTests.swift | 21 ++++++++++ .../ViewModels/RowDetailViewModel.swift | 27 ++++++++++++- .../Views/DataBrowserView.swift | 1 + .../TableProMobile/Views/InsertRowView.swift | 39 ++++++++++++++++-- .../Views/QueryEditorView.swift | 8 ++-- .../TableProMobile/Views/RowDetailView.swift | 22 ++++++++++ .../RowDetailViewModelTests.swift | 40 +++++++++++++++++++ 9 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 Packages/TableProCore/Tests/TableProModelsTests/SafeModeLevelTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1628910..effac650b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Packages/TableProCore/Sources/TableProModels/SafeModeLevel.swift b/Packages/TableProCore/Sources/TableProModels/SafeModeLevel.swift index 2abd8b661..60cd3c9ef 100644 --- a/Packages/TableProCore/Sources/TableProModels/SafeModeLevel.swift +++ b/Packages/TableProCore/Sources/TableProModels/SafeModeLevel.swift @@ -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" @@ -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" diff --git a/Packages/TableProCore/Tests/TableProModelsTests/SafeModeLevelTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/SafeModeLevelTests.swift new file mode 100644 index 000000000..d893dbe69 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProModelsTests/SafeModeLevelTests.swift @@ -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) + } +} diff --git a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index 3dfe1a2ed..624e0bfb4 100644 --- a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -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? @@ -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 } @@ -185,6 +188,26 @@ final class RowDetailViewModel { primaryKeys: pkValues ) + if safeModeLevel.writePermission == .requiresConfirmation { + pendingSaveSQL = sql + pendingWriteConfirmation = true + return false + } + + 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 } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index e25a039ed..31cd24b77 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -448,6 +448,7 @@ struct DataBrowserView: View { columnDetails: viewModel.columnDetails, session: session, databaseType: connection.type, + safeModeLevel: connection.safeModeLevel, onInserted: { Task { await viewModel.load() } } ) } diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 8582879f3..f2fd91aad 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -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 @@ -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 @@ -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 @@ -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)) + } } } @@ -172,9 +185,24 @@ struct InsertRowView: View { private func insertRow() async { guard let session else { return } - isSaving = true - defer { isSaving = false } + let sql = buildInsertSQL() + + if safeModeLevel.writePermission == .requiresConfirmation { + pendingInsertSQL = sql + showInsertConfirmation = true + return + } + 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?] = [] @@ -194,12 +222,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) diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 690d2d128..95c3accba 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -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 } } diff --git a/TableProMobile/TableProMobile/Views/RowDetailView.swift b/TableProMobile/TableProMobile/Views/RowDetailView.swift index 4bf146021..742cf7826 100644 --- a/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -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], @@ -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, @@ -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 { diff --git a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift index 9fdbcbaa3..415db5ba0 100644 --- a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift @@ -124,6 +124,46 @@ 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 fails when no primary key value present") func saveWithoutPrimaryKey() async { let driver = MockDatabaseDriver() From 0438f95fc047e9d9fdf6c8a427b8364204daf375 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 27 May 2026 20:28:59 +0700 Subject: [PATCH 2/2] refactor(ios): handle every writePermission case in grid save and insert gates --- .../ViewModels/RowDetailViewModel.swift | 9 ++++++--- .../TableProMobile/Views/InsertRowView.swift | 10 ++++++---- .../RowDetailViewModelTests.swift | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index 624e0bfb4..a4f3b2f10 100644 --- a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -188,13 +188,16 @@ final class RowDetailViewModel { primaryKeys: pkValues ) - if safeModeLevel.writePermission == .requiresConfirmation { + switch safeModeLevel.writePermission { + case .blocked: + return false + case .requiresConfirmation: pendingSaveSQL = sql pendingWriteConfirmation = true return false + case .proceed: + return await execute(sql: sql, session: session) } - - return await execute(sql: sql, session: session) } func executePendingSave() async -> Bool { diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index f2fd91aad..c1baf56ab 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -187,13 +187,15 @@ struct InsertRowView: View { let sql = buildInsertSQL() - if safeModeLevel.writePermission == .requiresConfirmation { + switch safeModeLevel.writePermission { + case .blocked: + return + case .requiresConfirmation: pendingInsertSQL = sql showInsertConfirmation = true - return + case .proceed: + await executeInsert(sql: sql, session: session) } - - await executeInsert(sql: sql, session: session) } private func executePendingInsert() async { diff --git a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift index 415db5ba0..cf601ddf9 100644 --- a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift @@ -164,6 +164,23 @@ struct RowDetailViewModelTests { #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()