diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0168fffc..7799966060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Middle-dot separators dropped from the CSV inspector's status bar and the query history rows. - Connection marked with a tinted symbol rather than a color dot in the query history rows. - Safe Mode list offering only the levels a connection allows, with the reason under it and in the toolbar tooltip. +- ClickHouse materialized views read-only in the data grid, as on every other engine. - **Show Previous Window Tab** and **Show Next Window Tab** for window tabs, with no default shortcut. - SQLite 3.53.4 built into the SQLite and libSQL drivers in place of the macOS copy. - One-time reset of Open Quickly's Recent query history, and of its objects on connections that switch databases. @@ -119,6 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Saved query longer than 500,000 characters silently cut short when saved. - A file the import panel dimmed still opening, and reaching the wrong importer. - Materialized view opened from Open Quickly edited as a plain view. (#2522) +- Materialized view rows editable in the data grid, then refused at Save. - Index edits refused on a PGlite materialized view. - Structure grid and inspector taking edits the object or engine refuses, such as a materialized view's Type. - **Delete** and **Duplicate** in a structure row's menu doing nothing on an object that refuses them. diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 322c4c8e66..4c506abd1f 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -42,9 +42,8 @@ final class RowEditingCoordinator: ObservableObject { // MARK: - Row Operations func addNewRow() { - guard !parent.safeModeLevel.blocksAllWrites, + guard parent.canEditActiveResult, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, - tab.tableContext.isEditable, tab.tableContext.tableName != nil else { return } let tabId = tab.id @@ -69,9 +68,8 @@ final class RowEditingCoordinator: ObservableObject { } func deleteSelectedRows(indices: Set) { - guard !parent.safeModeLevel.blocksAllWrites, + guard parent.canEditActiveResult, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, - tab.tableContext.isEditable, !indices.isEmpty else { return } let tabId = tab.id @@ -113,9 +111,8 @@ final class RowEditingCoordinator: ObservableObject { } func duplicateSelectedRow(index: Int) { - guard !parent.safeModeLevel.blocksAllWrites, + guard parent.canEditActiveResult, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, - tab.tableContext.isEditable, tab.tableContext.tableName != nil else { return } let tabId = tab.id @@ -337,7 +334,7 @@ final class RowEditingCoordinator: ObservableObject { } func pasteRows() { - guard !parent.safeModeLevel.blocksAllWrites, + guard parent.canEditActiveResult, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, tab.tabType == .table else { return } diff --git a/TablePro/Models/Database/StructureEditEligibility.swift b/TablePro/Models/Database/StructureEditEligibility.swift index 3db106cd09..e9bc335a00 100644 --- a/TablePro/Models/Database/StructureEditEligibility.swift +++ b/TablePro/Models/Database/StructureEditEligibility.swift @@ -142,10 +142,9 @@ enum StructureEditAvailability: Sendable, Equatable { /// /// Pure, so the rule is testable without a connection, and ordered: an engine that cannot edit /// structure at all says that first, then the object's kind, then the engine's own statement for the -/// operation. Reading the kind as one `isView` Bool is the defect this replaces, because -/// `TableInfo.TableType.allowsRowEditing` is true for a materialized view, so the Structure tab -/// offered `ADD COLUMN`, `SET NOT NULL`, type changes and constraint edits that PostgreSQL always -/// refuses. (#2726) +/// operation. Reading the kind as one `isView` Bool is the defect this replaces, because the Bool +/// read false for a materialized view, so the Structure tab offered `ADD COLUMN`, `SET NOT NULL`, +/// type changes and constraint edits that PostgreSQL always refuses. (#2726) enum StructureEditEligibility { static func allows( _ operation: StructureEditOperation, diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index 51d0ff93e2..7da703fe61 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -38,8 +38,7 @@ internal struct EditorTabPayload: Codable, Hashable { /// Whether this tab displays a database view (read-only) internal let isView: Bool /// The object's own kind, which decides which structure edits the tab may offer. Carried beside - /// `isView` because that Bool answers a different question and cannot tell a materialized view - /// from a table. (#2726) + /// `isView` because that Bool cannot say which of seven kinds the object is. (#2726) internal let objectType: TableInfo.TableType? /// Whether to show the structure view instead of data (for "Show Structure" context menu) internal let showStructure: Bool diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index d3c713e6f9..ab9ce11782 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -167,9 +167,9 @@ struct TableInfo: Identifiable, Hashable, Sendable { /// that the server always refuses. var allowsRowEditing: Bool { switch self { - case .view, .externalTable, .sequence: + case .view, .materializedView, .externalTable, .sequence: return false - case .table, .materializedView, .foreignTable, .systemTable, .partitionedTable: + case .table, .foreignTable, .systemTable, .partitionedTable: return true } } diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 749b9431aa..5a2c1ff304 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -579,11 +579,10 @@ struct TabTableContext: Equatable { /// The object's own kind, carried beside `isView` rather than replacing it. /// - /// The two answer different questions. `isView` decides whether the *rows* may be written, which - /// a dozen Bool-only carriers already speak (deeplinks, the URL parser, scripting, recents), and - /// it comes from `allowsRowEditing`, which is deliberately true for a materialized view because - /// a matview does hold rows. This says which of seven kinds the object is, which is the only - /// thing that can say which *structure* edits it accepts. Conflating them is the defect. (#2726) + /// `isView` is a read-only mark that a dozen Bool-only carriers already speak (deeplinks, the URL + /// parser, scripting, recents), and a tab saved by an older build can carry it false over a + /// materialized view. It cannot say which of seven kinds the object is. This can, and only the + /// kind says which *structure* edits the object accepts. (#2726) /// /// Nil on a tab restored from a file written before this existed, and on any path that never /// learned the kind; `resolvedObjectKind()` falls back to what `isView` can still tell us. @@ -593,6 +592,10 @@ struct TabTableContext: Equatable { objectType ?? (isView ? .view : .table) } + var allowsRowEditing: Bool { + !isView && resolvedObjectKind().allowsRowEditing + } + var primaryKeyColumn: String? { primaryKeyColumns.first } /// A tab opened without an explicit database carries an empty name and follows the window's diff --git a/TablePro/Models/Schema/ForeignKeyEditSupport.swift b/TablePro/Models/Schema/ForeignKeyEditSupport.swift index fc1afab234..ca97b7810c 100644 --- a/TablePro/Models/Schema/ForeignKeyEditSupport.swift +++ b/TablePro/Models/Schema/ForeignKeyEditSupport.swift @@ -68,7 +68,7 @@ enum ForeignKeyEditPolicy { /// - Parameter kindRefusal: Why the object's own kind refuses a foreign key edit, nil when it /// accepts one. Supplied by `StructureEditEligibility`, because only the per-kind matrix knows /// which of seven object kinds is in front of the user. This used to be an `isTable` Bool - /// derived from `allowsRowEditing`, which is true for a materialized view, so the "+" was + /// derived from `allowsRowEditing`, which was true for a materialized view, so the "+" was /// offered over an `ADD CONSTRAINT` PostgreSQL always refuses. (#2726) static func resolve( support: ForeignKeyEditSupport, diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 052f5012fe..fb23027b50 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -115,7 +115,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { } func dataGridEmptySpaceMenu() -> NSMenu? { - guard let onAddRow else { return nil } + guard let onAddRow, coordinator?.canAddRow == true else { return nil } let menu = NSMenu() let target = StructureMenuTarget { onAddRow() } let item = NSMenuItem( diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 84530bcadb..2a01ef1603 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -189,7 +189,6 @@ struct MainEditorContentView: View { updateHasQueryText() cachedChangeManager = AnyChangeManager(changeManager) wireDataTabDelegateStableRefs() - refreshDataTabDelegateMutableRefs() coordinator.dataTabDelegate = dataTabDelegate } .onDisappear { @@ -204,18 +203,6 @@ struct MainEditorContentView: View { .onChange(of: selectionState.indices) { newIndices in onSelectionChange(newIndices) } - .onChange(of: tabManager.selectedTab?.tableContext.isEditable) { _ in - refreshDataTabDelegateMutableRefs() - } - .onChange(of: tabManager.selectedTab?.tableContext.isView) { _ in - refreshDataTabDelegateMutableRefs() - } - .onChange(of: tabManager.selectedTab?.tableContext.tableName) { _ in - refreshDataTabDelegateMutableRefs() - } - .onChange(of: coordinator.safeModeLevel) { _ in - refreshDataTabDelegateMutableRefs() - } } private func wireDataTabDelegateStableRefs() { @@ -223,17 +210,10 @@ struct MainEditorContentView: View { dataTabDelegate.selectionState = selectionState dataTabDelegate.onCellEdit = onCellEdit dataTabDelegate.onSortStateChanged = onSortStateChanged + dataTabDelegate.onAddRow = onAddRow dataTabDelegate.onFilterColumn = onFilterColumn } - private func refreshDataTabDelegateMutableRefs() { - dataTabDelegate.onAddRow = currentTabAllowsAddRow ? onAddRow : nil - } - - private var currentTabAllowsAddRow: Bool { - coordinator.canAddRow - } - // MARK: - Tab Content @ViewBuilder diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift index 7101ba28a0..47db884572 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift @@ -30,7 +30,7 @@ extension MainContentCoordinator { var canEditActiveResult: Bool { guard let tab = tabManager.selectedTab else { return false } return tab.tableContext.isEditable - && !tab.tableContext.isView + && tab.tableContext.allowsRowEditing && !safeModeLevel.blocksAllWrites && activeResultEditRefusal == nil } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index c407a6d1ea..9f51b6510a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -9,10 +9,10 @@ import TableProPluginKit extension MainContentCoordinator { /// Whether the selected tab can take a new row. /// - /// One definition, because the answer now drives two controls: the toolbar item that inserts the - /// row and the data grid delegate that the Edit menu and the grid's own shortcut route through. + /// One definition, because the answer drives two controls: the toolbar item that inserts the row + /// and the **Add Row** item on the data grid's empty-space menu. var canAddRow: Bool { - guard let tab = tabManager.selectedTab else { return false } + guard canEditActiveResult, let tab = tabManager.selectedTab else { return false } guard tab.tableContext.tableName != nil else { return false } /// Only the data grid takes a row. `addNewRow()` resolves its target through /// `GridSelectionOwner`, which answers `.none` in Chart mode and `.schemaGrid` in Structure @@ -20,10 +20,7 @@ extension MainContentCoordinator { guard tab.display.resultsViewMode == .data else { return false } /// A new row is pre-filled from the schema's account of which columns the server fills in, /// so the command waits for that account rather than staging NULL into an identity column. - guard tabSessionRegistry.tableRows(for: tab.id).hasAuthoritativeSchema else { return false } - return tab.tableContext.isEditable - && !tab.tableContext.isView - && !safeModeLevel.blocksAllWrites + return tabSessionRegistry.tableRows(for: tab.id).hasAuthoritativeSchema } func addNewRow() { diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index d36071f83a..406b2b8e99 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -57,7 +57,7 @@ struct TableStructureView: View { /// The real `TableInfo.TableType`, read from the session rather than passed in beside it, so the /// grid delegate the session owns and the footer this view publishes can never disagree about /// what they are looking at. It used to be an `isView` Bool derived from `allowsRowEditing`, - /// which is true for a materialized view, so a matview reached here as a table and was offered + /// which was true for a materialized view, so a matview reached here as a table and was offered /// `ADD COLUMN`, `SET NOT NULL`, type changes and constraint edits the server always refuses. /// (#2726) var objectKind: TableInfo.TableType { session.objectKind } diff --git a/TableProTests/Models/Query/TabObjectKindTests.swift b/TableProTests/Models/Query/TabObjectKindTests.swift index e00329ec20..296c56b4c3 100644 --- a/TableProTests/Models/Query/TabObjectKindTests.swift +++ b/TableProTests/Models/Query/TabObjectKindTests.swift @@ -8,10 +8,10 @@ import TableProPluginKit import Testing @testable import TablePro -/// A tab used to carry one `isView` Bool, derived from `allowsRowEditing`, which is deliberately true -/// for a materialized view because a matview does hold rows. So a matview reached the Structure tab as -/// a table and was offered column, index and constraint edits PostgreSQL always refuses. The kind now -/// travels beside the Bool rather than replacing it: they answer different questions. (#2726) +/// A tab used to carry one `isView` Bool, derived from `allowsRowEditing`, which was then true for a +/// materialized view. So a matview reached the Structure tab as a table and was offered column, index +/// and constraint edits PostgreSQL always refuses. The kind now travels beside the Bool, and a tab an +/// older build saved with the Bool false still carries the kind that refuses its rows. (#2726) @Suite("Tab Object Kind") @MainActor struct TabObjectKindTests { @@ -19,7 +19,7 @@ struct TabObjectKindTests { QueryTab(id: UUID(), title: "mv_sales", query: "SELECT 1", tabType: .table, tableName: "mv_sales") } - @Test("A materialized view keeps its kind and leaves row editing alone") + @Test("A materialized view keeps its kind, and the kind refuses its rows even with the Bool false") func materializedViewKeepsItsKind() throws { let manager = QueryTabManager() try manager.addTableTab( @@ -34,6 +34,30 @@ struct TabObjectKindTests { #expect(tab.tableContext.objectType == .materializedView) #expect(tab.tableContext.isView == false) #expect(tab.tableContext.resolvedObjectKind() == .materializedView) + #expect(!tab.tableContext.allowsRowEditing) + } + + @Test("A kind that refuses rows refuses them whatever the Bool says") + func refusingKindDecidesRowEditing() { + #expect(!TabTableContext(isView: false, objectType: .materializedView).allowsRowEditing) + #expect(!TabTableContext(isView: false, objectType: .view).allowsRowEditing) + #expect(!TabTableContext(isView: false, objectType: .externalTable).allowsRowEditing) + #expect(!TabTableContext(isView: false, objectType: .sequence).allowsRowEditing) + } + + @Test("A kind never turns a read-only mark back into a writable tab") + func readOnlyMarkOutranksTheKind() { + #expect(!TabTableContext(isView: true, objectType: .table).allowsRowEditing) + #expect(!TabTableContext(isView: true, objectType: .partitionedTable).allowsRowEditing) + #expect(!TabTableContext(isView: true, objectType: nil).allowsRowEditing) + } + + @Test("A writable kind, or no kind at all, keeps the rows writable") + func writableKindsKeepRowEditing() { + #expect(TabTableContext(isView: false, objectType: .table).allowsRowEditing) + #expect(TabTableContext(isView: false, objectType: .partitionedTable).allowsRowEditing) + #expect(TabTableContext(isView: false, objectType: .foreignTable).allowsRowEditing) + #expect(TabTableContext(isView: false, objectType: nil).allowsRowEditing) } @Test("Retargeting a tab writes the new object's kind over the old one") diff --git a/TableProTests/Models/TableInfoTests.swift b/TableProTests/Models/TableInfoTests.swift index 98422622db..d7e6b8965c 100644 --- a/TableProTests/Models/TableInfoTests.swift +++ b/TableProTests/Models/TableInfoTests.swift @@ -225,10 +225,14 @@ struct TableInfoTests { #expect(!TableInfo.TableType.externalTable.allowsRowEditing) } + @Test("A materialized view does not allow row editing") + func materializedViewDisallowsRowEditing() { + #expect(!TableInfo.TableType.materializedView.allowsRowEditing) + } + @Test("Local relations still allow row editing") func localRelationsAllowRowEditing() { #expect(TableInfo.TableType.table.allowsRowEditing) - #expect(TableInfo.TableType.materializedView.allowsRowEditing) #expect(TableInfo.TableType.foreignTable.allowsRowEditing) #expect(TableInfo.TableType.systemTable.allowsRowEditing) #expect(TableInfo.TableType.partitionedTable.allowsRowEditing) diff --git a/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift b/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift index f66b135e7b..cdd66fc506 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift @@ -71,6 +71,55 @@ struct MainContentCoordinatorAddRowTests { #expect(!makeCoordinator(hasAuthoritativeSchema: false).canAddRow) #expect(makeCoordinator(hasAuthoritativeSchema: true).canAddRow) } + + @Test("A query tab offers a row only while its active result may be written") + func queryTabFollowsTheResultRefusal() throws { + let resolved = try makeQueryCoordinator(keysResolved: true) + #expect(resolved.activeResultEditRefusal == nil) + #expect(resolved.canAddRow) + + let unresolved = try makeQueryCoordinator(keysResolved: false) + #expect(unresolved.activeResultEditRefusal == .keysUnresolved) + #expect(!unresolved.canAddRow) + } + + private func makeQueryCoordinator(keysResolved: Bool) throws -> MainContentCoordinator { + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: TestFixtures.makeConnection(), + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + tabManager.addTab(databaseName: "") + let index = try #require(tabManager.selectedTabIndex) + let result = ResultSet( + label: "users", + tableRows: TableRows.from(queryRows: [], columns: ["id", "name"], columnTypes: []) + ) + result.origin = ResultOrigin( + tableName: "users", + primaryKeyColumns: ["id"], + isEditable: true, + keysResolved: keysResolved + ) + tabManager.mutate(at: index) { tab in + tab.display.resultSets = [result] + tab.display.activeResultSetId = result.id + tab.tableContext.tableName = "users" + tab.tableContext.isEditable = true + } + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [[.text("1"), .text("Alice")]], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)], + hasAuthoritativeSchema: true + ), + for: tabManager.tabs[index].id + ) + return coordinator + } } @Suite("MainContentCommandActions result view") diff --git a/TableProTests/Views/Main/MaterializedViewRowWriteTests.swift b/TableProTests/Views/Main/MaterializedViewRowWriteTests.swift new file mode 100644 index 0000000000..e5fdbb5634 --- /dev/null +++ b/TableProTests/Views/Main/MaterializedViewRowWriteTests.swift @@ -0,0 +1,257 @@ +// +// MaterializedViewRowWriteTests.swift +// TableProTests +// +// PostgreSQL 17.11 refuses every row write on a materialized view ("cannot change materialized +// view"), and the sidebar opened one as an editable table, so the edits queued and failed at Save. +// + +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class MaterializedViewClipboard: ClipboardProvider { + var gridRows: GridRowsClipboardPayload? + + func readText() -> String? { nil } + func readGridRows() -> GridRowsClipboardPayload? { gridRows } + func writeText(_ text: String) {} + func writeCsv(_ csv: String) {} + func writeImage(_ image: NSImage) {} + func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) {} + var hasText: Bool { false } + var hasGridRows: Bool { gridRows != nil } +} + +@Suite("Materialized view row writes") +@MainActor +struct MaterializedViewRowWriteTests { + private func makeCoordinator() -> MainContentCoordinator { + MainContentCoordinator( + connection: TestFixtures.makeConnection(database: "shop", type: .postgresql), + tabManager: QueryTabManager(), + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + } + + private func finishLoad(_ coordinator: MainContentCoordinator) throws { + let index = try #require(coordinator.tabManager.selectedTabIndex) + coordinator.tabManager.tabs[index].tableContext.isEditable = true + coordinator.tabManager.tabs[index].display.resultsViewMode = .data + let tabId = coordinator.tabManager.tabs[index].id + coordinator.setActiveTableRows( + TableRows.from( + queryRows: [[.text("1"), .text("Alice")], [.text("2"), .text("Bob")]], + columns: ["id", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)], + hasAuthoritativeSchema: true + ), + for: tabId + ) + coordinator.changeManager.configureForTable( + tableName: coordinator.tabManager.tabs[index].tableContext.tableName ?? "", + columns: ["id", "name"], + primaryKeyColumns: ["id"], + databaseType: .postgresql, + generatedColumns: [] + ) + } + + private func openedFromSidebar(_ type: TableInfo.TableType) throws -> MainContentCoordinator { + let coordinator = makeCoordinator() + coordinator.openTableTab(TableInfo(name: "daily_totals", type: type, rowCount: nil, schema: "public")) + try finishLoad(coordinator) + return coordinator + } + + private func restoredTab(objectType: TableInfo.TableType?, isView: Bool) throws -> MainContentCoordinator { + var tab = QueryTab( + title: "daily_totals", + query: "SELECT * FROM daily_totals", + tabType: .table, + tableName: "daily_totals" + ) + tab.tableContext.isView = isView + tab.tableContext.objectType = objectType + let restored = QueryTab(from: tab.toPersistedTab(), defaultPageSize: 1_000) + + let coordinator = makeCoordinator() + coordinator.tabManager.tabs.append(restored) + coordinator.tabManager.selectedTabId = restored.id + try finishLoad(coordinator) + return coordinator + } + + private func rowCount(_ coordinator: MainContentCoordinator) throws -> Int { + let tabId = try #require(coordinator.tabManager.selectedTabId) + return coordinator.tabSessionRegistry.tableRows(for: tabId).count + } + + // MARK: - Opening from the sidebar + + @Test("A materialized view opened from the sidebar refuses row edits and Add Row") + func sidebarMaterializedViewIsReadOnly() throws { + let coordinator = try openedFromSidebar(.materializedView) + defer { coordinator.teardown() } + + let context = try #require(coordinator.tabManager.selectedTab?.tableContext) + #expect(context.isView) + #expect(context.objectType == .materializedView) + #expect(!coordinator.canEditActiveResult) + #expect(!coordinator.canAddRow) + } + + @Test("A table opened from the sidebar the same way still edits and adds rows") + func sidebarTableStaysWritable() throws { + let coordinator = try openedFromSidebar(.table) + defer { coordinator.teardown() } + + #expect(coordinator.tabManager.selectedTab?.tableContext.isView == false) + #expect(coordinator.canEditActiveResult) + #expect(coordinator.canAddRow) + } + + @Test("The sidebar and Open Quickly open a materialized view with the same gate") + func sidebarAndOpenQuicklyAgree() throws { + let fromSidebar = try openedFromSidebar(.materializedView) + defer { fromSidebar.teardown() } + + let fromSwitcher = makeCoordinator() + defer { fromSwitcher.teardown() } + let target = QuickSwitcherTarget( + connectionId: fromSwitcher.connectionId, + connectionName: "Primary", + databaseName: "shop", + schemaName: "public" + ) + let item = try #require( + QuickSwitcherViewModel.makeCrossConnectionItems( + tables: [TableInfo(name: "daily_totals", type: .materializedView, rowCount: nil)], + target: target + ).first + ) + fromSwitcher.handleQuickSwitcherSelection(item) + try finishLoad(fromSwitcher) + + let sidebarContext = try #require(fromSidebar.tabManager.selectedTab?.tableContext) + let switcherContext = try #require(fromSwitcher.tabManager.selectedTab?.tableContext) + #expect(sidebarContext.isView == switcherContext.isView) + #expect(sidebarContext.objectType == switcherContext.objectType) + #expect(fromSidebar.canEditActiveResult == fromSwitcher.canEditActiveResult) + #expect(!fromSwitcher.canEditActiveResult) + } + + // MARK: - A tab saved by an older build + + @Test("A restored tab that kept isView false over a materialized view stays read-only") + func restoredStaleTabIsReadOnly() throws { + let coordinator = try restoredTab(objectType: .materializedView, isView: false) + defer { coordinator.teardown() } + + let context = try #require(coordinator.tabManager.selectedTab?.tableContext) + #expect(context.isEditable) + #expect(!context.isView) + #expect(!coordinator.canEditActiveResult) + #expect(!coordinator.canAddRow) + } + + @Test("A restored table tab with the same history still edits and adds rows") + func restoredTableTabStaysWritable() throws { + let coordinator = try restoredTab(objectType: .table, isView: false) + defer { coordinator.teardown() } + + #expect(coordinator.canEditActiveResult) + #expect(coordinator.canAddRow) + } + + @Test("A restored tab with no kind follows its Bool") + func restoredTabWithoutAKindFollowsTheBool() throws { + let writable = try restoredTab(objectType: nil, isView: false) + defer { writable.teardown() } + let readOnly = try restoredTab(objectType: nil, isView: true) + defer { readOnly.teardown() } + + #expect(writable.canEditActiveResult) + #expect(!readOnly.canEditActiveResult) + } + + // MARK: - The row commands themselves + + @Test("Add, Duplicate, Delete and Paste stage nothing on a materialized view") + func rowCommandsRefuseAMaterializedView() throws { + let clipboard = MaterializedViewClipboard() + clipboard.gridRows = GridRowsClipboardPayload(columns: ["id", "name"], rows: [[.text("3"), .text("Cleo")]]) + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + let coordinator = try restoredTab(objectType: .materializedView, isView: false) + defer { coordinator.teardown() } + + coordinator.addNewRow() + coordinator.duplicateSelectedRow(index: 0) + coordinator.pasteRows() + #expect(try rowCount(coordinator) == 2) + + coordinator.deleteSelectedRows(indices: [0]) + #expect(!coordinator.changeManager.hasChanges) + } + + @Test("Delete stages nothing on a view whose load marked it editable") + func deleteRefusesALoadedView() throws { + let coordinator = try restoredTab(objectType: .view, isView: true) + defer { coordinator.teardown() } + + coordinator.deleteSelectedRows(indices: [0]) + #expect(!coordinator.changeManager.hasChanges) + } + + @Test("Add, Duplicate and Paste still stage rows on a table") + func rowCommandsStillWorkOnATable() throws { + let clipboard = MaterializedViewClipboard() + clipboard.gridRows = GridRowsClipboardPayload(columns: ["id", "name"], rows: [[.text("3"), .text("Cleo")]]) + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + + let coordinator = try restoredTab(objectType: .table, isView: false) + defer { coordinator.teardown() } + + coordinator.addNewRow() + #expect(try rowCount(coordinator) == 3) + coordinator.duplicateSelectedRow(index: 0) + #expect(try rowCount(coordinator) == 4) + coordinator.pasteRows() + #expect(try rowCount(coordinator) == 5) + } + + @Test("Delete still stages on a table") + func deleteStillStagesOnATable() throws { + let coordinator = try restoredTab(objectType: .table, isView: false) + defer { coordinator.teardown() } + + coordinator.deleteSelectedRows(indices: [0]) + #expect(coordinator.changeManager.hasChanges) + } + + // MARK: - The grid's empty-space menu + + @Test("The grid's empty space offers Add Row on a table and not on a materialized view") + func emptySpaceMenuFollowsTheGate() throws { + let matview = try restoredTab(objectType: .materializedView, isView: false) + defer { matview.teardown() } + let table = try restoredTab(objectType: .table, isView: false) + defer { table.teardown() } + + let matviewDelegate = DataTabGridDelegate() + matviewDelegate.coordinator = matview + matviewDelegate.onAddRow = { matview.addNewRow() } + let tableDelegate = DataTabGridDelegate() + tableDelegate.coordinator = table + tableDelegate.onAddRow = { table.addNewRow() } + + #expect(matviewDelegate.dataGridEmptySpaceMenu() == nil) + #expect(tableDelegate.dataGridEmptySpaceMenu() != nil) + } +} diff --git a/TableProTests/Views/QuickSwitcherObjectKindTests.swift b/TableProTests/Views/QuickSwitcherObjectKindTests.swift index 20456feecb..1e23fc7d31 100644 --- a/TableProTests/Views/QuickSwitcherObjectKindTests.swift +++ b/TableProTests/Views/QuickSwitcherObjectKindTests.swift @@ -30,6 +30,7 @@ struct QuickSwitcherObjectKindTests { #expect(items.map(\.tableType) == [.materializedView, .partitionedTable]) #expect(items.first?.kind == .view) + #expect(items.map(\.isReadOnly) == [true, false]) } @Test("Opening a materialized view from the Quick Switcher gives its tab the matview kind") @@ -49,7 +50,7 @@ struct QuickSwitcherObjectKindTests { name: "daily_totals", kind: .view, subtitle: String(localized: "Materialized View"), - isReadOnly: false, + isReadOnly: true, schemaName: "public", tableType: .materializedView ) diff --git a/docs/databases/clickhouse.mdx b/docs/databases/clickhouse.mdx index 9e22358570..8bc09cf848 100644 --- a/docs/databases/clickhouse.mdx +++ b/docs/databases/clickhouse.mdx @@ -83,6 +83,7 @@ The CA file may be PEM or DER. If **Verify CA** cannot read it, the connection f ## Limitations - No foreign keys and no multi-statement transactions. +- A materialized view opens read-only in the data grid. Edit the table a `TO` view writes into, or run `ALTER TABLE … UPDATE` and `ALTER TABLE … DELETE` on a view that stores its own rows from the SQL editor. - No auto-increment, and the primary key and sorting key are fixed at creation. Structure editing covers adding, modifying, and dropping columns, and dropping data-skipping indexes. Add or change a data-skipping index with `ALTER TABLE … ADD INDEX` in the SQL editor, and recreate the table for anything else. - A `SET` does not carry to the next statement. Every statement is its own HTTP request with no session id, and the setting is gone by the next one. Put it in a `SETTINGS` clause on the query itself. - A query with its own `FORMAT` clause, such as `SELECT 1 FORMAT JSON`, shows the server's raw output in one column instead of a parsed table. Drop the clause to get a grid. diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index e5a9bbb6c0..65cfd132c6 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -114,6 +114,8 @@ An SQL export writes those statements back for tables, views and materialized vi The Structure tab of a view, a materialized view or a foreign table offers only the edits PostgreSQL takes on that kind of object, which is fewer than a table's and different for each. See [Table Structure](/features/table-structure#what-each-object-accepts). +PostgreSQL refuses `INSERT`, `UPDATE` and `DELETE` on a materialized view, so its rows open read-only in the data grid. Edit the tables it reads from, then choose **Refresh Materialized View…**. + ## Cross-database tabs PostgreSQL has no in-place `USE`, so a tab bound to a database other than the connection's active one runs on a second connection opened for that database. It shares no temp tables, session variables, or open transaction with the query editor on the main connection: keep a multi-statement transaction or a `CREATE TEMP TABLE` on tabs bound to one database. Binding itself is on [Tabs](/features/tabs#where-a-tab-points). diff --git a/docs/features/change-tracking.mdx b/docs/features/change-tracking.mdx index 86f6472d65..711718a5e1 100644 --- a/docs/features/change-tracking.mdx +++ b/docs/features/change-tracking.mdx @@ -52,7 +52,9 @@ Add Row and Duplicate Row stay dimmed until the table's schema has loaded, which ## When the grid will not edit -A tab opened from the sidebar edits its table directly. A query tab edits only when the app can prove the rows came from exactly one table: +A tab opened from the sidebar edits its table directly. Views, materialized views, sequences and external tables open read-only; to change what a view shows, edit the tables it reads from. + +A query tab edits only when the app can prove the rows came from exactly one table: | Query | Editable | |---|---|