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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 4 additions & 7 deletions TablePro/Core/Coordinators/RowEditingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -69,9 +68,8 @@ final class RowEditingCoordinator: ObservableObject {
}

func deleteSelectedRows(indices: Set<Int>) {
guard !parent.safeModeLevel.blocksAllWrites,
guard parent.canEditActiveResult,
let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex,
tab.tableContext.isEditable,
!indices.isEmpty else { return }

let tabId = tab.id
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }

Expand Down
7 changes: 3 additions & 4 deletions TablePro/Models/Database/StructureEditEligibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions TablePro/Models/Query/EditorTabPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Models/Query/QueryResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
13 changes: 8 additions & 5 deletions TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Models/Schema/ForeignKeyEditSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Main/Child/DataTabGridDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 1 addition & 21 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ struct MainEditorContentView: View {
updateHasQueryText()
cachedChangeManager = AnyChangeManager(changeManager)
wireDataTabDelegateStableRefs()
refreshDataTabDelegateMutableRefs()
coordinator.dataTabDelegate = dataTabDelegate
}
.onDisappear {
Expand All @@ -204,36 +203,17 @@ 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() {
dataTabDelegate.coordinator = coordinator
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,18 @@ 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
/// mode, so without this the command is either inert or adds a column under a row's name.
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() {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Views/Structure/TableStructureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
34 changes: 29 additions & 5 deletions TableProTests/Models/Query/TabObjectKindTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ 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 {
private func tableTab() -> QueryTab {
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(
Expand All @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion TableProTests/Models/TableInfoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading