diff --git a/CHANGELOG.md b/CHANGELOG.md index f6705d40a9..6204692737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -524,6 +524,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Diagram Fit to Window stopping a scroll bar's width short when scroll bars are always shown. - No Executing indicator or Stop button for a query tab with no result grid, in Output mode or on a query plan. - Executing indicator and Stop button carried over for a moment onto the query tab switched to. +- Every window's front tab reloading, and asking to discard its edits, after a row import, a new table or a structure change. +- Tabs showing old rows, columns, DDL or triggers after a save, import, structure change or materialized view refresh. +- Tables with hidden columns querying a dropped column, or leaving out a new one, after a SQL file import or a structure change. +- First SQLite or libSQL query after a structure save showing the table's old columns, without the new column's values. +- Edits after a structure change using the table's old primary key, defaults and generated columns. ### Security diff --git a/Packages/TableProCore/Sources/TableProSQLiteCore/SQLiteResultColumns.swift b/Packages/TableProCore/Sources/TableProSQLiteCore/SQLiteResultColumns.swift new file mode 100644 index 0000000000..ca7a06ec75 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSQLiteCore/SQLiteResultColumns.swift @@ -0,0 +1,35 @@ +import CSQLite + +/// The columns a statement returns, read once its first step has run. +/// +/// `sqlite3_prepare_v2` compiles against the schema this connection last read and does not look at +/// the file. The first `sqlite3_step` compares the schema cookie and, when another connection has +/// changed the table since, prepares the statement again with the new columns. Read before that +/// step, a `SELECT *` on the session connection right after an `ALTER TABLE` run on a pooled one +/// named the old columns and dropped the new column's values from every row. The structure editor +/// alters on a pooled connection, so that was the first reload after every structure save. +/// +/// Both plugins that link their own SQLite share this, so it compiles against that library's header +/// and never against the SDK's `SQLite3` module, whose `link "sqlite3"` would ask the linker for +/// macOS's build too. +public enum SQLiteResultColumns { + public struct FirstStep: Sendable { + public let result: Int32 + public let names: [String] + public let typeNames: [String] + + public var count: Int32 { Int32(names.count) } + } + + public static func stepFirst(_ statement: OpaquePointer?) -> FirstStep { + let result = sqlite3_step(statement) + let count = sqlite3_column_count(statement) + var names: [String] = [] + var typeNames: [String] = [] + for index in 0..= PluginRowLimits.emergencyMax { truncated = true break } rows.append(rowValues(of: statement, count: columnCount)) + stepResult = sqlite3_step(statement) } if columns.isEmpty { @@ -142,10 +145,11 @@ actor SQLiteLocalBackend { throw LibSQLError(message: errorMessage) } - let columnCount = sqlite3_column_count(statement) + let firstStep = SQLiteResultColumns.stepFirst(statement) + let columnCount = firstStep.count continuation.yield(.header(PluginStreamHeader( - columns: columnNames(of: statement, count: columnCount), - columnTypeNames: columnDeclaredTypes(of: statement, count: columnCount), + columns: firstStep.names, + columnTypeNames: firstStep.typeNames, estimatedRowCount: nil ))) @@ -153,7 +157,8 @@ actor SQLiteLocalBackend { var batch: [PluginRow] = [] batch.reserveCapacity(batchSize) - while sqlite3_step(statement) == SQLITE_ROW { + var stepResult = firstStep.result + while stepResult == SQLITE_ROW { if Task.isCancelled { if !batch.isEmpty { continuation.yield(.rows(batch)) @@ -168,6 +173,7 @@ actor SQLiteLocalBackend { continuation.yield(.rows(batch)) batch.removeAll(keepingCapacity: true) } + stepResult = sqlite3_step(statement) } if !batch.isEmpty { @@ -210,18 +216,6 @@ actor SQLiteLocalBackend { } } - private func columnNames(of statement: OpaquePointer?, count: Int32) -> [String] { - (0.. [String] { - (0.. [PluginCellValue] { (0..= PluginRowLimits.emergencyMax { truncated = true @@ -310,17 +312,17 @@ actor SQLiteLocalBackend: SQLiteExecutionBackend { throw SQLitePluginError.queryFailed(String(cString: sqlite3_errmsg(db))) } - let columnCount = sqlite3_column_count(statement) - let (columns, columnTypeNames) = Self.columnMetadata(statement, count: columnCount) + let firstStep = SQLiteResultColumns.stepFirst(statement) + let columnCount = firstStep.count continuation.yield(.header(PluginStreamHeader( - columns: columns, columnTypeNames: columnTypeNames, estimatedRowCount: nil + columns: firstStep.names, columnTypeNames: firstStep.typeNames, estimatedRowCount: nil ))) let batchSize = 5_000 var batch: [PluginRow] = [] batch.reserveCapacity(batchSize) - var stepResult = sqlite3_step(statement) + var stepResult = firstStep.result while stepResult == SQLITE_ROW { if Task.isCancelled { if !batch.isEmpty { continuation.yield(.rows(batch)) } @@ -369,24 +371,6 @@ actor SQLiteLocalBackend: SQLiteExecutionBackend { } } - private static func columnMetadata(_ statement: OpaquePointer?, count: Int32) -> ([String], [String]) { - var columns: [String] = [] - var columnTypeNames: [String] = [] - for i in 0.. [PluginCellValue] { var row: [PluginCellValue] = [] for i in 0.. Bool { - guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }), + !parent.tabSessionRegistry.needsDefinition(tabId) + else { return false } let tab = parent.tabManager.tabs[idx] @@ -168,6 +173,24 @@ extension QueryExecutionCoordinator { return resolved } + /// The keys a committed result is edited by. Keys the tab held for the same table carry over + /// while its definition is unchanged, until phase 2 reports them. After a definition change they + /// describe the table as it was, so they are never carried into the new result. + static func resolvedPrimaryKeys( + reported: [String]?, + engineDefault: String?, + previous: [String], + definitionChanged: Bool + ) -> [String] { + if let reported, !reported.isEmpty { + return reported + } + if let engineDefault { + return [engineDefault] + } + return definitionChanged ? [] : previous + } + func applyPhase1Result( // swiftlint:disable:this function_parameter_count tabId: UUID, columns: [String], @@ -180,6 +203,7 @@ extension QueryExecutionCoordinator { isEditable: Bool, metadata: ParsedSchemaMetadata?, hasSchema: Bool, + read: TableFreshness.Read, sql: String, connection conn: DatabaseConnection, isTruncated: Bool = false, @@ -240,8 +264,13 @@ extension QueryExecutionCoordinator { foreignKeysFetched: resolved.foreignKeysFetched ) let previousTableName = parent.tabManager.tabs[idx].tableContext.tableName + let definitionChanged = parent.tabSessionRegistry.needsDefinition(existingTabId) parent.flushBufferToActiveResult(tabId: existingTabId, pinnedOnly: true) parent.setActiveTableRows(newTableRows, for: existingTabId, viewport: viewport) + /// A count that started before the change can land after it, while the tab is out of sight, + /// and put the old total back. Kept, a total above the automatic-count threshold stops the + /// count this read launches, so paging stays bounded by the table as it was. + let answeredRowsChange = parent.tabSessionRegistry.recordRead(read, for: existingTabId) parent.tabManager.mutate(at: idx) { tab in tab.schemaVersion += 1 @@ -253,6 +282,9 @@ extension QueryExecutionCoordinator { tab.tableContext.isEditable = isEditable tab.pagination.isLoading = false + if answeredRowsChange { + tab.pagination.retireDerivedRowCount() + } if let metadata, let approxCount = metadata.approximateRowCount, approxCount > 0, !tab.filterState.hasAppliedFilters { tab.pagination.applyDerivedRowCount(approxCount, isApproximate: true) @@ -289,16 +321,12 @@ extension QueryExecutionCoordinator { } parent.toolbarState.isResultsCollapsed = false - let resolvedPKs: [String] - if let pks = metadata?.primaryKeyColumns, !pks.isEmpty { - resolvedPKs = pks - } else if let defaultPK = PluginManager.shared.defaultPrimaryKeyColumn(for: conn.type) { - resolvedPKs = [defaultPK] - } else if tableName == previousTableName { - resolvedPKs = parent.tabManager.tabs[idx].tableContext.primaryKeyColumns - } else { - resolvedPKs = [] - } + let resolvedPKs = Self.resolvedPrimaryKeys( + reported: metadata?.primaryKeyColumns, + engineDefault: PluginManager.shared.defaultPrimaryKeyColumn(for: conn.type), + previous: tableName == previousTableName ? parent.tabManager.tabs[idx].tableContext.primaryKeyColumns : [], + definitionChanged: definitionChanged + ) parent.tabManager.mutate(at: idx) { $0.tableContext.primaryKeyColumns = resolvedPKs } captureOrigin( diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 6644cc254f..22dabedcdc 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -632,6 +632,7 @@ extension QueryExecutionCoordinator { isEditable: isEditable, metadata: inlineMetadata, hasSchema: false, + read: TableFreshness.Read(startedAt: claim.startedAt, includesDefinition: false), sql: sql, connection: connection, isTruncated: fetchResult.isTruncated, diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift index f0d7e2288d..56bb65ba2b 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift @@ -64,6 +64,7 @@ extension RowEditingCoordinator { if let (_, index) = parent.tabManager.selectedTabAndIndex { parent.tabManager.mutate(at: index) { $0.pendingChanges = TabChangeSnapshot() } } + parent.resumeDeferredTableRefresh() Task { [parent] in await parent.refreshTables() } } diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift index afe307638e..3009bfca4f 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift @@ -3,6 +3,7 @@ // TablePro // +import Combine import Foundation import os import SwiftUI @@ -323,11 +324,20 @@ extension RowEditingCoordinator { /// The saving tab reloads only when this save changed its rows: rows it wrote, or a truncate of /// the table it shows. A save of table operations alone used to re-run whatever the selected /// tab held, which on a query tab executed its statement again. A tab on a dropped table closes. - guard savingTabIsSelected, - let savedTabIndex = parent.tabManager.selectedTabIndex, - !tab(at: savedTabIndex, shows: deletedTables), - plan.steps.contains(where: { $0.kind == .rowWrite }) || tab(at: savedTabIndex, shows: truncatedTables) - else { return } + /// A tab another change marked stale while it held these edits reloads now they are saved. + let reloadIndex = parent.tabManager.selectedTabIndex.flatMap { index -> Int? in + guard savingTabIsSelected, !tab(at: index, shows: deletedTables) else { return nil } + let changedItsRows = plan.steps.contains(where: { $0.kind == .rowWrite }) + || tab(at: index, shows: truncatedTables) + || parent.tabSessionRegistry.isStale(parent.tabManager.tabs[index].id) + return changedItsRows ? index : nil + } + announceWrittenTables( + plan: plan, + truncatedTables: truncatedTables, + reloadingTabId: reloadIndex.map { parent.tabManager.tabs[$0].id } + ) + guard let savedTabIndex = reloadIndex else { return } /// An insert or a delete changes the number this tab is reporting, so a count /// the user asked for before the save no longer describes the table. Without @@ -337,6 +347,35 @@ extension RowEditingCoordinator { parent.runQuery(viewport: .keepPlace) } + /// Every table this save wrote rows to or truncated, so a tab showing one in this window or + /// another stops showing the rows as they were. The saving tab reloads itself and is left out. + private func announceWrittenTables( + plan: DataWritePlan, + truncatedTables: Set, + reloadingTabId: UUID? + ) { + let connectionId = parent.connectionId + let adoption = catalogEditAdoption + var written = Set(plan.steps.filter { $0.kind == .rowWrite }.compactMap { step in + step.tableName.map { DataWriteTarget(database: plan.scope.database, schema: plan.scope.schema, table: $0) } + }) + for ref in truncatedTables { + guard let scope = adoption.objectScope(for: ref, connectionId: connectionId) else { continue } + written.insert(DataWriteTarget(database: scope.database, schema: scope.schema, table: ref.table.name)) + } + for target in written { + AppCommands.shared.objectChanged.send( + DatabaseObjectChange( + connectionId: connectionId, + scope: DatabaseScope(connectionId: connectionId, database: target.database, schema: target.schema), + name: target.table, + kind: .rows, + originTabId: reloadingTabId + ) + ) + } + } + /// MySQL, MariaDB and Oracle commit each DROP as it runs, so a save that failed part way can /// already have dropped tables its rollback could not bring back. The refreshed catalog says /// which, and those are then treated as the drops they were: unstaged, and their tabs closed. diff --git a/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift b/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift index e7c85778a2..6e4b34d821 100644 --- a/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift +++ b/TablePro/Core/Database/DatabaseManager+DocumentWrite.swift @@ -52,7 +52,9 @@ extension DatabaseManager { } await recordDocumentWrite(statement, scope: scope, databaseType: databaseType, startedAt: startedAt, error: nil) - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: scope.connectionId, scope: scope)) + AppCommands.shared.objectChanged.send( + DatabaseObjectChange(connectionId: scope.connectionId, scope: scope, name: write.table, kind: .rows) + ) } private func recordDocumentWrite( diff --git a/TablePro/Core/Database/DatabaseManager+Schema.swift b/TablePro/Core/Database/DatabaseManager+Schema.swift index 9c50ad1177..f0c8539d4a 100644 --- a/TablePro/Core/Database/DatabaseManager+Schema.swift +++ b/TablePro/Core/Database/DatabaseManager+Schema.swift @@ -24,7 +24,8 @@ extension DatabaseManager { func executeSchemaChanges( _ statements: [SchemaStatement], databaseType: DatabaseType, - scope: DatabaseScope + scope: DatabaseScope, + table: String ) async throws { let route = schemaChangeRoute(for: scope) @@ -104,7 +105,9 @@ extension DatabaseManager { ) } - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: scope.connectionId, scope: scope)) + AppCommands.shared.objectChanged.send( + DatabaseObjectChange(connectionId: scope.connectionId, scope: scope, name: table, kind: .structure) + ) CatalogChangeService.post( .changed(CatalogChange(connectionId: scope.connectionId, database: scope.database, kinds: .tables)) ) diff --git a/TablePro/Core/Database/TriggerEditing.swift b/TablePro/Core/Database/TriggerEditing.swift index 08a7199b4b..48daf10354 100644 --- a/TablePro/Core/Database/TriggerEditing.swift +++ b/TablePro/Core/Database/TriggerEditing.swift @@ -98,7 +98,9 @@ enum TriggerEditing { } await recordHistory(sql, scope: scope, connection: connection, executionTime: Date().timeIntervalSince(startedAt)) - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id, scope: scope)) + AppCommands.shared.objectChanged.send( + DatabaseObjectChange(connectionId: connection.id, scope: scope, name: tableName, kind: .structure) + ) } static func drop( @@ -139,7 +141,9 @@ enum TriggerEditing { _ = try await driver.execute(query: dropSQL) } await recordHistory(dropSQL, scope: scope, connection: connection, executionTime: Date().timeIntervalSince(startedAt)) - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id, scope: scope)) + AppCommands.shared.objectChanged.send( + DatabaseObjectChange(connectionId: connection.id, scope: scope, name: tableName, kind: .structure) + ) CatalogChangeService.shared.record( .changed(CatalogChange(connectionId: connection.id, database: scope.database, kinds: .triggers)) ) diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index 2c8877c805..caca9b06db 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -7,17 +7,24 @@ import Combine import Foundation import TableProPluginKit -/// A rows-changed signal. `scope` names the database and schema the change landed in, so a tab -/// on another scope does not reload. A nil scope means the whole connection changed. It says -/// nothing about the catalog: a change to the objects themselves goes through -/// `CatalogChangeService`, which reaches every store and window whatever they are browsing. +/// A change that names no single table: a SQL file import, a session context switch, a new enum +/// label. Each can change a definition as well as rows, since an import runs DDL, a label changes a +/// column's allowed values and a context switch changes what a name resolves to, so every table tab +/// in `scope` is marked for both and each window's selected one reloads when nothing of the user's +/// is in the way. A nil scope means the whole connection changed. A write to one known table sends +/// `DatabaseObjectChange` instead, which reaches only the tabs showing it. Neither says anything +/// about the catalog: a change to the objects themselves goes through `CatalogChangeService`. struct DataRefreshRequest: Sendable, Equatable { let connectionId: UUID let scope: DatabaseScope? + /// Taken when the request is made, after the change it announces, so a load that claimed its + /// tab later has already read it. + let changedAt: ContinuousClock.Instant - init(connectionId: UUID, scope: DatabaseScope? = nil) { + init(connectionId: UUID, scope: DatabaseScope? = nil, changedAt: ContinuousClock.Instant = .now) { self.connectionId = connectionId self.scope = scope + self.changedAt = changedAt } /// A tab reloads when the change landed in its own scope. Matching on the browse @@ -28,16 +35,15 @@ struct DataRefreshRequest: Sendable, Equatable { } } -/// A change to one named object, addressed by name rather than by scope. -/// -/// `DataRefreshRequest` reloads whichever tab each window has selected in the scope, whatever table -/// it shows, and asks the user to discard its edits first. A change that touches one object has no -/// business interrupting a tab on another, and the tabs that do show it are reloaded whether they -/// are in front or not. +/// A change to one named object, addressed by name rather than by scope, so a tab on another object +/// is never touched and every tab on this one is, in front or not. struct DatabaseObjectChange: Sendable, Equatable { enum Kind: Sendable, Equatable { - /// The object's rows were recomputed, as a materialized view refresh does. + /// The object's rows changed: a save, an import, an inserted document, a materialized view + /// refresh. case rows + /// The object's columns, keys, indexes or triggers changed, and with them possibly its rows. + case structure /// The object's comment changed. case comment /// The object no longer exists. @@ -50,6 +56,28 @@ struct DatabaseObjectChange: Sendable, Equatable { let scope: DatabaseScope let name: String let kind: Kind + /// The tab that made the change and reloads itself, so it is not reloaded a second time. + let originTabId: UUID? + /// Taken when the change is made, after its write has finished, so a load that claimed its tab + /// later has already read it. Delivery comes a run loop turn after that, by when such a load may + /// already be running. + let changedAt: ContinuousClock.Instant + + init( + connectionId: UUID, + scope: DatabaseScope, + name: String, + kind: Kind, + originTabId: UUID? = nil, + changedAt: ContinuousClock.Instant = .now + ) { + self.connectionId = connectionId + self.scope = scope + self.name = name + self.kind = kind + self.originTabId = originTabId + self.changedAt = changedAt + } /// Whether a tab's table is this object. Schema is compared as the tab stores it, which is the /// resolved schema for an engine that has them and nil for one that does not. diff --git a/TablePro/Core/Services/Query/SchemaColumnStore.swift b/TablePro/Core/Services/Query/SchemaColumnStore.swift index 733cfda3a3..f6f38a209b 100644 --- a/TablePro/Core/Services/Query/SchemaColumnStore.swift +++ b/TablePro/Core/Services/Query/SchemaColumnStore.swift @@ -68,6 +68,11 @@ final class SchemaColumnStore { entries.removeAll() } + func remove(_ key: String) { + loads.removeValue(forKey: key)?.task.cancel() + entries.removeValue(forKey: key) + } + /// A cancelled load is never joined. Between the last waiter leaving and its `load` clearing /// the entry there is a window where the task is already cancelled, and a caller that adopted /// it would wait for a fetch that is never going to produce anything. Clicking back to a table diff --git a/TablePro/Models/Query/TabSession.swift b/TablePro/Models/Query/TabSession.swift index 558dce08ff..9038cd12f8 100644 --- a/TablePro/Models/Query/TabSession.swift +++ b/TablePro/Models/Query/TabSession.swift @@ -47,10 +47,17 @@ final class TabSession: ObservableObject, Identifiable { var viewportStage: GridViewportStage? + /// Whether the table changed after these rows, or the definition they were read with, were + /// fetched. Unlike eviction the rows stay, so the grid goes on showing them until the reload + /// commits over them, and a tab whose last result was empty can be marked too. Not published: + /// nothing draws it. + var freshness: TableFreshness + init(id: UUID = UUID()) { self.id = id self.tableRows = TableRows() self.isEvicted = false + self.freshness = TableFreshness() self.dataRevision = 0 self.bufferEpoch = 0 self.rowSetRevision = 0 diff --git a/TablePro/Models/Query/TabSessionRegistry.swift b/TablePro/Models/Query/TabSessionRegistry.swift index 9ca86b4b67..a8c716fa29 100644 --- a/TablePro/Models/Query/TabSessionRegistry.swift +++ b/TablePro/Models/Query/TabSessionRegistry.swift @@ -98,6 +98,39 @@ final class TabSessionRegistry { session.rowSetRevision &+= 1 } + func isStale(_ tabId: UUID) -> Bool { + sessions[tabId]?.freshness.isStale ?? false + } + + /// Whether the next load has to fetch the table's definition rather than reuse the metadata the + /// tab holds. + func needsDefinition(_ tabId: UUID) -> Bool { + sessions[tabId]?.freshness.needsDefinition ?? false + } + + /// Records that the tab's table changed. Nothing is discarded, and a tab holding no rows is + /// marked all the same: an empty collection that gains its first document is exactly the tab + /// eviction refuses. + func recordChange(_ change: TableFreshness.Change, for tabId: UUID) { + ensureSession(for: tabId).freshness.record(change) + } + + func pendingChange(for tabId: UUID) -> TableFreshness.Change? { + sessions[tabId]?.freshness.pendingChange + } + + func definitionIsCurrent(asOf startedAt: ContinuousClock.Instant, for tabId: UUID) -> Bool { + sessions[tabId]?.freshness.definitionIsCurrent(asOf: startedAt) ?? true + } + + /// Called only where a fetched result is committed. `setTableRows` also installs rows the tab + /// already held, a result set switch or a re-sort, and those answer nothing about the change. + /// Answers whether the read covered a change to the table's rows. + @discardableResult + func recordRead(_ read: TableFreshness.Read, for tabId: UUID) -> Bool { + sessions[tabId]?.freshness.record(read) ?? false + } + func stageViewportPlacement(_ placement: GridViewportPlacement, for tabId: UUID) { guard let session = sessions[tabId] else { return } session.viewportStage = GridViewportStage(bufferEpoch: session.bufferEpoch, placement: placement) diff --git a/TablePro/Models/Query/TableFreshness.swift b/TablePro/Models/Query/TableFreshness.swift new file mode 100644 index 0000000000..9641a8556f --- /dev/null +++ b/TablePro/Models/Query/TableFreshness.swift @@ -0,0 +1,93 @@ +// +// TableFreshness.swift +// TablePro +// + +import Foundation + +/// How far a table tab's rows and definition trail the changes announced for its table. +/// +/// A change is stamped when it is announced, after its write has finished, and a read with the +/// moment its query claimed the tab. A read covers a change only when it started after it: a load +/// already running when the change lands may have read the table before the write, so committing +/// it leaves the mark in place. A definition is covered only by a read that fetched the definition +/// itself, because a load that reused the metadata it already held carries the old keys, defaults +/// and generated columns into the new result. +struct TableFreshness: Equatable { + struct Change: Equatable, Sendable { + enum Extent: Equatable, Sendable { + case rows + /// The columns, keys or constraints changed, and with them possibly the rows. + case definition + } + + let extent: Extent + let at: ContinuousClock.Instant + } + + struct Read: Equatable, Sendable { + let startedAt: ContinuousClock.Instant + let includesDefinition: Bool + } + + private(set) var rowsChangedAt: ContinuousClock.Instant? + private(set) var definitionChangedAt: ContinuousClock.Instant? + + var isStale: Bool { + rowsChangedAt != nil || definitionChangedAt != nil + } + + var needsDefinition: Bool { + definitionChangedAt != nil + } + + /// What a read still has to answer: the latest change marked, as a definition change while one + /// is outstanding. Nil when nothing is. + var pendingChange: Change? { + guard let at = rowsChangedAt ?? definitionChangedAt else { return nil } + return Change(extent: needsDefinition ? .definition : .rows, at: at) + } + + mutating func record(_ change: Change) { + rowsChangedAt = Self.later(rowsChangedAt, change.at) + if change.extent == .definition { + definitionChangedAt = Self.later(definitionChangedAt, change.at) + } + } + + /// Whether a definition fetched by a read that started at `startedAt` describes the table after + /// every definition change marked, rather than before the latest one. + func definitionIsCurrent(asOf startedAt: ContinuousClock.Instant) -> Bool { + guard let definitionChangedAt else { return true } + return definitionChangedAt <= startedAt + } + + /// Answers whether the read covered a change to the rows, which is what makes every total + /// derived before it stale too. + @discardableResult + mutating func record(_ read: Read) -> Bool { + var answeredRows = false + if let changedAt = rowsChangedAt, changedAt <= read.startedAt { + rowsChangedAt = nil + answeredRows = true + } + if read.includesDefinition, definitionIsCurrent(asOf: read.startedAt) { + definitionChangedAt = nil + } + return answeredRows + } + + /// Whether a query already running when `change` is recorded, claimed at `startedAt`, reads what + /// the change wrote. Never for a definition: that query chose its metadata before the mark existed. + static func inFlightRead(startedAt: ContinuousClock.Instant, covers change: Change) -> Bool { + change.extent == .rows && change.at <= startedAt + } + + private static func later( + _ current: ContinuousClock.Instant?, + _ candidate: ContinuousClock.Instant + ) -> ContinuousClock.Instant { + guard let current else { return candidate } + return max(current, candidate) + } +} diff --git a/TablePro/Models/Query/TableRowsRefreshPlan.swift b/TablePro/Models/Query/TableRowsRefreshPlan.swift new file mode 100644 index 0000000000..f4704037b7 --- /dev/null +++ b/TablePro/Models/Query/TableRowsRefreshPlan.swift @@ -0,0 +1,112 @@ +// +// TableRowsRefreshPlan.swift +// TablePro +// + +import Foundation + +/// Which table tabs a change to their table leaves holding old rows, and what the selected one does +/// about it now. +/// +/// Every addressed tab is marked stale and keeps its rows. A background tab reloads when it is next +/// shown. The selected tab reloads at once only when nothing of the user's is in the way: a change +/// made somewhere else never commits a half-typed cell and never asks to discard edits. A load +/// already running is left to finish only when it reads what the change wrote; one that claimed the +/// tab before the change, or chose its metadata before a definition change, is started again, +/// since its result is out of date the moment it lands. A selected tab left marked reloads once +/// what stood in the way is gone: its cell overlay closes, or its edits are saved, undone or +/// discarded. +struct TableRowsRefreshPlan: Equatable { + enum SelectedTabAction: Equatable { + case noReload + case reloadNow + case reloadBehindStructure + } + + /// What is running on the selected tab when the change lands. + enum SelectedTabLoad: Equatable { + case idle + /// A load is scheduled and has not claimed the tab yet, so it reads the table as it is now + /// and sees the mark when it chooses its metadata. + case scheduled + /// A query claimed the tab at this instant. + case running(startedAt: ContinuousClock.Instant) + /// Work that extends the rows already on screen, such as Fetch All, and owns no result. + case extending + } + + /// What the tab value alone cannot say about the selected tab. + struct SelectedTabState: Equatable { + let id: UUID + let holdsEdits: Bool + let load: SelectedTabLoad + } + + let staleTabIds: [UUID] + let selectedTabAction: SelectedTabAction + + init( + tabs: [QueryTab], + selectedTab: SelectedTabState?, + change: TableFreshness.Change, + excludingTabId: UUID? = nil, + where isAddressed: (QueryTab) -> Bool + ) { + let addressed = tabs.filter { tab in + tab.tabType == .table && tab.id != excludingTabId && isAddressed(tab) + } + staleTabIds = addressed.map(\.id) + + guard let selectedTab, let tab = addressed.first(where: { $0.id == selectedTab.id }) else { + selectedTabAction = .noReload + return + } + selectedTabAction = Self.action( + for: tab, + state: selectedTab, + loadMisses: Self.landingLoadMisses(change, load: selectedTab.load) + ) + } + + /// What the selected tab does about a change it already owes, once whatever put the reload off + /// has gone. Asked on every such moment, a tab switch or a save included, so the tab's own load + /// stands in the way like an edit does: one that claimed the tab after the change read what it + /// wrote. Only a load that claimed the tab before the change is started again. + static func resumedAction( + for tab: QueryTab, + state: SelectedTabState, + owing change: TableFreshness.Change + ) -> SelectedTabAction { + action(for: tab, state: state, loadMisses: resumedLoadMisses(change, load: state.load)) + } + + private static func action(for tab: QueryTab, state: SelectedTabState, loadMisses: Bool) -> SelectedTabAction { + guard !state.holdsEdits, loadMisses else { return .noReload } + return tab.display.resultsViewMode == .structure ? .reloadBehindStructure : .reloadNow + } + + /// A load running as the change lands chose its metadata before the mark existed. + private static func landingLoadMisses(_ change: TableFreshness.Change, load: SelectedTabLoad) -> Bool { + switch load { + case .idle: + return true + case .scheduled, .extending: + return false + case .running(let startedAt): + return !TableFreshness.inFlightRead(startedAt: startedAt, covers: change) + } + } + + /// Once the change is marked, a load claimed after it was either started with the mark in place + /// or left to run when the change landed, and in that case the mark outlives what it misses. + private static func resumedLoadMisses(_ change: TableFreshness.Change, load: SelectedTabLoad) -> Bool { + switch load { + case .idle: + return true + case .scheduled, .extending: + return false + case .running(let startedAt): + return startedAt < change.at + } + } +} diff --git a/TablePro/Views/Import/RowImportSheet.swift b/TablePro/Views/Import/RowImportSheet.swift index 785af9d252..801419cf6d 100644 --- a/TablePro/Views/Import/RowImportSheet.swift +++ b/TablePro/Views/Import/RowImportSheet.swift @@ -91,6 +91,7 @@ struct RowImportSheet: View { @State private var importService: ImportService? @State private var importResult: PluginImportResult? + @State private var importedRows: DatabaseObjectChange? @State private var importError: (any Error)? @State private var showProgressDialog = false @State private var showSuccessDialog = false @@ -202,7 +203,9 @@ struct RowImportSheet: View { ) { showSuccessDialog = false isPresented = false - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) + if let importedRows { + AppCommands.shared.objectChanged.send(importedRows) + } } } .onChange(of: showErrorDialog) { isShowing in @@ -898,6 +901,12 @@ struct RowImportSheet: View { showProgressDialog = false importSucceeded = true importResult = result + importedRows = DatabaseObjectChange( + connectionId: connection.id, + scope: scope, + name: targetTable, + kind: .rows + ) showSuccessDialog = true } } catch is PluginImportCancellationError { diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 2574c29ece..d1f7a8621e 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -215,4 +215,8 @@ final class DataTabGridDelegate: DataGridViewDelegate { func dataGridDidReplaceAllRows() { tableViewCoordinator?.applyFullReplace() } + + func dataGridDidCloseCellOverlay() { + coordinator?.resumeDeferredTableRefresh() + } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index dc18c30a43..228d425e96 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -73,6 +73,27 @@ extension MainContentCoordinator { } } + /// The metadata a load commits with its rows after it fetched the table's definition itself, and + /// the same definition's columns in place of the set a definition change dropped. A load that + /// started before the latest definition change still commits what it read, and the tab stays + /// marked, but its columns never reach the cache the next query builds its select list from. + func adoptLoadedDefinition( + _ schema: FetchedTableSchema?, + of tableName: String?, + in scope: DatabaseScope, + readBy claim: TabExecutionClaim + ) -> ParsedSchemaMetadata? { + guard let schema else { return nil } + if let tableName, !schema.columns.isEmpty, + tabSessionRegistry.definitionIsCurrent(asOf: claim.startedAt, for: claim.tabId) { + schemaColumns.store( + SchemaColumnStore.Entry(fetchedColumns: schema.columns), + for: schemaColumnsKey(tableName, scope: scope) + ) + } + return parseSchemaMetadata(schema) + } + func columnsForVisibilityPicker(for tab: QueryTab, resultColumns: [String]) -> [String] { guard tab.tabType == .table, let tableName = tab.tableContext.tableName else { return resultColumns } return ColumnFetchScope.visibilityPickerColumns( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift index d74ef19556..bdb5bc8e4f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+DatabaseObjectTools.swift @@ -111,19 +111,14 @@ extension MainContentCoordinator { // MARK: - Object Changes - /// Brings every tab showing the changed object up to date, and no other tab. The selected one - /// reloads now, asking first if it holds edits; a background one drops its rows so it reloads - /// when it is next shown. + /// Brings every tab showing the changed object up to date, and no other tab. /// - /// The selected tab goes through `handleRefresh`, the entry Cmd+R uses, rather than straight to - /// the data reload: that one refuses to run while the Structure pane is in front and refreshes - /// the structure instead, and a tab excluded from the eviction loop for being selected would - /// otherwise keep its rows with nothing left to reload them. - func applyObjectChange( - _ change: DatabaseObjectChange, - hasPendingTableOps: Bool, - onDiscard: @escaping () -> Void - ) { + /// A rows or structure change goes through `refreshTableTabs`, which reloads the selected tab only + /// when nothing of the user's is in the way, leaves every other tab marked to reload when it is + /// next shown, and fetches the structure of each again. A rows change needs that too: a MongoDB + /// collection's columns are sampled from its documents, and a materialized view's refresh changes + /// what its **Indexes** tab reports. + func applyObjectChange(_ change: DatabaseObjectChange) { guard change.connectionId == connectionId else { return } let showing = tabManager.tabs.filter { tab in tab.tabType == .table && change.matches( @@ -132,16 +127,17 @@ extension MainContentCoordinator { schemaName: tab.tableContext.schemaName ) } - let selected = tabManager.selectedTab.flatMap { tab in showing.contains { $0.id == tab.id } ? tab : nil } + let showingIds = Set(showing.map(\.id)) + let selected = tabManager.selectedTab.flatMap { showingIds.contains($0.id) ? $0 : nil } switch change.kind { case .rows: - for tab in showing where tab.id != selected?.id { - evictReloadableTableRows(for: tab.id) - } - if selected != nil { - handleRefresh(hasPendingTableOps: hasPendingTableOps, onDiscard: onDiscard) - } + let rows = TableFreshness.Change(extent: .rows, at: change.changedAt) + refreshTableTabs(for: rows, excluding: change.originTabId) { showingIds.contains($0.id) } + case .structure: + forgetSchemaColumns(of: change) + let definition = TableFreshness.Change(extent: .definition, at: change.changedAt) + refreshTableTabs(for: definition, excluding: change.originTabId) { showingIds.contains($0.id) } case .comment: for tab in showing { tableMetadataCache.removeValue(forKey: tab.id) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index 54e5115442..3825099717 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -122,6 +122,7 @@ extension MainContentCoordinator { isEditable: Bool, metadata: ParsedSchemaMetadata?, hasSchema: Bool, + read: TableFreshness.Read, sql: String, connection conn: DatabaseConnection, isTruncated: Bool = false, @@ -143,6 +144,7 @@ extension MainContentCoordinator { isEditable: isEditable, metadata: metadata, hasSchema: hasSchema, + read: read, sql: sql, connection: conn, isTruncated: isTruncated, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index a968b11ac8..adbc506782 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -79,7 +79,7 @@ extension MainContentCoordinator { } } - private func reloadTableTab(at tabIndex: Int) { + func reloadTableTab(at tabIndex: Int) { stopExecution(for: tabManager.tabs[tabIndex].id) /// A refresh asks for the table as it is now, so the exact count the user requested earlier /// describes a table that may have moved on. Retiring it here is what lets the automatic diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 617f163c0c..d2cea97314 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -105,6 +105,9 @@ extension MainContentCoordinator { toolbarState.isResultsCollapsed = newTab.display.isResultsCollapsed syncQueryToolbarState(for: newTab) + /// Consumed once restored. The change manager is the selected tab's edits from here, and + /// a copy left on the tab went stale with the first undo: it went on reporting edits the + /// reader had taken back, and a reload put off for them never ran. let pendingState = newTab.pendingChanges if pendingState.hasChanges { changeManager.restoreState( @@ -115,6 +118,7 @@ extension MainContentCoordinator { generatedColumns: newRows.generatedColumns, rowMatchPolicy: newRows.rowMatchPolicy ) + tabManager.mutate(at: newIndex) { $0.pendingChanges = TabChangeSnapshot() } } else { changeManager.configureForTable( tableName: newTab.tableContext.tableName ?? "", diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift new file mode 100644 index 0000000000..1706bb8a51 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift @@ -0,0 +1,163 @@ +// +// MainContentCoordinator+TableRowsRefresh.swift +// TablePro +// +// What a window does with a change made to the rows or the definition of tables it shows. +// + +import Combine +import Foundation + +extension MainContentCoordinator { + /// Marks every addressed table tab stale, reloads the selected one now when the plan allows, and + /// has the structure of each fetched again. + /// + /// Nothing here asks a question. The change was made somewhere else, maybe in another window, so + /// a tab holding edits or an open cell overlay keeps them, and its rows, until they are gone, and + /// a structure holding staged edits keeps those. The tab that made the change is left out of the + /// rows plan only: it reloads its own rows, and its structure is fetched like any other. + func refreshTableTabs( + for change: TableFreshness.Change, + excluding originTabId: UUID? = nil, + where isAddressed: (QueryTab) -> Bool + ) { + let addressed = tabManager.tabs.filter { $0.tabType == .table && isAddressed($0) } + if change.extent == .definition { + forgetSchemaColumns(ofTabs: addressed) + } + let selectedState = selectedTabRefreshState + let plan = TableRowsRefreshPlan( + tabs: tabManager.tabs, + selectedTab: selectedState, + change: change, + excludingTabId: originTabId, + where: isAddressed + ) + let selectedId = tabManager.selectedTabId + for tabId in plan.staleTabIds { + tabSessionRegistry.recordChange(change, for: tabId) + if tabId != selectedId { + retireDerivedRowCountIfSet(forTab: tabId) + } + } + perform(plan.selectedTabAction, selectedState: selectedState) + refreshStructure(ofTabs: addressed) + } + + /// Runs the reload the selected tab put off while its cell overlay or its edits were in the way, + /// once they are gone. The plan is asked again rather than remembered, so whatever the user + /// started meanwhile, a new overlay, an edit or a load of the tab's own, still stands in the way. + /// A grid torn down with its window closes its overlay too, and that starts nothing. + func resumeDeferredTableRefresh() { + guard !isTearingDown, + let selectedState = selectedTabRefreshState, + let tab = tabManager.selectedTab, + tab.tabType == .table, + let change = tabSessionRegistry.pendingChange(for: tab.id) else { return } + perform( + TableRowsRefreshPlan.resumedAction(for: tab, state: selectedState, owing: change), + selectedState: selectedState + ) + } + + /// Edits go without a Discard too: the last one undone, a cell typed back to what it held, a + /// deleted row restored. Each leaves the change manager clean, which is when a reload put off for + /// them can run. Heard a turn later, because the manager publishes before it stores the value, and + /// a switch or a save that cleaned it has started the tab's own load by then. + func resumeWhenEditsClear() -> AnyCancellable { + changeManager.$hasChanges + .removeDuplicates() + .dropFirst() + .filter { !$0 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.resumeDeferredTableRefresh() } + } + + /// A change that names no single table reaches every table tab in its scope, and can have + /// changed the columns of a table no tab shows, so every column list cached for scoping goes. + func applyDataRefresh(_ request: DataRefreshRequest) { + guard request.connectionId == connectionId else { return } + schemaColumns.removeAll() + refreshTableTabs(for: TableFreshness.Change(extent: .definition, at: request.changedAt)) { + request.reaches(tabScope: scope(for: $0)) + } + } + + /// Structure the user is not editing is fetched again: now for the one on screen, on its next + /// mount for the rest. A structure holding staged edits keeps them and the old baseline, and is + /// fetched once they are applied, undone or discarded. + private func refreshStructure(ofTabs tabs: [QueryTab]) { + let selectedId = tabManager.selectedTabId + for tab in tabs { + guard let session = structureSessions[tab.id] else { continue } + let isOnScreen = tab.id == selectedId && tab.display.resultsViewMode == .structure + if isOnScreen, !session.changeManager.hasChanges, let refresh = structureActions?.refresh { + refresh() + } else { + session.markStructureStale() + } + } + } + + /// The named table's column list, cached for scoping a tab opened on it later. + func forgetSchemaColumns(of change: DatabaseObjectChange) { + schemaColumns.remove(schemaColumnsKey(change.name, scope: change.scope)) + } + + /// The columns cached for building a table's column-scoped query describe the definition before + /// the change, and a reload builds its SQL from them before it fetches anything: kept, they would + /// name a dropped column in the select list and leave out an added one. The reload's own + /// definition fetch stores the new set. + private func forgetSchemaColumns(ofTabs tabs: [QueryTab]) { + for tab in tabs { + guard let tableName = tab.tableContext.tableName else { continue } + schemaColumns.remove(schemaColumnsKey(tableName, scope: scope(for: tab))) + } + } + + private func perform( + _ action: TableRowsRefreshPlan.SelectedTabAction, + selectedState: TableRowsRefreshPlan.SelectedTabState? + ) { + switch action { + case .noReload: + break + case .reloadNow: + guard let index = tabManager.selectedTabIndex else { return } + reloadTableTab(at: index) + case .reloadBehindStructure: + guard let selectedId = selectedState?.id else { return } + if case .running = selectedState?.load { + stopExecution(for: selectedId) + cancelTableLoad(for: selectedId) + } + retireDerivedRowCountIfSet(forTab: selectedId) + lazyLoadCurrentTabIfNeeded() + } + } + + private var selectedTabRefreshState: TableRowsRefreshPlan.SelectedTabState? { + guard let tab = tabManager.selectedTab else { return nil } + let holdsEdits = changeManager.hasChanges + || tab.pendingChanges.hasChanges + || dataTabDelegate?.tableViewCoordinator?.hasOpenCellOverlay == true + return TableRowsRefreshPlan.SelectedTabState(id: tab.id, holdsEdits: holdsEdits, load: selectedTabLoad(tab.id)) + } + + private func selectedTabLoad(_ tabId: UUID) -> TableRowsRefreshPlan.SelectedTabLoad { + if let startedAt = tabExecution.startedAt(tabId) { + return .running(startedAt: startedAt) + } + if tableLoadTasks[tabId] != nil { + return .scheduled + } + return tabExecution.isBusy(tabId) ? .extending : .idle + } + + /// Written only when there is a count to retire, because every write fires `tabs`' `didSet`. + private func retireDerivedRowCountIfSet(forTab tabId: UUID) { + guard let pagination = tabManager.tabs.first(where: { $0.id == tabId })?.pagination, + pagination.totalRowCount != nil || pagination.isApproximateRowCount else { return } + tabManager.mutate(tabId: tabId) { $0.pagination.retireDerivedRowCount() } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 5cb8e50b21..3655df10cc 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -248,9 +248,9 @@ extension MainContentCoordinator { guard !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } let rows = tabSessionRegistry.tableRows(for: tab.id) - let isEvicted = tabSessionRegistry.isEvicted(tab.id) - let hasFreshRows = !rows.rows.isEmpty && !isEvicted - let hasExecuted = tab.execution.lastExecutedAt != nil && !isEvicted + let needsReload = tabSessionRegistry.isEvicted(tab.id) || tabSessionRegistry.isStale(tab.id) + let hasFreshRows = !rows.rows.isEmpty && !needsReload + let hasExecuted = tab.execution.lastExecutedAt != nil && !needsReload guard !hasFreshRows, !hasExecuted else { return false } let hasPendingEdits = changeManager.hasChanges || tab.pendingChanges.hasChanges diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 2f14a10231..0b1f19d874 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -902,14 +902,6 @@ final class MainContentCommandActions: ObservableObject { } guard !victims.isEmpty else { return true } - /// Every apply broadcasts a data refresh for its scope, and a mounted structure view on the - /// same database answers that by asking whether to discard its own staged edits. Mid-close - /// that question is both unanswerable and destructive, so the views stand down while this - /// runs. Scoped by `defer` rather than latched, because a flag with no exit is how this - /// area has gone deaf before. - coordinator.isApplyingStagedStructureEdits = true - defer { coordinator.isApplyingStagedStructureEdits = false } - for tab in victims { guard let session = coordinator.structureSessions[tab.id] else { continue } guard await session.applyStagedChanges(coordinator: coordinator).allowsClose else { @@ -1444,27 +1436,16 @@ final class MainContentCommandActions: ObservableObject { AppCommands.shared.refreshData .receive(on: RunLoop.main) .sink { [weak self] request in - guard let self, request.connectionId == self.connection.id, - let coordinator = self.coordinator else { return } - if request.reaches(tabScope: coordinator.selectedTabScope) { - coordinator.reloadActiveTableData( - hasPendingTableOps: self.hasPendingTableOps, - onDiscard: { [weak self] in self?.clearPendingTableOps() } - ) - } + guard let self, request.connectionId == self.connection.id else { return } + self.coordinator?.applyDataRefresh(request) } .store(in: &eventCancellables) AppCommands.shared.objectChanged .receive(on: RunLoop.main) .sink { [weak self] change in - guard let self, change.connectionId == self.connection.id, - let coordinator = self.coordinator else { return } - coordinator.applyObjectChange( - change, - hasPendingTableOps: self.hasPendingTableOps, - onDiscard: { [weak self] in self?.clearPendingTableOps() } - ) + guard let self, change.connectionId == self.connection.id else { return } + self.coordinator?.applyObjectChange(change) } .store(in: &eventCancellables) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 1bd5bd6ace..7314d6977b 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -197,12 +197,6 @@ final class MainContentCoordinator: ObservableObject { /// Direct reference to structure view actions — eliminates notification broadcasts weak var structureActions: StructureViewActionHandler? - /// Raised while a close is applying the staged structure edits of tabs it is about to close. - /// Each apply broadcasts a data refresh for its scope, and a mounted structure view on the same - /// database answers that by asking whether to discard its own staged edits, which mid-close is - /// a question the user cannot usefully answer. Scoped by the caller's `defer`, never latched. - @Published var isApplyingStagedStructureEdits = false - /// Direct reference to create-table view actions so the Save Changes menu /// (Cmd+S) routes to table creation. Set by `CreateTableView` on appear. weak var createTableActions: CreateTableActionHandler? @@ -378,6 +372,7 @@ final class MainContentCoordinator: ObservableObject { private var externalFileModCancellable: AnyCancellable? internal lazy var sourceFileDiskChangeMonitor = SourceFileDiskChangeMonitor(tabManager: tabManager) private var schemaSwitchCancellable: AnyCancellable? + private var clearedEditsCancellable: AnyCancellable? @Published var fileConflictRequest: FileConflictRequest? @@ -762,6 +757,7 @@ final class MainContentCoordinator: ObservableObject { self.queryExecutionCoordinator = QueryExecutionCoordinator(parent: self) self.paginationCoordinator = PaginationCoordinator(parent: self) self.rowEditingCoordinator = RowEditingCoordinator(parent: self) + clearedEditsCancellable = resumeWhenEditsClear() Self.lifecycleLogger.info( "[open] MainContentCoordinator.init done connId=\(connection.id, privacy: .public) elapsedMs=\(Int(Date().timeIntervalSince(initStart) * 1_000))" @@ -1290,6 +1286,7 @@ final class MainContentCoordinator: ObservableObject { let rowCap = statement.rowCap let (tableName, isEditable) = resolveTableEditability(tab: tab, sql: sql) + let needsDefinition = tabSessionRegistry.needsDefinition(tabId) let needsMetadataFetch = tableName.map { isEditable && !isMetadataCached(tabId: tabId, tableName: $0) } ?? false /// Captured now, while the result this decision was made against is still the active one. let cachedMetadata: ParsedSchemaMetadata? = needsMetadataFetch ? nil : ParsedSchemaMetadata.cached( @@ -1364,6 +1361,9 @@ final class MainContentCoordinator: ObservableObject { let inlineMeta = needsMetadataFetch ? QueryExecutor.inlineMetadata(from: fetchResult.resultColumnMeta, columns: fetchResult.columns) : nil + /// After a definition change the rows and the definition commit together, so the grid + /// never holds the new columns under the old keys, defaults or generated columns. + let definition = needsDefinition ? try? await schemaTask?.value : nil await MainActor.run { [weak self] in guard let self else { return } @@ -1388,6 +1388,7 @@ final class MainContentCoordinator: ObservableObject { traceApplyingResult(traceToken, tabId: tabId) + let definitionMetadata = adoptLoadedDefinition(definition, of: tableName, in: scope, readBy: claim) applyPhase1Result( tabId: tabId, columns: fetchResult.columns, @@ -1398,8 +1399,9 @@ final class MainContentCoordinator: ObservableObject { statusMessage: fetchResult.statusMessage, tableName: tableName, isEditable: isEditable, - metadata: inlineMeta ?? cachedMetadata, - hasSchema: false, + metadata: definitionMetadata ?? inlineMeta ?? cachedMetadata, + hasSchema: definitionMetadata != nil, + read: TableFreshness.Read(startedAt: claim.startedAt, includesDefinition: definitionMetadata != nil), sql: sql, connection: conn, isTruncated: fetchResult.isTruncated, diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 582e4a62f5..3460f04fcb 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -1127,6 +1127,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData refreshRowVisualState(at: row) } + var hasOpenCellOverlay: Bool { + overlayEditor?.isActive == true || overlayViewer?.isActive == true + } + func commitActiveCellEdit() { overlayEditor?.dismiss(commit: true) overlayViewer?.dismiss() diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index 0acb4b539b..05b4351375 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -44,6 +44,9 @@ protocol DataGridViewDelegate: AnyObject { func dataGridAttach(tableViewCoordinator: TableViewCoordinator) func dataGridDisplayOrderChanged() func dataGridDisplayFormatChanged() + /// A cell editor or viewer closed. Told on the turn after, so an editor that closed with a + /// commit has recorded its edit, which it does after it removes itself. + func dataGridDidCloseCellOverlay() /// The menu this particular cell should offer, when the list depends on the row rather than /// only on the column. /// @@ -59,6 +62,7 @@ protocol DataGridViewDelegate: AnyObject { extension DataGridViewDelegate { func dataGridDisplayOrderChanged() {} func dataGridDisplayFormatChanged() {} + func dataGridDidCloseCellOverlay() {} func dataGridMenuOptions(forRow row: Int, columnIndex: Int) -> [GridMenuOption]? { nil } func dataGridCheckboxState(row: Int, column: Int) -> Bool? { nil } func dataGridSetCheckbox(_ isOn: Bool, rows: IndexSet, column: Int) {} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 5c59aafb8d..fb8266e6d8 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -114,9 +114,7 @@ extension TableViewCoordinator { } guard let editor = overlayEditor else { return } - editor.onRemove = { [weak self] in - self?.flushPendingCellPresentationRefresh() - } + observeRemoval(of: editor) editor.onCommit = { [weak self] row, columnIndex, newValue in self?.commitCellEdit(row: row, columnIndex: columnIndex, newValue: newValue) } @@ -132,13 +130,22 @@ extension TableViewCoordinator { overlayViewer = CellOverlayViewer() } guard let viewer = overlayViewer else { return } - viewer.onRemove = { [weak self] in - self?.flushPendingCellPresentationRefresh() - } + observeRemoval(of: viewer) overlayEditor?.dismiss(commit: false) viewer.show(in: tableView, row: row, column: column, columnIndex: columnIndex, value: value) } + /// Cell presentation work and a reload put off while the overlay was open both wait for it to + /// close. + func observeRemoval(of overlay: CellOverlayBase) { + overlay.onRemove = { [weak self] in + self?.flushPendingCellPresentationRefresh() + Task { @MainActor [weak self] in + self?.delegate?.dataGridDidCloseCellOverlay() + } + } + } + /// The cell cursor moves with the editor, through the same `focusCell` the grid's own Tab uses. /// Selecting the row alone left the cursor on the column the editor came from, so closing the /// editor put it back where the editing was not, and it never scrolled the target row into diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 03e973b502..e6a5917c57 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -576,7 +576,8 @@ struct CreateTableView: View { databaseType: connection.type, scope: scope ) + let created = DatabaseObjectChange(connectionId: connection.id, scope: scope, name: createdName, kind: .rows) coordinator?.openTableTab(createdName, schema: scope.schema, database: scope.database.nilIfEmpty) - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) + AppCommands.shared.objectChanged.send(created) } } diff --git a/TablePro/Views/Structure/StructureEditingSession+Apply.swift b/TablePro/Views/Structure/StructureEditingSession+Apply.swift index 9a32c9d5b2..3dad77c883 100644 --- a/TablePro/Views/Structure/StructureEditingSession+Apply.swift +++ b/TablePro/Views/Structure/StructureEditingSession+Apply.swift @@ -136,10 +136,11 @@ internal extension StructureEditingSession { try await DatabaseManager.shared.executeSchemaChanges( statements, databaseType: connection.type, - scope: scope + scope: scope, + table: tableName ) changeManager.discardChanges() - tabData.markAllStale() + markEveryTabStale() hasLoaded = false lastAppliedAt = Date() isApplying = false @@ -231,7 +232,7 @@ internal extension StructureEditingSession { ) changeManager.discardChanges() - tabData.markAllStale() + markEveryTabStale() hasLoaded = false lastAppliedAt = Date() isApplying = false @@ -239,7 +240,14 @@ internal extension StructureEditingSession { if let clearTarget = coordinator?.selectedColumnLayoutClearTarget() { coordinator?.clearColumnLayout(clearTarget) } - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) + AppCommands.shared.objectChanged.send( + DatabaseObjectChange( + connectionId: connection.id, + scope: prepared.scope, + name: prepared.tableName, + kind: .structure + ) + ) CatalogChangeService.post( .changed(CatalogChange(connectionId: connection.id, database: prepared.scope.database, kinds: .tables)) ) diff --git a/TablePro/Views/Structure/StructureEditingSession.swift b/TablePro/Views/Structure/StructureEditingSession.swift index f5f921104e..ef5cf4f1ae 100644 --- a/TablePro/Views/Structure/StructureEditingSession.swift +++ b/TablePro/Views/Structure/StructureEditingSession.swift @@ -96,6 +96,20 @@ internal final class StructureEditingSession: ObservableObject { StructureTabAvailability.tabs(for: connection.type, serverSupport: serverSupport) } + /// What a mount fetches: the sub-tabs the change manager is baselined from, then the one the + /// user left selected. A mount does not change the selection, so nothing else fetches that one, + /// and after `markStructureStale` or an apply it would go on showing the object as it was. + internal var tabsFetchedOnMount: [StructureTab] { + var tabs: [StructureTab] = [.columns, .indexes, .foreignKeys] + if availableTabs.contains(.checkConstraints) { + tabs.append(.checkConstraints) + } + if !tabs.contains(selectedTab) { + tabs.append(selectedTab) + } + return tabs + } + /// What the bottom bar offers while this tab is showing its structure. /// /// Keyed by tab through the session, so two structure tabs cannot answer for each other. The @@ -153,6 +167,37 @@ internal final class StructureEditingSession: ObservableObject { appliedVersion += 1 } + /// A change made elsewhere while edits were staged here. Fetching it would re-baseline the change + /// manager and discard the edits without asking, so it waits for them to go. An apply fetches + /// everything again, which answers it; an undo or a discard leaves it to `settleOwedRefetch`. + internal private(set) var owesRefetch = false + + /// The object changed outside this editor, so the next mount fetches it again. A session holding + /// staged edits keeps them and the baseline they were made against, and owes the fetch instead. + internal func markStructureStale() { + guard !changeManager.hasChanges else { + owesRefetch = true + return + } + markEveryTabStale() + hasLoaded = false + } + + /// Answers a change owed from while edits were staged, once they are gone. True when it did, and + /// the structure on screen, if any, has to be fetched again now. + @discardableResult + internal func settleOwedRefetch() -> Bool { + guard owesRefetch, !changeManager.hasChanges else { return false } + markStructureStale() + return true + } + + /// Every sub-tab is fetched again from here, which answers an owed change too. + internal func markEveryTabStale() { + tabData.markAllStale() + owesRefetch = false + } + internal func reloadConcurrentRefreshAvailability( provider: any ScopedMetadataProviding = DatabaseManager.shared ) async { diff --git a/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift b/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift index 5d6a354371..7630be4401 100644 --- a/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift +++ b/TablePro/Views/Structure/TableStructureView+ColumnReorder.swift @@ -123,7 +123,9 @@ extension TableStructureView { if let clearTarget { coordinator?.clearColumnLayout(clearTarget) } - AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) + AppCommands.shared.objectChanged.send( + DatabaseObjectChange(connectionId: connection.id, scope: prepared.scope, name: tableName, kind: .structure) + ) CatalogChangeService.post( .changed(CatalogChange(connectionId: connection.id, database: prepared.scope.database, kinds: .tables)) ) diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift index 52e003c112..f7212cbd53 100644 --- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift @@ -23,6 +23,7 @@ extension TableStructureView { /// A genuine refresh still refetches, through `onRefreshData`, which asks before discarding. @Sendable func loadInitialData() async { + session.settleOwedRefetch() guard !session.hasLoaded else { isInitialLoading = false isLoading = false @@ -30,10 +31,8 @@ extension TableStructureView { return } await loadColumns() - await loadTabDataIfNeeded(.indexes) - await loadTabDataIfNeeded(.foreignKeys) - if session.availableTabs.contains(.checkConstraints) { - await loadTabDataIfNeeded(.checkConstraints) + for tab in session.tabsFetchedOnMount where tab != .columns { + await loadTabDataIfNeeded(tab) } loadSchemaForEditing() session.hasLoaded = true @@ -176,7 +175,7 @@ extension TableStructureView { } private func reloadAllTabs() async { - tabData.markAllStale() + session.markEveryTabStale() session.gridDelegate.referenceMenus.invalidateTableLists() partsReloadToken += 1 await reloadCoreTabs() diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 90f4cebdd8..cca813148f 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -267,6 +267,9 @@ struct TableStructureView: View { .onChange(of: structureChangeManager.hasChanges) { newValue in coordinator?.toolbarState.hasStructureChanges = newValue updateGridDelegate() + if !newValue, session.settleOwedRefetch() { + Task { await loadInitialData() } + } } .onChange(of: session.appliedVersion) { _ in Task { await refreshAfterApply() } @@ -280,15 +283,6 @@ struct TableStructureView: View { // manager but the grid never displays it. displayVersion += 1 } - .onReceive(AppCommands.shared.refreshData) { request in - guard request.connectionId == connection.id else { return } - guard request.reaches(tabScope: scope) else { return } - /// A close applying another tab's staged edits broadcasts a refresh for the same - /// database. Answering it here would ask this tab whether to discard the edits the user - /// has just asked to save, in a sheet queued behind the close. - guard coordinator?.isApplyingStagedStructureEdits != true else { return } - onRefreshData() - } } // MARK: - Toolbar diff --git a/TableProTests/Core/Coordinators/ResolvedPrimaryKeysTests.swift b/TableProTests/Core/Coordinators/ResolvedPrimaryKeysTests.swift new file mode 100644 index 0000000000..270a9dd20e --- /dev/null +++ b/TableProTests/Core/Coordinators/ResolvedPrimaryKeysTests.swift @@ -0,0 +1,69 @@ +// +// ResolvedPrimaryKeysTests.swift +// TableProTests +// +// A reload after a structure change carried the tab's old primary key into the new result, so the +// next edit was written against a key the table no longer had. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct ResolvedPrimaryKeysTests { + @Test("The keys the result reports win over everything else") + func reportedKeysWin() { + let keys = QueryExecutionCoordinator.resolvedPrimaryKeys( + reported: ["code"], + engineDefault: "_id", + previous: ["id"], + definitionChanged: true + ) + + #expect(keys == ["code"]) + } + + @Test("An engine's own key answers when the result reports none") + func engineDefaultAnswersNextEvenAfterADefinitionChange() { + let keys = QueryExecutionCoordinator.resolvedPrimaryKeys( + reported: [], + engineDefault: "_id", + previous: ["id"], + definitionChanged: true + ) + + #expect(keys == ["_id"]) + } + + @Test("The tab's keys carry over while the definition is unchanged") + func previousKeysCarryOverWhileTheDefinitionHolds() { + let keys = QueryExecutionCoordinator.resolvedPrimaryKeys( + reported: nil, + engineDefault: nil, + previous: ["id"], + definitionChanged: false + ) + + #expect(keys == ["id"]) + } + + @Test("The tab's keys never carry over a definition change, so a dropped key stays dropped") + func previousKeysNeverCarryOverADefinitionChange() { + let droppedKey = QueryExecutionCoordinator.resolvedPrimaryKeys( + reported: [], + engineDefault: nil, + previous: ["id"], + definitionChanged: true + ) + let unreadDefinition = QueryExecutionCoordinator.resolvedPrimaryKeys( + reported: nil, + engineDefault: nil, + previous: ["id"], + definitionChanged: true + ) + + #expect(droppedKey.isEmpty) + #expect(unreadDefinition.isEmpty) + } +} diff --git a/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift b/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift index 8dfa1c0450..fd5133dc82 100644 --- a/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerSchemaChangeRoutingTests.swift @@ -71,6 +71,8 @@ private final class SchemaRoutingDriver: SchemaRoutingBaseDriver, PluginDatabase self.schema = schema } + func executeDocumentWrite(_ write: PluginDocumentWrite) async throws {} + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { "ALTER TABLE \(qualified(table)) ADD COLUMN `\(column.name)` \(column.dataType)" } @@ -166,7 +168,12 @@ struct DatabaseManagerSchemaChangeRoutingTests { changes: changes, scope: scope ) - try await DatabaseManager.shared.executeSchemaChanges(statements, databaseType: databaseType, scope: scope) + try await DatabaseManager.shared.executeSchemaChanges( + statements, + databaseType: databaseType, + scope: scope, + table: "orders" + ) } private static func tearDown(_ connections: DatabaseConnection...) { @@ -334,19 +341,17 @@ struct DatabaseManagerSchemaChangeRoutingTests { #expect(driver.executedQueries.isEmpty) } - @Test("A save broadcasts a refresh scoped to the edited tab, not to the browse cursor") - func schemaChangeBroadcastsTheEditedScope() async throws { + @Test("A save announces a structure change to the edited table in the edited tab's scope, not the browse cursor's") + func schemaChangeAnnouncesTheEditedTable() async throws { let (connection, _) = Self.makeSession( savedDatabase: "analytics", browseDatabase: "inventory" ) defer { Self.tearDown(connection) } - let recorder = RefreshRequestRecorder() - let cancellable = AppCommands.shared.refreshData.sink { request in - recorder.record(request) - } - defer { cancellable.cancel() } + let recorder = BroadcastRecorder() + let cancellables = recorder.observe(connectionId: connection.id) + defer { cancellables.forEach { $0.cancel() } } let scope = try #require(Self.makeScope(connection, database: "orders")) _ = try await Self.seedPooledDriver(connection, scope: scope) @@ -356,10 +361,35 @@ struct DatabaseManagerSchemaChangeRoutingTests { scope: scope ) - let broadcast = recorder.requests.filter { $0.connectionId == connection.id } - #expect(broadcast.count == 1) - #expect(broadcast.first?.scope == scope) - #expect(broadcast.first?.scope?.database == "orders") + #expect(recorder.objectChanges.map(\.announced) == [ + .init(scope: scope, name: "orders", kind: .structure, originTabId: nil) + ]) + #expect(recorder.refreshRequests.isEmpty) + } + + @Test("An inserted document announces its collection as a rows change and nothing broader") + func documentWriteAnnouncesItsCollection() async throws { + let (connection, _) = Self.makeSession(savedDatabase: "probe") + defer { Self.tearDown(connection) } + + let recorder = BroadcastRecorder() + let cancellables = recorder.observe(connectionId: connection.id) + defer { cancellables.forEach { $0.cancel() } } + + let scope = try #require(Self.makeScope(connection, database: "probe")) + try await DatabaseManager.shared.executeDocumentWrite( + PluginDocumentWrite(table: "people", schema: nil, operation: .insert(document: "{}")), + statement: "db.people.insertOne({})", + databaseType: .mysql, + scope: scope, + operationDescription: "Insert Document", + gate: AlwaysAllowGate() + ) + + #expect(recorder.objectChanges.map(\.announced) == [ + .init(scope: scope, name: "people", kind: .rows, originTabId: nil) + ]) + #expect(recorder.refreshRequests.isEmpty) } private static func invoicesDefinition() -> PluginCreateTableDefinition { @@ -445,11 +475,35 @@ struct DatabaseManagerSchemaChangeRoutingTests { } } -@MainActor -private final class RefreshRequestRecorder { - private(set) var requests: [DataRefreshRequest] = [] +/// A change without the moment it was made, which no two sends share. +private struct AnnouncedChange: Equatable { + let scope: DatabaseScope + let name: String + let kind: DatabaseObjectChange.Kind + let originTabId: UUID? +} - func record(_ request: DataRefreshRequest) { - requests.append(request) +private extension DatabaseObjectChange { + var announced: AnnouncedChange { + AnnouncedChange(scope: scope, name: name, kind: kind, originTabId: originTabId) + } +} + +/// Filtered by connection, because the subjects are process-wide and other suites send on them +/// while these run. +@MainActor +private final class BroadcastRecorder { + private(set) var objectChanges: [DatabaseObjectChange] = [] + private(set) var refreshRequests: [DataRefreshRequest] = [] + + func observe(connectionId: UUID) -> [AnyCancellable] { + [ + AppCommands.shared.objectChanged + .filter { $0.connectionId == connectionId } + .sink { [weak self] in self?.objectChanges.append($0) }, + AppCommands.shared.refreshData + .filter { $0.connectionId == connectionId } + .sink { [weak self] in self?.refreshRequests.append($0) } + ] } } diff --git a/TableProTests/Models/Query/TabSessionRegistryTests.swift b/TableProTests/Models/Query/TabSessionRegistryTests.swift index 60723b0fbb..f43ebbe62e 100644 --- a/TableProTests/Models/Query/TabSessionRegistryTests.swift +++ b/TableProTests/Models/Query/TabSessionRegistryTests.swift @@ -237,4 +237,91 @@ struct TabSessionRegistryTests { #expect(session.dataRevision == afterEvict) #expect(session.tableRows.index(of: .existing(0)) == nil) } + + // MARK: - Stale mark + + private static func rowsChange(at instant: ContinuousClock.Instant = .now) -> TableFreshness.Change { + TableFreshness.Change(extent: .rows, at: instant) + } + + @Test("Marking a tab stale keeps its rows, where evicting drops them") + func markingStaleKeepsTheRows() { + let registry = TabSessionRegistry() + let stale = UUID() + let evicted = UUID() + registry.setTableRows(makeRows(["a", "b", "c"]), for: stale) + registry.setTableRows(makeRows(["a", "b", "c"]), for: evicted) + + registry.recordChange(Self.rowsChange(), for: stale) + registry.evict(for: evicted) + + #expect(registry.isStale(stale)) + #expect(registry.tableRows(for: stale).rows.count == 3) + #expect(!registry.isEvicted(stale)) + #expect(registry.tableRows(for: evicted).rows.isEmpty) + } + + @Test("A tab whose last result was empty can be marked stale, which eviction refuses") + func emptyResultCanBeMarkedStale() { + let registry = TabSessionRegistry() + let tabId = UUID() + registry.setTableRows(makeRows([]), for: tabId) + + registry.evict(for: tabId) + registry.recordChange(Self.rowsChange(), for: tabId) + + #expect(!registry.isEvicted(tabId)) + #expect(registry.isStale(tabId)) + } + + @Test("Only a fetched result clears the stale mark, not rows the tab already held nor late metadata") + func onlyAFetchedResultClearsTheStaleMark() { + let registry = TabSessionRegistry() + let tabId = UUID() + registry.setTableRows(makeRows(["a"]), for: tabId) + let changedAt = ContinuousClock.now + registry.recordChange(Self.rowsChange(at: changedAt), for: tabId) + + registry.setTableRows(makeRows(["a"]), for: tabId) + registry.updateTableRows(for: tabId) { _ in .none } + #expect(registry.isStale(tabId)) + + registry.recordRead( + TableFreshness.Read(startedAt: changedAt.advanced(by: .milliseconds(1)), includesDefinition: false), + for: tabId + ) + #expect(!registry.isStale(tabId)) + } + + @Test("A result whose query claimed the tab before the change keeps the tab stale when it commits") + func aReadThatStartedBeforeTheChangeKeepsTheMark() { + let registry = TabSessionRegistry() + let tabId = UUID() + let claimedAt = ContinuousClock.now + registry.setTableRows(makeRows(["a"]), for: tabId) + registry.recordChange(Self.rowsChange(at: claimedAt.advanced(by: .milliseconds(5))), for: tabId) + + registry.recordRead(TableFreshness.Read(startedAt: claimedAt, includesDefinition: false), for: tabId) + + #expect(registry.isStale(tabId)) + } + + @Test("A definition change asks the next load to fetch the definition until one that did commits") + func aDefinitionChangeNeedsARead() { + let registry = TabSessionRegistry() + let tabId = UUID() + let changedAt = ContinuousClock.now + registry.setTableRows(makeRows(["a"]), for: tabId) + registry.recordChange(TableFreshness.Change(extent: .definition, at: changedAt), for: tabId) + #expect(registry.needsDefinition(tabId)) + + let later = changedAt.advanced(by: .milliseconds(1)) + registry.recordRead(TableFreshness.Read(startedAt: later, includesDefinition: false), for: tabId) + #expect(registry.needsDefinition(tabId)) + #expect(registry.isStale(tabId)) + + registry.recordRead(TableFreshness.Read(startedAt: later, includesDefinition: true), for: tabId) + #expect(!registry.needsDefinition(tabId)) + #expect(!registry.isStale(tabId)) + } } diff --git a/TableProTests/Models/Query/TableFreshnessTests.swift b/TableProTests/Models/Query/TableFreshnessTests.swift new file mode 100644 index 0000000000..da668a33e5 --- /dev/null +++ b/TableProTests/Models/Query/TableFreshnessTests.swift @@ -0,0 +1,145 @@ +// +// TableFreshnessTests.swift +// TableProTests +// +// A load that was already running when its table changed cleared the change's mark when it +// committed, so the tab counted as fresh while it showed rows read before the write. +// + +import Foundation +@testable import TablePro +import Testing + +struct TableFreshnessTests { + private let base = ContinuousClock.now + + private func at(_ milliseconds: Int) -> ContinuousClock.Instant { + base.advanced(by: .milliseconds(milliseconds)) + } + + private func rows(at milliseconds: Int) -> TableFreshness.Change { + TableFreshness.Change(extent: .rows, at: at(milliseconds)) + } + + private func definition(at milliseconds: Int) -> TableFreshness.Change { + TableFreshness.Change(extent: .definition, at: at(milliseconds)) + } + + private func read(startedAt milliseconds: Int, includesDefinition: Bool = false) -> TableFreshness.Read { + TableFreshness.Read(startedAt: at(milliseconds), includesDefinition: includesDefinition) + } + + @Test("A read that started after the change clears it") + func aLaterReadClearsTheChange() { + var freshness = TableFreshness() + freshness.record(rows(at: 10)) + + freshness.record(read(startedAt: 20)) + + #expect(!freshness.isStale) + } + + @Test("A read that started before the change leaves it in place") + func anEarlierReadLeavesTheChange() { + var freshness = TableFreshness() + freshness.record(rows(at: 10)) + + freshness.record(read(startedAt: 5)) + + #expect(freshness.isStale) + } + + @Test("A read between two changes covers only the first, so the tab stays stale") + func aReadBetweenTwoChangesLeavesTheSecond() { + var freshness = TableFreshness() + freshness.record(rows(at: 10)) + freshness.record(rows(at: 30)) + + freshness.record(read(startedAt: 20)) + #expect(freshness.isStale) + + freshness.record(read(startedAt: 40)) + #expect(!freshness.isStale) + } + + @Test("A definition change is cleared only by a later read that fetched the definition") + func aDefinitionNeedsARead() { + var freshness = TableFreshness() + freshness.record(definition(at: 10)) + #expect(freshness.needsDefinition) + + freshness.record(read(startedAt: 20)) + #expect(freshness.needsDefinition) + #expect(freshness.isStale) + + freshness.record(read(startedAt: 5, includesDefinition: true)) + #expect(freshness.needsDefinition) + + freshness.record(read(startedAt: 20, includesDefinition: true)) + #expect(!freshness.needsDefinition) + #expect(!freshness.isStale) + } + + @Test("A rows change leaves the definition alone") + func aRowsChangeNeedsNoDefinition() { + var freshness = TableFreshness() + + freshness.record(rows(at: 10)) + + #expect(freshness.isStale) + #expect(!freshness.needsDefinition) + } + + @Test("A query already running covers a rows change only when it claimed the tab after it, and never a definition") + func anInFlightReadCoversOnlyALaterRowsChange() { + #expect(TableFreshness.inFlightRead(startedAt: at(20), covers: rows(at: 10))) + #expect(!TableFreshness.inFlightRead(startedAt: at(5), covers: rows(at: 10))) + #expect(!TableFreshness.inFlightRead(startedAt: at(20), covers: definition(at: 10))) + } + + @Test("A read reports whether it answered a change to the rows") + func aReadReportsWhetherItAnsweredTheRows() { + var freshness = TableFreshness() + let onAFreshTab = freshness.record(read(startedAt: 0)) + freshness.record(rows(at: 10)) + let startedBefore = freshness.record(read(startedAt: 5)) + let startedAfter = freshness.record(read(startedAt: 20)) + let afterItWasAnswered = freshness.record(read(startedAt: 30)) + + #expect(!onAFreshTab) + #expect(!startedBefore) + #expect(startedAfter) + #expect(!afterItWasAnswered) + } + + @Test("The change a read still owes carries the latest instant, as a definition while one is outstanding") + func pendingChangeIsTheLatestOutstandingOne() { + var freshness = TableFreshness() + #expect(freshness.pendingChange == nil) + + freshness.record(definition(at: 10)) + freshness.record(rows(at: 30)) + #expect(freshness.pendingChange == definition(at: 30)) + + freshness.record(read(startedAt: 40)) + #expect(freshness.pendingChange == definition(at: 10)) + + freshness.record(read(startedAt: 40, includesDefinition: true)) + #expect(freshness.pendingChange == nil) + + freshness.record(rows(at: 50)) + #expect(freshness.pendingChange == rows(at: 50)) + } + + @Test("A definition is current only when the read that fetched it started after the last definition change") + func aDefinitionIsCurrentOnlyAfterTheLastChange() { + var freshness = TableFreshness() + #expect(freshness.definitionIsCurrent(asOf: at(0))) + + freshness.record(definition(at: 10)) + freshness.record(rows(at: 30)) + + #expect(!freshness.definitionIsCurrent(asOf: at(5))) + #expect(freshness.definitionIsCurrent(asOf: at(20))) + } +} diff --git a/TableProTests/Models/Query/TableRowsRefreshPlanTests.swift b/TableProTests/Models/Query/TableRowsRefreshPlanTests.swift new file mode 100644 index 0000000000..8e54d7d73f --- /dev/null +++ b/TableProTests/Models/Query/TableRowsRefreshPlanTests.swift @@ -0,0 +1,264 @@ +// +// TableRowsRefreshPlanTests.swift +// TableProTests +// +// A change to one table used to reload whichever tab each window had in front, whatever table it +// showed, and asked that tab to discard its edits first, while the background tabs that did show +// the table kept their old rows. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct TableRowsRefreshPlanTests { + private func tableTab(_ name: String, mode: ResultsViewMode = .data) -> QueryTab { + var tab = QueryTab(title: name, query: "SELECT 1", tabType: .table, tableName: name) + tab.display.resultsViewMode = mode + return tab + } + + private let changedAt = ContinuousClock.now + + private var rowsChange: TableFreshness.Change { + TableFreshness.Change(extent: .rows, at: changedAt) + } + + private func state( + _ tab: QueryTab, + holdsEdits: Bool = false, + load: TableRowsRefreshPlan.SelectedTabLoad = .idle + ) -> TableRowsRefreshPlan.SelectedTabState { + TableRowsRefreshPlan.SelectedTabState(id: tab.id, holdsEdits: holdsEdits, load: load) + } + + private func showsUsers(_ tab: QueryTab) -> Bool { + tab.tableContext.tableName == "users" + } + + @Test("Marks every table tab on the table and nothing else, query tabs included") + func marksOnlyTheAddressedTableTabs() { + let first = tableTab("users") + let second = tableTab("users") + let orders = tableTab("orders") + let query = QueryTab(title: "users", query: "SELECT * FROM users", tabType: .query, tableName: "users") + + let plan = TableRowsRefreshPlan( + tabs: [first, orders, query, second], + selectedTab: state(orders), + change: rowsChange, + where: showsUsers + ) + + #expect(plan.staleTabIds == [first.id, second.id]) + #expect(plan.selectedTabAction == .noReload) + } + + @Test("Reloads the selected tab now when it is on the table and nothing of the user's is in it") + func reloadsACleanSelectedTab() { + let selected = tableTab("users") + + let plan = TableRowsRefreshPlan(tabs: [selected], selectedTab: state(selected), change: rowsChange, where: showsUsers) + + #expect(plan.staleTabIds == [selected.id]) + #expect(plan.selectedTabAction == .reloadNow) + } + + @Test("Leaves a selected tab holding edits or an open cell editor marked, and reloads nothing") + func leavesASelectedTabWithEditsMarked() { + let selected = tableTab("users") + + let plan = TableRowsRefreshPlan( + tabs: [selected], + selectedTab: state(selected, holdsEdits: true), + change: rowsChange, + where: showsUsers + ) + + #expect(plan.staleTabIds == [selected.id]) + #expect(plan.selectedTabAction == .noReload) + } + + @Test("Leaves a load that claimed the tab after the change to finish, since it reads what was written") + func leavesALoadThatStartedAfterTheChange() { + let selected = tableTab("users") + + let plan = TableRowsRefreshPlan( + tabs: [selected], + selectedTab: state(selected, load: .running(startedAt: changedAt.advanced(by: .milliseconds(1)))), + change: rowsChange, + where: showsUsers + ) + + #expect(plan.staleTabIds == [selected.id]) + #expect(plan.selectedTabAction == .noReload) + } + + @Test("Starts again a load that claimed the tab before the change, whose rows are out of date") + func restartsALoadThatStartedBeforeTheChange() { + let selected = tableTab("users") + let structure = tableTab("users", mode: .structure) + let running = TableRowsRefreshPlan.SelectedTabLoad.running(startedAt: changedAt.advanced(by: .milliseconds(-1))) + + let plan = TableRowsRefreshPlan( + tabs: [selected], + selectedTab: state(selected, load: running), + change: rowsChange, + where: showsUsers + ) + let behindStructure = TableRowsRefreshPlan( + tabs: [structure], + selectedTab: state(structure, load: running), + change: rowsChange, + where: showsUsers + ) + + #expect(plan.selectedTabAction == .reloadNow) + #expect(behindStructure.selectedTabAction == .reloadBehindStructure) + } + + @Test("Starts again any running load after a definition change, since it chose its metadata before the mark") + func restartsAnyRunningLoadAfterADefinitionChange() { + let selected = tableTab("users") + + let plan = TableRowsRefreshPlan( + tabs: [selected], + selectedTab: state(selected, load: .running(startedAt: changedAt.advanced(by: .milliseconds(1)))), + change: TableFreshness.Change(extent: .definition, at: changedAt), + where: showsUsers + ) + + #expect(plan.selectedTabAction == .reloadNow) + } + + @Test("Leaves a scheduled load, which has not read yet, and Fetch All, which extends rows it does not own") + func leavesAScheduledOrExtendingLoad() { + let selected = tableTab("users") + let definition = TableFreshness.Change(extent: .definition, at: changedAt) + + for load in [TableRowsRefreshPlan.SelectedTabLoad.scheduled, .extending] { + for change in [rowsChange, definition] { + let plan = TableRowsRefreshPlan( + tabs: [selected], + selectedTab: state(selected, load: load), + change: change, + where: showsUsers + ) + + #expect(plan.staleTabIds == [selected.id]) + #expect(plan.selectedTabAction == .noReload, "\(load) \(change.extent)") + } + } + } + + @Test("Starts nothing again for a selected tab holding edits, whatever is running") + func leavesARunningLoadOnATabWithEdits() { + let selected = tableTab("users") + + let plan = TableRowsRefreshPlan( + tabs: [selected], + selectedTab: state( + selected, + holdsEdits: true, + load: .running(startedAt: changedAt.advanced(by: .milliseconds(-1))) + ), + change: rowsChange, + where: showsUsers + ) + + #expect(plan.selectedTabAction == .noReload) + } + + @Test("Reloads the rows behind a selected tab showing its structure") + func reloadsBehindTheStructureView() { + let selected = tableTab("users", mode: .structure) + + let plan = TableRowsRefreshPlan(tabs: [selected], selectedTab: state(selected), change: rowsChange, where: showsUsers) + + #expect(plan.staleTabIds == [selected.id]) + #expect(plan.selectedTabAction == .reloadBehindStructure) + } + + @Test("Reloads a selected tab drawing its rows as JSON or a chart like one drawing a grid") + func reloadsEveryRowsMode() { + for mode in [ResultsViewMode.json, .chart, .map] { + let selected = tableTab("users", mode: mode) + + let plan = TableRowsRefreshPlan(tabs: [selected], selectedTab: state(selected), change: rowsChange, where: showsUsers) + + #expect(plan.selectedTabAction == .reloadNow, "mode \(mode)") + } + } + + @Test("Leaves out the tab that made the change, selected or not") + func leavesOutTheOriginTab() { + let origin = tableTab("users") + let duplicate = tableTab("users") + + let plan = TableRowsRefreshPlan( + tabs: [origin, duplicate], + selectedTab: state(origin), + change: rowsChange, + excludingTabId: origin.id, + where: showsUsers + ) + + #expect(plan.staleTabIds == [duplicate.id]) + #expect(plan.selectedTabAction == .noReload) + } + + @Test("A window with no tab selected still marks its tabs") + func marksWithNoSelection() { + let background = tableTab("users") + + let plan = TableRowsRefreshPlan(tabs: [background], selectedTab: nil, change: rowsChange, where: showsUsers) + + #expect(plan.staleTabIds == [background.id]) + #expect(plan.selectedTabAction == .noReload) + } + + @Test("A change the selected tab owes is acted on once the edits in the way are gone, and not before") + func anOwedChangeWaitsForTheEditsInTheWay() { + let selected = tableTab("users") + let behindStructure = tableTab("users", mode: .structure) + + #expect( + TableRowsRefreshPlan.resumedAction(for: selected, state: state(selected, holdsEdits: true), owing: rowsChange) + == .noReload + ) + #expect(TableRowsRefreshPlan.resumedAction(for: selected, state: state(selected), owing: rowsChange) == .reloadNow) + #expect( + TableRowsRefreshPlan.resumedAction(for: behindStructure, state: state(behindStructure), owing: rowsChange) + == .reloadBehindStructure + ) + #expect( + TableRowsRefreshPlan.resumedAction(for: selected, state: state(selected, load: .scheduled), owing: rowsChange) + == .noReload + ) + } + + /// A tab switch or a save that cleans the change manager has started the tab's own load by the + /// time the resume is asked. That load claimed the tab after the change, with the mark in place, + /// so starting it again would run the same query twice, a definition change included. + @Test("An owed change leaves the tab's own load running when it claimed the tab after the change") + func anOwedChangeLeavesALoadStartedAfterIt() { + let selected = tableTab("users") + let definition = TableFreshness.Change(extent: .definition, at: changedAt) + let after = TableRowsRefreshPlan.SelectedTabLoad.running(startedAt: changedAt.advanced(by: .milliseconds(1))) + let before = TableRowsRefreshPlan.SelectedTabLoad.running(startedAt: changedAt.advanced(by: .milliseconds(-1))) + + for change in [rowsChange, definition] { + #expect( + TableRowsRefreshPlan.resumedAction(for: selected, state: state(selected, load: after), owing: change) + == .noReload, + "\(change.extent)" + ) + #expect( + TableRowsRefreshPlan.resumedAction(for: selected, state: state(selected, load: before), owing: change) + == .reloadNow, + "\(change.extent)" + ) + } + } +} diff --git a/TableProTests/Plugins/SQLiteResultColumnsTests.swift b/TableProTests/Plugins/SQLiteResultColumnsTests.swift new file mode 100644 index 0000000000..52ab94ad7e --- /dev/null +++ b/TableProTests/Plugins/SQLiteResultColumnsTests.swift @@ -0,0 +1,82 @@ +// +// SQLiteResultColumnsTests.swift +// TableProTests +// +// The first query on one connection after another connection altered the table named the old +// columns, because they were read before the step that notices the schema changed. +// + +import Foundation +import SQLite3 +import Testing + +struct SQLiteResultColumnsTests { + private struct OpenFailed: Error {} + + private final class Database { + let url: URL + private(set) var handles: [OpaquePointer] = [] + + init() { + url = FileManager.default.temporaryDirectory + .appendingPathComponent("sqlite-result-columns-\(UUID().uuidString).sqlite") + } + + func open() throws -> OpaquePointer { + var handle: OpaquePointer? + guard sqlite3_open(url.path, &handle) == SQLITE_OK, let handle else { + throw OpenFailed() + } + handles.append(handle) + return handle + } + + func run(_ sql: String, on handle: OpaquePointer) { + #expect(sqlite3_exec(handle, sql, nil, nil, nil) == SQLITE_OK, "\(sql)") + } + + func close() { + handles.forEach { sqlite3_close($0) } + try? FileManager.default.removeItem(at: url) + } + } + + private func firstStep(of sql: String, on handle: OpaquePointer) -> SQLiteResultColumns.FirstStep? { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + return SQLiteResultColumns.stepFirst(statement) + } + + @Test("A column another connection added is named on the first query that follows") + func namesAColumnAddedOnAnotherConnection() throws { + let database = Database() + defer { database.close() } + let session = try database.open() + let pooled = try database.open() + database.run("CREATE TABLE fixture (id INTEGER PRIMARY KEY, label TEXT)", on: session) + database.run("INSERT INTO fixture VALUES (1, 'First')", on: session) + #expect(firstStep(of: "SELECT * FROM fixture", on: session)?.names == ["id", "label"]) + + database.run("ALTER TABLE fixture ADD COLUMN added_col TEXT", on: pooled) + + let step = try #require(firstStep(of: "SELECT * FROM fixture", on: session)) + #expect(step.result == SQLITE_ROW) + #expect(step.names == ["id", "label", "added_col"]) + #expect(step.typeNames == ["INTEGER", "TEXT", "TEXT"]) + #expect(step.count == 3) + } + + @Test("A query that returns no rows still names its columns") + func namesTheColumnsOfAnEmptyResult() throws { + let database = Database() + defer { database.close() } + let handle = try database.open() + database.run("CREATE TABLE empty_fixture (id INTEGER PRIMARY KEY, label TEXT)", on: handle) + + let step = try #require(firstStep(of: "SELECT * FROM empty_fixture", on: handle)) + + #expect(step.result == SQLITE_DONE) + #expect(step.names == ["id", "label"]) + } +} diff --git a/TableProTests/Plugins/VendoredSQLiteImportTests.swift b/TableProTests/Plugins/VendoredSQLiteImportTests.swift new file mode 100644 index 0000000000..7692ecc3e5 --- /dev/null +++ b/TableProTests/Plugins/VendoredSQLiteImportTests.swift @@ -0,0 +1,91 @@ +// +// VendoredSQLiteImportTests.swift +// TableProTests +// +// A plugin that links its own SQLite compiled one file against the SDK's `SQLite3` module, whose +// `link "sqlite3"` puts macOS's library on that plugin's link line beside the vendored one. +// + +import Foundation +import Testing + +struct VendoredSQLiteImportTests { + private static let repositoryRoot: URL = { + var url = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 3 { + url.deleteLastPathComponent() + } + return url + }() + + /// The source directories of every target `project.yml` force-loads the vendored library into: + /// the plugin's own folder and each TableProCore product it links, read from the manifest so a + /// new target on that library is covered the moment it is added. + private static func vendoredSourceDirectories() throws -> [URL] { + let manifest = try String(contentsOf: repositoryRoot.appendingPathComponent("project.yml"), encoding: .utf8) + return targetBlocks(in: manifest) + .filter { $0.contains("libsqlite3_vendored.a") } + .flatMap { block in + values(of: "folder", in: block).map { repositoryRoot.appendingPathComponent("Plugins/\($0)") } + + values(of: "product", in: block).map { + repositoryRoot.appendingPathComponent("Packages/TableProCore/Sources/\($0)") + } + } + .filter { FileManager.default.fileExists(atPath: $0.path) } + } + + private static func targetBlocks(in manifest: String) -> [String] { + var blocks: [[Substring]] = [] + for line in manifest.split(separator: "\n", omittingEmptySubsequences: false) { + if line.range(of: #"^ [A-Za-z][A-Za-z0-9]*:\s*$"#, options: .regularExpression) != nil { + blocks.append([]) + } + if !blocks.isEmpty { + blocks[blocks.count - 1].append(line) + } + } + return blocks.map { $0.joined(separator: "\n") } + } + + private static func values(of key: String, in block: String) -> [String] { + block.split(separator: "\n").compactMap { line -> String? in + var trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("- ") { + trimmed = String(trimmed.dropFirst(2)) + } + guard trimmed.hasPrefix("\(key):") else { return nil } + return trimmed.dropFirst(key.count + 1).trimmingCharacters(in: .whitespaces) + } + } + + private static func swiftSources(in directories: [URL]) throws -> [(path: String, text: String)] { + try directories.flatMap { directory -> [(path: String, text: String)] in + guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { + return [] + } + return try enumerator.compactMap { $0 as? URL } + .filter { $0.pathExtension == "swift" } + .map { ($0.path, try String(contentsOf: $0, encoding: .utf8)) } + } + } + + @Test("The scan finds both plugins that link their own SQLite, and the package they share") + func scanReachesTheVendoredTargets() throws { + let names = try Self.vendoredSourceDirectories().map(\.lastPathComponent) + + #expect(names.contains("SQLiteDriverPlugin")) + #expect(names.contains("LibSQLDriverPlugin")) + #expect(names.contains("TableProSQLiteCore")) + } + + @Test("No source compiled into a plugin on the vendored SQLite imports the SDK's SQLite3 module") + func noVendoredTargetImportsTheSystemModule() throws { + let sources = try Self.swiftSources(in: Self.vendoredSourceDirectories()) + let offenders = sources + .filter { $0.text.range(of: #"(?m)^\s*(@\w+\s+)*import\s+SQLite3\b"#, options: .regularExpression) != nil } + .map { URL(fileURLWithPath: $0.path).lastPathComponent } + + #expect(sources.count > 10, "The plugin sources were not found; the guard below would pass vacuously") + #expect(offenders.isEmpty, "These files import the SDK's SQLite3 instead of CSQLite: \(offenders)") + } +} diff --git a/TableProTests/Views/Main/CatalogChangeWindowTests.swift b/TableProTests/Views/Main/CatalogChangeWindowTests.swift index 2e884bc2dd..bd38d19082 100644 --- a/TableProTests/Views/Main/CatalogChangeWindowTests.swift +++ b/TableProTests/Views/Main/CatalogChangeWindowTests.swift @@ -42,16 +42,40 @@ struct CatalogChangeWindowTests { name: String, database: String, schema: String?, - kind: DatabaseObjectChange.Kind + kind: DatabaseObjectChange.Kind, + originTabId: UUID? = nil ) -> DatabaseObjectChange { DatabaseObjectChange( connectionId: connection.id, scope: DatabaseScope(connectionId: connection.id, database: database, schema: schema), name: name, - kind: kind + kind: kind, + originTabId: originTabId ) } + /// A tab that has run its query, holding `rowCount` rows and the total a count reported for them. + private static func loadedTab( + _ name: String, + rowCount: Int, + totalRowCount: Int? = nil, + in coordinator: MainContentCoordinator + ) -> QueryTab { + var tab = tableTab(name, database: "shop", schema: nil) + tab.execution.lastExecutedAt = Date() + tab.pagination.totalRowCount = totalRowCount + let rows = (0.. Int? { + tabManager.tabs.first { $0.id == tabId }?.pagination.totalRowCount + } + @Test("a dropped table closes its tabs and no tab on a same-named table in another schema") func droppedTableClosesOnlyItsOwnTabs() { let connection = TestFixtures.makeConnection(database: "shop") @@ -64,9 +88,7 @@ struct CatalogChangeWindowTests { tabManager.selectedTabId = dropped.id coordinator.applyObjectChange( - Self.change(connection, name: "users", database: "shop", schema: "analytics", kind: .dropped), - hasPendingTableOps: false, - onDiscard: {} + Self.change(connection, name: "users", database: "shop", schema: "analytics", kind: .dropped) ) #expect(tabManager.tabs.map(\.id) == [survivor.id, query.id]) @@ -83,9 +105,7 @@ struct CatalogChangeWindowTests { tabManager.tabs = [renamed, other] coordinator.applyObjectChange( - Self.change(connection, name: "orders", database: "shop", schema: nil, kind: .renamed(to: "orders_2026")), - hasPendingTableOps: false, - onDiscard: {} + Self.change(connection, name: "orders", database: "shop", schema: nil, kind: .renamed(to: "orders_2026")) ) #expect(tabManager.tabs[0].tableContext.tableName == "orders_2026") @@ -144,4 +164,458 @@ struct CatalogChangeWindowTests { #expect(tabManager.tabs.map(\.id) == [tab.id]) } + + @Test("a rows change marks every background tab on that table, an empty one too, and leaves the rest alone") + func rowsChangeMarksBackgroundTabsOnItsTable() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let loaded = Self.loadedTab("users", rowCount: 3, totalRowCount: 3, in: coordinator) + let empty = Self.loadedTab("users", rowCount: 0, in: coordinator) + let unrelated = Self.loadedTab("orders", rowCount: 2, totalRowCount: 42, in: coordinator) + tabManager.tabs = [loaded, empty, unrelated] + tabManager.selectedTabId = unrelated.id + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + + let registry = coordinator.tabSessionRegistry + #expect(registry.isStale(loaded.id)) + #expect(registry.tableRows(for: loaded.id).rows.count == 3) + #expect(Self.totalRowCount(of: loaded.id, in: tabManager) == nil) + #expect(registry.isStale(empty.id)) + #expect(!registry.isStale(unrelated.id)) + #expect(Self.totalRowCount(of: unrelated.id, in: tabManager) == 42) + + tabManager.selectedTabId = empty.id + coordinator.lazyLoadCurrentTabIfNeeded() + #expect(coordinator.pendingLoadTrigger == .userInitiated) + } + + @Test("a rows change reloads the selected tab on that table when nothing of the user's is in it") + func rowsChangeReloadsACleanSelectedTab() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) + tabManager.tabs = [selected] + tabManager.selectedTabId = selected.id + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + + #expect(Self.totalRowCount(of: selected.id, in: tabManager) == nil) + } + + @Test("a rows change leaves the selected tab holding edits as it is, marked to reload with its next query") + func rowsChangeLeavesASelectedTabWithEditsAlone() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + var selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) + selected.pendingChanges.deletedRowIDs = [.existing(0)] + tabManager.tabs = [selected] + tabManager.selectedTabId = selected.id + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + + #expect(Self.totalRowCount(of: selected.id, in: tabManager) == 42) + #expect(coordinator.tabSessionRegistry.isStale(selected.id)) + #expect(coordinator.tabSessionRegistry.tableRows(for: selected.id).rows.count == 3) + } + + @Test("a rows change leaves out the tab that made it") + func rowsChangeSkipsItsOriginTab() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let origin = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) + let duplicate = Self.loadedTab("users", rowCount: 3, in: coordinator) + tabManager.tabs = [origin, duplicate] + tabManager.selectedTabId = origin.id + + coordinator.applyObjectChange( + Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows, originTabId: origin.id) + ) + + #expect(Self.totalRowCount(of: origin.id, in: tabManager) == 42) + #expect(!coordinator.tabSessionRegistry.isStale(origin.id)) + #expect(coordinator.tabSessionRegistry.isStale(duplicate.id)) + } + + @Test("a rows change reloads the rows of the selected tab showing its structure") + func rowsChangeReloadsBehindTheStructureView() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + var selected = Self.loadedTab("users", rowCount: 3, in: coordinator) + selected.display.resultsViewMode = .structure + tabManager.tabs = [selected] + tabManager.selectedTabId = selected.id + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + + #expect(coordinator.pendingLoadTrigger == .userInitiated) + } + + @Test("a structure change marks the structure of a tab on that table, and one holding staged edits owes it") + func structureChangeMarksStructureSessionsWithoutStagedEdits() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let clean = Self.loadedTab("users", rowCount: 1, in: coordinator) + let editing = Self.loadedTab("users", rowCount: 1, in: coordinator) + let unrelated = Self.loadedTab("orders", rowCount: 1, in: coordinator) + tabManager.tabs = [clean, editing, unrelated] + tabManager.selectedTabId = unrelated.id + for tab in [clean, editing, unrelated] { + let session = Self.structureSession(for: tab, connection: connection) + session.hasLoaded = true + coordinator.structureSessions[tab.id] = session + } + coordinator.structureSessions[editing.id]?.changeManager.addNewColumn() + + coordinator.applyObjectChange( + Self.change(connection, name: "users", database: "shop", schema: nil, kind: .structure) + ) + + #expect(coordinator.structureSessions[clean.id]?.hasLoaded == false) + #expect(coordinator.structureSessions[editing.id]?.hasLoaded == true) + #expect(coordinator.structureSessions[editing.id]?.owesRefetch == true) + #expect(coordinator.structureSessions[unrelated.id]?.hasLoaded == true) + #expect(coordinator.structureSessions[unrelated.id]?.owesRefetch == false) + #expect(coordinator.tabSessionRegistry.isStale(clean.id)) + #expect(!coordinator.tabSessionRegistry.isStale(unrelated.id)) + } + + @Test("a structure change forgets the table's cached columns, and a rows change keeps them") + func structureChangeForgetsCachedSchemaColumns() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let tab = Self.loadedTab("users", rowCount: 1, in: coordinator) + tabManager.tabs = [tab] + let key = coordinator.schemaColumnsKey("users", scope: coordinator.scope(for: tab)) + let entry = SchemaColumnStore.Entry(columns: ["id"], primaryKeys: ["id"], columnTypes: [:]) + coordinator.schemaColumns.store(entry, for: key) + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + #expect(coordinator.schemaColumns.cached(key) == entry) + + coordinator.applyObjectChange( + Self.change(connection, name: "users", database: "shop", schema: nil, kind: .structure) + ) + #expect(coordinator.schemaColumns.cached(key) == nil) + } + + /// A tab holding everything `isMetadataCached` asks for, so its next load would reuse it. + private static func tabWithCachedMetadata(_ name: String, in coordinator: MainContentCoordinator) -> QueryTab { + var tab = tableTab(name, database: "shop", schema: nil) + tab.execution.lastExecutedAt = Date() + tab.tableContext.primaryKeyColumns = ["id"] + coordinator.tabSessionRegistry.setTableRows( + TableRows.from( + queryRows: [[.text("1")]], + columns: ["id"], + columnTypes: [.text(rawType: nil)], + columnDefaults: ["id": nil], + hasAuthoritativeSchema: true, + foreignKeysFetched: true + ), + for: tab.id + ) + return tab + } + + @Test("a structure change makes the next load of every tab on that table fetch its definition again") + func structureChangeRetiresTheCachedDefinition() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let background = Self.tabWithCachedMetadata("users", in: coordinator) + let unrelated = Self.tabWithCachedMetadata("orders", in: coordinator) + var editing = Self.tabWithCachedMetadata("users", in: coordinator) + editing.pendingChanges.deletedRowIDs = [.existing(0)] + tabManager.tabs = [background, unrelated, editing] + tabManager.selectedTabId = editing.id + #expect(coordinator.isMetadataCached(tabId: background.id, tableName: "users")) + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + #expect(coordinator.isMetadataCached(tabId: background.id, tableName: "users")) + + coordinator.applyObjectChange( + Self.change(connection, name: "users", database: "shop", schema: nil, kind: .structure) + ) + + #expect(!coordinator.isMetadataCached(tabId: background.id, tableName: "users")) + #expect(!coordinator.isMetadataCached(tabId: editing.id, tableName: "users")) + #expect(coordinator.isMetadataCached(tabId: unrelated.id, tableName: "orders")) + } + + @Test("a change starts the selected tab's running load again only when that load claimed the tab before it") + func changeRestartsOnlyALoadThatStartedBeforeIt() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let selected = Self.loadedTab("users", rowCount: 3, in: coordinator) + tabManager.tabs = [selected] + tabManager.selectedTabId = selected.id + let changedAt = ContinuousClock.now + + let startedAfter = coordinator.tabExecution.claim(selected.id, startedAt: changedAt.advanced(by: .milliseconds(1))) + coordinator.applyObjectChange(Self.rowsChange(connection, table: "users", at: changedAt)) + #expect(coordinator.tabExecution.isCurrent(startedAfter)) + #expect(coordinator.tabSessionRegistry.isStale(selected.id)) + + let startedBefore = coordinator.tabExecution.claim(selected.id, startedAt: changedAt.advanced(by: .milliseconds(-1))) + coordinator.applyObjectChange(Self.rowsChange(connection, table: "users", at: changedAt)) + #expect(!coordinator.tabExecution.isCurrent(startedBefore)) + } + + /// A result for `users` read by a query that claimed its tab at `startedAt`. + private static func commitRead( + of tabId: UUID, + startedAt: ContinuousClock.Instant, + in coordinator: MainContentCoordinator, + connection: DatabaseConnection + ) { + coordinator.applyPhase1Result( + tabId: tabId, + columns: ["id"], + columnTypes: [.text(rawType: nil)], + rows: [[.text("1")]], + executionTime: 0, + rowsAffected: 0, + statusMessage: nil, + tableName: "users", + isEditable: true, + metadata: nil, + hasSchema: false, + read: TableFreshness.Read(startedAt: startedAt, includesDefinition: false), + sql: "SELECT * FROM users", + connection: connection + ) + } + + @Test("the read that answers a change drops a total a count started before the change put back") + func answeringReadRetiresALateTotal() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let background = Self.loadedTab("users", rowCount: 3, totalRowCount: 5_000_000, in: coordinator) + let unrelated = Self.loadedTab("orders", rowCount: 2, in: coordinator) + tabManager.tabs = [background, unrelated] + tabManager.selectedTabId = unrelated.id + let changedAt = ContinuousClock.now + + Self.commitRead(of: background.id, startedAt: changedAt, in: coordinator, connection: connection) + #expect(Self.totalRowCount(of: background.id, in: tabManager) == 5_000_000) + + coordinator.applyObjectChange(Self.rowsChange(connection, table: "users", at: changedAt)) + tabManager.mutate(tabId: background.id) { $0.pagination.totalRowCount = 5_000_000 } + + Self.commitRead( + of: background.id, + startedAt: changedAt.advanced(by: .milliseconds(-1)), + in: coordinator, + connection: connection + ) + #expect(Self.totalRowCount(of: background.id, in: tabManager) == 5_000_000) + #expect(coordinator.tabSessionRegistry.isStale(background.id)) + + Self.commitRead( + of: background.id, + startedAt: changedAt.advanced(by: .milliseconds(1)), + in: coordinator, + connection: connection + ) + #expect(Self.totalRowCount(of: background.id, in: tabManager) == nil) + #expect(!coordinator.tabSessionRegistry.isStale(background.id)) + } + + @Test("a definition read by a load that started before the latest definition change stays out of the column cache") + func aPreChangeDefinitionStaysOutOfTheColumnCache() throws { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let background = Self.loadedTab("users", rowCount: 1, in: coordinator) + tabManager.tabs = [background] + let scope = try #require(coordinator.scope(for: background)) + let key = coordinator.schemaColumnsKey("users", scope: scope) + let changedAt = ContinuousClock.now + coordinator.tabSessionRegistry.recordChange( + TableFreshness.Change(extent: .definition, at: changedAt), + for: background.id + ) + let schema = FetchedTableSchema( + columns: [ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true)], + foreignKeys: nil, + approximateRowCount: nil + ) + + let before = coordinator.adoptLoadedDefinition( + schema, + of: "users", + in: scope, + readBy: TabExecutionClaim(tabId: background.id, epoch: 1, startedAt: changedAt.advanced(by: .milliseconds(-1))) + ) + #expect(before?.primaryKeyColumns == ["id"]) + #expect(coordinator.schemaColumns.cached(key) == nil) + + _ = coordinator.adoptLoadedDefinition( + schema, + of: "users", + in: scope, + readBy: TabExecutionClaim(tabId: background.id, epoch: 2, startedAt: changedAt.advanced(by: .milliseconds(1))) + ) + #expect(coordinator.schemaColumns.cached(key)?.columns == ["id"]) + } + + @Test("a change put off while the selected tab held edits reloads it once they are discarded") + func deferredChangeReloadsOnceTheEditsAreGone() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + var selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) + selected.pendingChanges.deletedRowIDs = [.existing(0)] + tabManager.tabs = [selected] + tabManager.selectedTabId = selected.id + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + coordinator.resumeDeferredTableRefresh() + #expect(Self.totalRowCount(of: selected.id, in: tabManager) == 42) + + var pendingTruncates = Set() + var pendingDeletes = Set() + coordinator.handleDiscard(pendingTruncates: &pendingTruncates, pendingDeletes: &pendingDeletes) + + #expect(Self.totalRowCount(of: selected.id, in: tabManager) == nil) + } + + /// Waits a few turns for a reload the change manager's cleared edits start on a later one. + private static func waitForReload(of tabId: UUID, in tabManager: QueryTabManager) async throws { + for _ in 0..<50 where totalRowCount(of: tabId, in: tabManager) != nil { + try await Task.sleep(for: .milliseconds(10)) + } + } + + /// Discard was the only way out that resumed the reload. An undo, a cell typed back to what it + /// held or a restored row leave the change manager just as clean, and the tab went on showing + /// the rows from before the change until the user refreshed it by hand. + @Test("a change put off while the selected tab held edits reloads it once the last edit is taken back") + func deferredChangeReloadsOnceTheLastEditIsTakenBack() async throws { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) + tabManager.tabs = [selected] + tabManager.selectedTabId = selected.id + Self.configureChangeManager(of: coordinator) + coordinator.changeManager.recordRowDeletion(rowID: .existing(0), originalRow: [.text("0")]) + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + try await Task.sleep(for: .milliseconds(50)) + #expect(Self.totalRowCount(of: selected.id, in: tabManager) == 42) + + coordinator.changeManager.undoRowDeletion(rowID: .existing(0)) + try await Self.waitForReload(of: selected.id, in: tabManager) + + #expect(Self.totalRowCount(of: selected.id, in: tabManager) == nil) + } + + /// Switching back to a tab restores its edits into the change manager, and the copy left on the + /// tab went on reporting them after the reader undid them all, so it vetoed the reload they had + /// put off for as long as the tab stayed in front. + @Test("a tab switched back to holds its edits in the change manager only, so undoing them reloads it") + func aTabShownAgainDropsItsSavedEditsOnceRestored() async throws { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let edited = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) + let other = Self.loadedTab("orders", rowCount: 1, in: coordinator) + tabManager.tabs = [edited, other] + tabManager.selectedTabId = edited.id + Self.configureChangeManager(of: coordinator) + coordinator.changeManager.recordRowDeletion(rowID: .existing(0), originalRow: [.text("0")]) + + tabManager.selectedTabId = other.id + coordinator.handleTabChange(from: edited.id, to: other.id, tabs: tabManager.tabs) + tabManager.selectedTabId = edited.id + coordinator.handleTabChange(from: other.id, to: edited.id, tabs: tabManager.tabs) + #expect(coordinator.changeManager.hasChanges) + #expect(tabManager.tabs.first { $0.id == edited.id }?.pendingChanges.hasChanges == false) + + coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) + try await Task.sleep(for: .milliseconds(50)) + #expect(Self.totalRowCount(of: edited.id, in: tabManager) == 42) + + coordinator.changeManager.undoRowDeletion(rowID: .existing(0)) + try await Self.waitForReload(of: edited.id, in: tabManager) + + #expect(Self.totalRowCount(of: edited.id, in: tabManager) == nil) + } + + private static func configureChangeManager(of coordinator: MainContentCoordinator) { + coordinator.changeManager.configureForTable( + tableName: "users", + columns: ["id"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: [], + triggerReload: false + ) + } + + @Test("a window being torn down starts no reload when its grid's overlay closes") + func resumingDuringTeardownReloadsNothing() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) + tabManager.tabs = [selected] + tabManager.selectedTabId = selected.id + coordinator.tabSessionRegistry.recordChange(TableFreshness.Change(extent: .rows, at: .now), for: selected.id) + coordinator.markTeardownScheduled() + defer { coordinator.clearTeardownScheduled() } + + coordinator.resumeDeferredTableRefresh() + + #expect(Self.totalRowCount(of: selected.id, in: tabManager) == 42) + } + + @Test("closing what was in the way reloads nothing on a tab that owes no read") + func resumingAFreshTabReloadsNothing() { + let connection = TestFixtures.makeConnection(database: "shop") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection) + let selected = Self.loadedTab("users", rowCount: 3, totalRowCount: 42, in: coordinator) + tabManager.tabs = [selected] + tabManager.selectedTabId = selected.id + + coordinator.resumeDeferredTableRefresh() + + #expect(Self.totalRowCount(of: selected.id, in: tabManager) == 42) + } + + private static func rowsChange( + _ connection: DatabaseConnection, + table: String, + at changedAt: ContinuousClock.Instant + ) -> DatabaseObjectChange { + DatabaseObjectChange( + connectionId: connection.id, + scope: DatabaseScope(connectionId: connection.id, database: "shop", schema: nil), + name: table, + kind: .rows, + changedAt: changedAt + ) + } + + private static func structureSession(for tab: QueryTab, connection: DatabaseConnection) -> StructureEditingSession { + StructureEditingSession( + identity: tab.id.uuidString, + connection: connection, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, + tableName: tab.tableContext.tableName ?? "" + ) + } } diff --git a/TableProTests/Views/Main/DataRefreshScopeTests.swift b/TableProTests/Views/Main/DataRefreshScopeTests.swift index 9a46a0e078..d677ec5b5f 100644 --- a/TableProTests/Views/Main/DataRefreshScopeTests.swift +++ b/TableProTests/Views/Main/DataRefreshScopeTests.swift @@ -135,4 +135,150 @@ struct DataRefreshScopeTests { #expect(request.connectionId != coordinator.connectionId) #expect(request.scope != coordinator.selectedTabScope) } + + private static func loadedTab( + _ name: String, + database: String, + tabType: TabType = .table, + in coordinator: MainContentCoordinator + ) -> QueryTab { + var tab = QueryTab(title: name, query: "SELECT 1", tabType: tabType, tableName: name) + tab.tableContext.databaseName = database + tab.execution.lastExecutedAt = Date() + coordinator.tabSessionRegistry.setTableRows( + TableRows.from(queryRows: [["1"]], columns: ["id"], columnTypes: [.text(rawType: nil)]), + for: tab.id + ) + return tab + } + + @Test("A scoped refresh marks the table tabs in its scope, background ones too, and none elsewhere") + func scopedRefreshMarksTheTableTabsInItsScope() { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "inventory") + let inScope = Self.loadedTab("orders", database: "orders", in: coordinator) + let queryInScope = Self.loadedTab("orders", database: "orders", tabType: .query, in: coordinator) + let elsewhere = Self.loadedTab("orders", database: "inventory", in: coordinator) + tabManager.tabs = [inScope, queryInScope, elsewhere] + tabManager.selectedTabId = elsewhere.id + + coordinator.applyDataRefresh( + DataRefreshRequest( + connectionId: connection.id, + scope: DatabaseScope(connectionId: connection.id, database: "orders", schema: nil) + ) + ) + + let registry = coordinator.tabSessionRegistry + #expect(registry.isStale(inScope.id)) + #expect(registry.tableRows(for: inScope.id).rows.count == 1) + #expect(!registry.isStale(queryInScope.id)) + #expect(!registry.isStale(elsewhere.id)) + } + + @Test("An unscoped refresh marks every table tab on the connection") + func unscopedRefreshMarksEveryTableTab() { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "inventory") + let first = Self.loadedTab("orders", database: "orders", in: coordinator) + let second = Self.loadedTab("stock", database: "inventory", in: coordinator) + tabManager.tabs = [first, second] + + coordinator.applyDataRefresh(DataRefreshRequest(connectionId: connection.id)) + + #expect(coordinator.tabSessionRegistry.isStale(first.id)) + #expect(coordinator.tabSessionRegistry.isStale(second.id)) + } + + @Test("A refresh forgets every cached column list before the selected tab builds its reload") + func refreshForgetsCachedColumnsBeforeTheReload() throws { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "orders") + var tab = Self.loadedTab("orders", database: "orders", in: coordinator) + tab.columnLayout.hiddenColumns = ["note"] + tabManager.tabs = [tab] + tabManager.selectedTabId = tab.id + let scope = try #require(coordinator.scope(for: tab)) + let shownKey = coordinator.schemaColumnsKey("orders", scope: scope) + let unshownKey = coordinator.schemaColumnsKey("stock", scope: scope) + let before = SchemaColumnStore.Entry(columns: ["id", "note", "dropped_col"], primaryKeys: ["id"], columnTypes: [:]) + coordinator.schemaColumns.store(before, for: shownKey) + coordinator.schemaColumns.store(before, for: unshownKey) + #expect(coordinator.selectColumns(for: tab) == ["id", "dropped_col"]) + + coordinator.applyDataRefresh(DataRefreshRequest(connectionId: connection.id)) + + let reloaded = try #require(tabManager.tabs.first { $0.id == tab.id }) + #expect(!reloaded.content.query.contains("dropped_col")) + #expect(coordinator.schemaColumns.cached(shownKey) == nil) + #expect(coordinator.schemaColumns.cached(unshownKey) == nil) + } + + private static func loadedStructure( + of tab: QueryTab, + connection: DatabaseConnection, + in coordinator: MainContentCoordinator + ) -> StructureEditingSession { + let session = StructureEditingSession( + identity: tab.id.uuidString, + connection: connection, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, + tableName: tab.tableContext.tableName ?? "" + ) + session.hasLoaded = true + coordinator.structureSessions[tab.id] = session + return session + } + + /// An import or a context switch can change any definition in its scope, and a structure left + /// loaded behind the Data view, or behind another tab, was never told: it showed the columns + /// from before the change when it was shown again. + @Test("A refresh has the structure of every table tab it reaches fetched again, once any staged edits are gone") + func refreshMarksTheStructureOfEveryTabItReaches() { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "orders") + let inScope = Self.loadedTab("orders", database: "orders", in: coordinator) + let editing = Self.loadedTab("orders", database: "orders", in: coordinator) + let elsewhere = Self.loadedTab("stock", database: "inventory", in: coordinator) + tabManager.tabs = [inScope, editing, elsewhere] + tabManager.selectedTabId = elsewhere.id + let inScopeStructure = Self.loadedStructure(of: inScope, connection: connection, in: coordinator) + let editingStructure = Self.loadedStructure(of: editing, connection: connection, in: coordinator) + let elsewhereStructure = Self.loadedStructure(of: elsewhere, connection: connection, in: coordinator) + editingStructure.changeManager.addNewColumn() + + coordinator.applyDataRefresh( + DataRefreshRequest( + connectionId: connection.id, + scope: DatabaseScope(connectionId: connection.id, database: "orders", schema: nil) + ) + ) + + #expect(!inScopeStructure.hasLoaded) + #expect(elsewhereStructure.hasLoaded) + #expect(editingStructure.hasLoaded, "A structure holding staged edits keeps them") + #expect(editingStructure.changeManager.hasChanges) + + editingStructure.changeManager.discardChanges() + #expect(editingStructure.settleOwedRefetch()) + #expect(!editingStructure.hasLoaded) + } + + @Test("A refresh for another connection marks nothing in this window") + func refreshForAnotherConnectionMarksNothing() { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, tabManager) = Self.makeCoordinator(connection: connection, browseDatabase: "orders") + let tab = Self.loadedTab("orders", database: "orders", in: coordinator) + tabManager.tabs = [tab] + + coordinator.applyDataRefresh(DataRefreshRequest(connectionId: UUID())) + + #expect(!coordinator.tabSessionRegistry.isStale(tab.id)) + } } diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index 5725fe9ab0..7fa637eee7 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -121,6 +121,42 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 5) } + @Test("A tab whose table changed after its rows were fetched asks to load again") + func loadsAStaleTabWithRows() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + seedRows(coordinator, for: tabId, rowCount: 5) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("expected tab to exist") + return + } + tabManager.tabs[idx].execution.lastExecutedAt = Date() + coordinator.tabSessionRegistry.recordChange(TableFreshness.Change(extent: .rows, at: .now), for: tabId) + + coordinator.lazyLoadCurrentTabIfNeeded() + + #expect(coordinator.pendingLoadTrigger == .userInitiated) + #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 5) + } + + @Test("A stale tab holding edits is not reloaded behind the user") + func skipsAStaleTabWithPendingEdits() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + seedRows(coordinator, for: tabId, rowCount: 1) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("expected tab to exist") + return + } + tabManager.tabs[idx].execution.lastExecutedAt = Date() + tabManager.tabs[idx].pendingChanges.deletedRowIDs = [.existing(0)] + coordinator.tabSessionRegistry.recordChange(TableFreshness.Change(extent: .rows, at: .now), for: tabId) + + coordinator.lazyLoadCurrentTabIfNeeded() + + #expect(coordinator.pendingLoadTrigger == nil) + } + @Test("Returns early when tab has pending edits in the change manager") func skipsWhenPendingChangesPresent() { let (coordinator, tabManager) = makeCoordinator() diff --git a/TableProTests/Views/Results/KeyHandlingTableViewOverlayTests.swift b/TableProTests/Views/Results/KeyHandlingTableViewOverlayTests.swift index f62d73672f..e2e0a14dcf 100644 --- a/TableProTests/Views/Results/KeyHandlingTableViewOverlayTests.swift +++ b/TableProTests/Views/Results/KeyHandlingTableViewOverlayTests.swift @@ -18,14 +18,23 @@ private final class StubColumnLayoutPersister: ColumnLayoutPersisting { func clear(for key: ColumnLayoutTableKey) {} } +@MainActor +private final class OverlayCloseRecorder: DataGridViewDelegate { + private(set) var closedCount = 0 + + func dataGridDidCloseCellOverlay() { + closedCount += 1 + } +} + @MainActor struct KeyHandlingTableViewOverlayTests { - private func makeCoordinator() -> TableViewCoordinator { + private func makeCoordinator(delegate: (any DataGridViewDelegate)? = nil) -> TableViewCoordinator { TableViewCoordinator( changeManager: AnyChangeManager(DataChangeManager()), isEditable: true, selectedRowIndices: .constant([]), - delegate: nil, + delegate: delegate, layoutPersister: StubColumnLayoutPersister() ) } @@ -79,4 +88,33 @@ struct KeyHandlingTableViewOverlayTests { #expect(!editor.isActive) #expect(container.superview == nil) } + + /// A reload a change put off while the overlay was open waits on this, and an editor that closes + /// with a commit records its edit only after removing itself, so the owner hears on the next turn. + @Test("closing a cell overlay tells the grid's owner on the next turn, once") + func closingAnOverlayTellsTheOwnerOnTheNextTurn() async throws { + let recorder = OverlayCloseRecorder() + let tableView = KeyHandlingTableView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let coordinator = makeCoordinator(delegate: recorder) + tableView.coordinator = coordinator + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + scrollView.documentView = tableView + + for overlay in [CellOverlayViewer(), CellOverlayEditor()] as [CellOverlayBase] { + let before = recorder.closedCount + coordinator.observeRemoval(of: overlay) + let container = CellOverlayContainerView(frame: NSRect(x: 0, y: 0, width: 80, height: 24)) + overlay.install(in: tableView, row: 0, column: 0, columnIndex: 0, container: container) + #expect(overlay.isActive) + + overlay.removeOverlay() + overlay.removeOverlay() + #expect(recorder.closedCount == before) + + for _ in 0..<50 where recorder.closedCount == before { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(recorder.closedCount == before + 1) + } + } } diff --git a/TableProTests/Views/Structure/StructureEditingSessionTests.swift b/TableProTests/Views/Structure/StructureEditingSessionTests.swift index db992ea301..86dc689866 100644 --- a/TableProTests/Views/Structure/StructureEditingSessionTests.swift +++ b/TableProTests/Views/Structure/StructureEditingSessionTests.swift @@ -181,6 +181,8 @@ struct StructureEditingSessionTests { Self.stageAColumn(on: session) #expect(session.changeManager.hasChanges) + session.markStructureStale() + #expect(session.owesRefetch) let outcome = await session.applyStagedChanges(coordinator: nil) @@ -191,6 +193,7 @@ struct StructureEditingSessionTests { #expect(!session.changeManager.hasChanges) #expect(session.appliedVersion == 1) #expect(!session.hasLoaded) + #expect(!session.owesRefetch, "The apply fetches everything again, which answers the change it owed") } /// Stands in for the connection the pool would open on the scope. @@ -257,4 +260,54 @@ struct StructureEditingSessionTests { #expect(!session.availableTabs.contains(.checkConstraints)) #expect(session.selectedTab == .columns) } + + /// A session left on DDL or Triggers is marked stale by a change made elsewhere, and its next + /// mount does not change the selection, so the selection handler never fetches that sub-tab. + @Test("A mount fetches the sub-tab the user left selected, once, after the ones it baselines from") + func mountFetchesTheSelectedSubTab() { + let session = Self.makeSession(connection: TestFixtures.makeConnection()) + #expect(session.tabsFetchedOnMount.first == .columns) + #expect(!session.tabsFetchedOnMount.contains(.ddl)) + + session.selectedTab = .ddl + session.tabData.markFetched(.ddl) + session.hasLoaded = true + session.markStructureStale() + + #expect(!session.hasLoaded) + #expect(session.tabData.needsFetch(.ddl)) + #expect(session.tabsFetchedOnMount.last == .ddl) + + session.selectedTab = .indexes + #expect(session.tabsFetchedOnMount.filter { $0 == .indexes }.count == 1) + #expect(!session.tabsFetchedOnMount.contains(.ddl)) + } + + /// A change made elsewhere while edits are staged cannot be fetched without re-baselining the + /// change manager, which throws the edits away. Dropping it instead left a session undone back to + /// clean still marked loaded, so it showed the old columns and staged its next edit against them. + @Test("A change made while edits are staged is fetched once they are undone, and once only") + func aChangeOwedWhileEditingIsFetchedOnceTheEditsAreGone() { + let session = Self.makeSession(connection: TestFixtures.makeConnection()) + Self.stageAColumn(on: session) + session.tabData.markFetched(.columns) + session.hasLoaded = true + + session.markStructureStale() + + #expect(session.changeManager.hasChanges, "The staged edit survives the change") + #expect(session.hasLoaded) + #expect(!session.tabData.needsFetch(.columns)) + #expect(!session.settleOwedRefetch(), "Nothing is fetched while the edit is still staged") + + while session.changeManager.canUndo { + session.changeManager.undo() + } + #expect(!session.changeManager.hasChanges) + + #expect(session.settleOwedRefetch()) + #expect(!session.hasLoaded) + #expect(session.tabData.needsFetch(.columns)) + #expect(!session.settleOwedRefetch()) + } } diff --git a/TableProUITests/TableChangeReloadUITests.swift b/TableProUITests/TableChangeReloadUITests.swift new file mode 100644 index 0000000000..efe95b9188 --- /dev/null +++ b/TableProUITests/TableChangeReloadUITests.swift @@ -0,0 +1,405 @@ +// +// TableChangeReloadUITests.swift +// TableProUITests +// +// A change to a table used to reach only whichever tab each window had in front. A save announced +// nothing at all, so a second tab on the same table went on showing the rows from before it, and a +// structure save reloaded nothing behind the Structure view, so switching back to Data showed the +// old columns. Neither tab reloaded when shown: each already held rows and had run its query. +// + +import AppKit +import XCTest + +final class TableChangeReloadUITests: UITestCase { + /// Chinook has no small table a test can write to without leaving the edit for the next case, so + /// the app seeds this one afresh at every launch. Both sides spell the name out because a UI test + /// target cannot import the app. + private let fixtureVariable = "TABLEPRO_UI_TEST_SEED_JSON_TABLE" + private let fixtureTable = "json_fixture" + private let labelColumnPosition = 2 + private let savedValue = "Saved Elsewhere" + private let heldValue = "Held Here" + private let addedColumn = "added_col" + private let otherTable = "MediaType" + + /// The first tab is sorted by `id` descending and the second is not, so the two show the edited + /// row at different positions. Row 2 of the first tab reads the saved value only once that tab + /// has fetched again; the second tab's row 2 never holds it, so a grid still painting the second + /// tab for a moment after the switch cannot pass this. + func testASaveInOneTabReachesAnotherTabOnTheSameTable() throws { + let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) + let window = app.windows.firstMatch + let grid = window.tables.matching(identifier: "data-grid").firstMatch + let tableRow = openFixtureTable(in: window, grid: grid) + + try clickHeader("id", in: grid) + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.label(row: 1, in: grid) == "First" }, + "The first click sorts ascending" + ) + try clickHeader("id", in: grid) + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.label(row: 1, in: grid) == "Second" }, + "The second click sorts descending, which puts the row this test edits second" + ) + + tableRow.rightClick() + let openInNewTab = app.menuItems["Open in New Tab"].firstMatch + XCTAssertTrue(openInNewTab.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") + openInNewTab.click() + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.fixtureTabs(in: window).count == 2 }, + "Open in New Tab must add a second tab on the same table" + ) + XCTAssertTrue( + waitForPredicate(timeout: 30) { + self.label(row: 1, in: grid) == "First" && self.label(row: 2, in: grid) == "Second" + }, + "The second tab opens unsorted" + ) + + editCell(row: 1, column: labelColumnPosition, in: grid, app: app, to: savedValue) + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.label(row: 1, in: grid) == self.savedValue }, + "The edit must land in the second tab" + ) + app.typeKey("s", modifierFlags: .command) + /// The save reloads the tab that made it, so its edited cell settles on the stored value. + _ = waitForPredicate(timeout: 5) { false } + + let firstTab = fixtureTabs(in: window).element(boundBy: 0) + XCTAssertTrue(firstTab.waitToExist(timeout: 10), "The first tab must still be in the strip") + clickAtCenter(firstTab) + + XCTAssertTrue( + waitForPredicate(timeout: 30) { self.label(row: 2, in: grid) == self.savedValue }, + "The first tab must show the saved value without a refresh, it shows " + + "'\(label(row: 2, in: grid) ?? "nil")'" + ) + XCTAssertEqual(label(row: 1, in: grid), "Second", "The first tab must keep its own sort across the reload") + } + + func testAColumnAddedInTheStructureViewShowsInTheDataView() throws { + let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) + let window = app.windows.firstMatch + let grid = window.tables.matching(identifier: "data-grid").firstMatch + _ = openFixtureTable(in: window, grid: grid) + XCTAssertTrue(header("label", in: grid).waitToExist(timeout: 30), "The Data view must show the fixture's columns") + + showStructure(in: app, window: window) + addColumnAndSave(in: window, grid: grid, app: app) + + let menuBar = app.menuBars.firstMatch + menuBar.menuBarItems["View"].menuItems["Data"].click() + + XCTAssertTrue( + header(addedColumn, in: grid).waitToExist(timeout: 30), + "Back on Data, the grid must show the column the Structure view added, without a refresh" + ) + } + + /// The first tab is left on its structure's DDL, which is not one of the sub-tabs a mount + /// fetches to baseline the editor. Showing it again does not change the selection either, so + /// nothing fetched the DDL after the other tab's save and it went on showing the old table. + func testAColumnAddedInAnotherTabShowsInTheDDLThisTabLeftOpen() throws { + let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) + let window = app.windows.firstMatch + let grid = window.tables.matching(identifier: "data-grid").firstMatch + let tableRow = openFixtureTable(in: window, grid: grid) + + showStructure(in: app, window: window) + let ddlTab = structureSubTab(named: "DDL", in: window) + XCTAssertTrue(ddlTab.waitToExist(timeout: 20), "The structure editor must offer a DDL sub-tab") + ddlTab.click() + XCTAssertTrue( + ddlText(containing: fixtureTable, in: window).waitToExist(timeout: 30), + "The DDL sub-tab must show the fixture's CREATE TABLE" + ) + XCTAssertFalse(ddlText(containing: addedColumn, in: window).exists, "The fixture starts without the column") + + tableRow.rightClick() + let openInNewTab = app.menuItems["Open in New Tab"].firstMatch + XCTAssertTrue(openInNewTab.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") + openInNewTab.click() + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.fixtureTabs(in: window).count == 2 }, + "Open in New Tab must add a second tab on the same table" + ) + XCTAssertTrue(waitForClickableRows(in: grid), "The second tab must load its rows") + + showStructure(in: app, window: window) + addColumnAndSave(in: window, grid: grid, app: app) + + let firstTab = fixtureTabs(in: window).element(boundBy: 0) + XCTAssertTrue(firstTab.waitToExist(timeout: 10), "The first tab must still be in the strip") + clickAtCenter(firstTab) + + XCTAssertTrue( + ddlText(containing: addedColumn, in: window).waitToExist(timeout: 30), + "Back on the first tab, its DDL must show the column the other tab added, without a refresh" + ) + } + + /// The first tab holds a staged column in its Structure view while the second adds another and + /// saves. Fetching that change would throw the staged column away, so it has to wait until the + /// column is gone, and it used to be dropped instead: the first tab went back to the columns it + /// had before the save and staged its next edit against them. The staged column is removed with + /// the footer's remove button rather than undone, because switching tabs resets the window's + /// undo stack, so Cmd+Z after coming back reaches nothing. + func testAColumnAddedInAnotherTabShowsOnceThisTabRemovesItsStagedColumn() throws { + let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) + let window = app.windows.firstMatch + let grid = window.tables.matching(identifier: "data-grid").firstMatch + let tableRow = openFixtureTable(in: window, grid: grid) + + showStructure(in: app, window: window) + let add = window.buttons["structure-footer-add"].firstMatch + XCTAssertTrue(add.waitToExist(timeout: 20), "The Columns tab must offer an add button") + XCTAssertTrue(waitForPredicate(timeout: 10) { add.isEnabled }, "SQLite adds a column with ALTER TABLE") + add.click() + let stagedRow = 4 + XCTAssertTrue( + cellElement(row: stagedRow, column: 1, in: grid).waitToExist(timeout: 10), + "Adding a column stages a fourth row under the fixture's three" + ) + + openSecondFixtureTab(from: tableRow, in: window, grid: grid, app: app) + showStructure(in: app, window: window) + addColumnAndSave(in: window, grid: grid, app: app) + + let firstTab = fixtureTabs(in: window).element(boundBy: 0) + XCTAssertTrue(firstTab.waitToExist(timeout: 10), "The first tab must still be in the strip") + clickAtCenter(firstTab) + XCTAssertTrue( + cellElement(row: stagedRow, column: 1, in: grid).waitToExist(timeout: 20), + "The first tab keeps its staged column across the other tab's save" + ) + XCTAssertNotEqual(value(row: stagedRow, column: 1, in: grid), addedColumn, "Nothing is fetched over a staged edit") + + point(at: cellElement(row: stagedRow, column: 1, in: grid).frame, in: grid).click() + let remove = window.buttons["structure-footer-remove"].firstMatch + XCTAssertTrue(remove.waitToExist(timeout: 10), "The Columns tab must offer a remove button") + XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the staged column must be offered") + remove.click() + + XCTAssertTrue( + waitForPredicate(timeout: 30) { self.value(row: stagedRow, column: 1, in: grid) == self.addedColumn }, + "Once its staged column is removed, the first tab must show the column the other tab added, it shows " + + "'\(value(row: stagedRow, column: 1, in: grid) ?? "nil")'" + ) + } + + /// An undo only reaches the tab it was made in while that tab stays in front, since a switch + /// resets the window's undo stack, so the save comes from a second window. The tab holding the + /// edit keeps its rows through the save, and used to keep them after the edit was undone too: + /// only Discard resumed the reload the edit had put off. + func testUndoingTheLastEditReloadsRowsSavedInAnotherWindow() throws { + let app = try launchWithSampleDatabase(environment: [fixtureVariable: "1"]) + let firstWindow = app.windows.firstMatch + let firstGrid = firstWindow.tables.matching(identifier: "data-grid").firstMatch + let tableRow = openFixtureTable(in: firstWindow, grid: firstGrid) + openSecondFixtureTab(from: tableRow, in: firstWindow, grid: firstGrid, app: app) + + /// A third table in front of the first window gives the two windows different titles, which + /// is how the Window menu tells them apart. + let otherRow = objectBrowserRow(otherTable, in: firstWindow) + XCTAssertTrue(otherRow.waitToExist(timeout: 20), "The object browser must list \(otherTable)") + otherRow.rightClick() + let openOther = app.menuItems["Open in New Tab"].firstMatch + XCTAssertTrue(openOther.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") + openOther.click() + XCTAssertTrue(waitForClickableRows(in: firstGrid), "\(otherTable) must load its rows") + + let editingTab = fixtureTabs(in: firstWindow).element(boundBy: 0) + XCTAssertTrue(waitUntilHittable(editingTab, timeout: 20), "The first fixture tab must be hittable") + editingTab.rightClick() + let moveItem = app.menuItems.matching(identifier: "Move Tab to New Window").firstMatch + XCTAssertTrue(moveItem.waitToExist(timeout: 5), "The tab menu must offer Move Tab to New Window") + moveItem.click() + XCTAssertTrue(waitForPredicate(timeout: 20) { app.windows.count >= 2 }, "The move must open a second window") + + let editingWindow = app.windows.matching(NSPredicate(format: "title CONTAINS %@", fixtureTable)).firstMatch + let editingGrid = editingWindow.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(waitForClickableRows(in: editingGrid), "The moved tab must load its rows in its own window") + editCell(row: 1, column: labelColumnPosition, in: editingGrid, app: app, to: heldValue) + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.label(row: 1, in: editingGrid) == self.heldValue }, + "The edit must land in the moved tab" + ) + + bringToFront(windowTitled: otherTable, in: app) + let savingWindow = app.windows.containing(.any, identifier: "editor-tab").firstMatch + let savingGrid = savingWindow.tables.matching(identifier: "data-grid").firstMatch + let savingTab = fixtureTabs(in: savingWindow).firstMatch + XCTAssertTrue(waitUntilHittable(savingTab, timeout: 20), "The first window must keep the second fixture tab") + clickAtCenter(savingTab) + XCTAssertTrue( + waitForPredicate(timeout: 30) { self.label(row: 2, in: savingGrid) == "Second" }, + "The second fixture tab opens unsorted" + ) + editCell(row: 2, column: labelColumnPosition, in: savingGrid, app: app, to: savedValue) + app.typeKey("s", modifierFlags: .command) + XCTAssertTrue( + waitForPredicate(timeout: 30) { self.label(row: 2, in: savingGrid) == self.savedValue }, + "The save must land in the second fixture tab" + ) + let otherTab = savingWindow.descendants(matching: .any) + .matching(identifier: "editor-tab") + .matching(NSPredicate(format: "label == %@", otherTable)) + .firstMatch + clickAtCenter(otherTab) + + bringToFront(windowTitled: fixtureTable, in: app) + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.label(row: 1, in: editingGrid) == self.heldValue }, + "The moved tab keeps its edit across the other window's save" + ) + XCTAssertEqual(label(row: 2, in: editingGrid), "Second", "Nothing reloads over an unsaved edit") + + app.typeKey("z", modifierFlags: .command) + + XCTAssertTrue( + waitForPredicate(timeout: 30) { self.label(row: 2, in: editingGrid) == self.savedValue }, + "Once its edit is undone, the moved tab must show the other window's save, it shows " + + "'\(label(row: 2, in: editingGrid) ?? "nil")'" + ) + XCTAssertEqual(label(row: 1, in: editingGrid), "First", "The undo must put the edited cell back") + } + + // MARK: - Helpers + + private func openSecondFixtureTab( + from tableRow: XCUIElement, + in window: XCUIElement, + grid: XCUIElement, + app: XCUIApplication + ) { + tableRow.rightClick() + let openInNewTab = app.menuItems["Open in New Tab"].firstMatch + XCTAssertTrue(openInNewTab.waitToExist(timeout: 15), "The sidebar must offer Open in New Tab") + openInNewTab.click() + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.fixtureTabs(in: window).count == 2 }, + "Open in New Tab must add a second tab on the same table" + ) + XCTAssertTrue(waitForClickableRows(in: grid), "The second tab must load its rows") + } + + /// The Window menu lists every open window by title and is reachable however the windows + /// overlap, which a click on a window behind another is not. + private func bringToFront(windowTitled title: String, in app: XCUIApplication) { + let windowMenu = app.menuBars.firstMatch.menuBarItems["Window"] + XCTAssertTrue(windowMenu.waitToExist(timeout: 20), "The app must publish its Window menu") + windowMenu.click() + let item = windowMenu.menuItems.matching(NSPredicate(format: "title CONTAINS %@", title)).firstMatch + XCTAssertTrue(item.waitToExist(timeout: 10), "The Window menu must list the window showing \(title)") + item.click() + } + + private func addColumnAndSave(in window: XCUIElement, grid: XCUIElement, app: XCUIApplication) { + let add = window.buttons["structure-footer-add"].firstMatch + XCTAssertTrue(add.waitToExist(timeout: 20), "The Columns tab must offer an add button") + XCTAssertTrue(waitForPredicate(timeout: 10) { add.isEnabled }, "SQLite adds a column with ALTER TABLE") + add.click() + + let newRow = 4 + XCTAssertTrue( + cellElement(row: newRow, column: 1, in: grid).waitToExist(timeout: 10), + "Adding a column stages a fourth row under the fixture's three" + ) + editCell(row: newRow, column: 1, in: grid, app: app, to: addedColumn) + editCell(row: newRow, column: 2, in: grid, app: app, to: "TEXT") + XCTAssertTrue( + waitForPredicate(timeout: 10) { self.value(row: newRow, column: 2, in: grid) == "TEXT" }, + "The new column must carry a type, it has '\(value(row: newRow, column: 2, in: grid) ?? "nil")'" + ) + + app.typeKey("s", modifierFlags: .command) + XCTAssertTrue( + waitForPredicate(timeout: 30) { !app.sheets.firstMatch.exists && !window.sheets.firstMatch.exists } + && waitForPredicate(timeout: 30) { !(self.value(row: newRow, column: 1, in: grid) ?? "").isEmpty }, + "The save must go through without a sheet" + ) + _ = waitForPredicate(timeout: 3) { false } + } + + /// The sub-tab labels carry item counts, so they are matched by prefix rather than exactly. + private func structureSubTab(named name: String, in window: XCUIElement) -> XCUIElement { + window.radioGroups["structure-tab-picker"].firstMatch + .radioButtons + .matching(NSPredicate(format: "label BEGINSWITH %@", name)) + .firstMatch + } + + private func ddlText(containing text: String, in window: XCUIElement) -> XCUIElement { + window.textViews.matching(NSPredicate(format: "value CONTAINS %@", text)).firstMatch + } + + private func openFixtureTable(in window: XCUIElement, grid: XCUIElement) -> XCUIElement { + let tableRow = objectBrowserRow(fixtureTable, in: window) + XCTAssertTrue(tableRow.waitToExist(timeout: 30), "The seeded fixture table must appear in the object browser") + tableRow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).doubleClick() + Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) + XCTAssertTrue(waitForClickableRows(in: grid), "The fixture table must load its rows") + return tableRow + } + + /// The sample opens `Track` in a tab of its own, so the fixture's tabs are picked out by title. + private func fixtureTabs(in window: XCUIElement) -> XCUIElementQuery { + window.descendants(matching: .any) + .matching(identifier: "editor-tab") + .matching(NSPredicate(format: "label == %@", fixtureTable)) + } + + private func header(_ column: String, in grid: XCUIElement) -> XCUIElement { + grid.buttons + .matching(NSPredicate(format: "label BEGINSWITH %@", "Column: \(column)")) + .firstMatch + } + + /// A header is clicked through a coordinate taken off the grid. XCUITest reports the header + /// element as never hittable however long it waits, and clicks the same point fine. + private func clickHeader(_ column: String, in grid: XCUIElement) throws { + let header = header(column, in: grid) + XCTAssertTrue(header.waitToExist(timeout: 30), "The grid must publish an \(column) header") + let frame = header.frame + XCTAssertTrue(frame.width > 0, "The \(column) header must be laid out") + point(at: frame, in: grid).click() + } + + private func editCell(row: Int, column: Int, in grid: XCUIElement, app: XCUIApplication, to value: String) { + let cell = cellElement(row: row, column: column, in: grid) + XCTAssertTrue(cell.waitToExist(timeout: 10), "Row \(row) must publish its cells") + point(at: cell.frame, in: grid).click() + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + app.typeKey("a", modifierFlags: .command) + app.typeText(value) + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + } + + private func point(at frame: CGRect, in grid: XCUIElement) -> XCUICoordinate { + let origin = grid.frame.origin + return grid.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: frame.midX - origin.x, dy: frame.midY - origin.y)) + } + + /// A cell with a picker publishes as a combo box and a plain one as static text, so both are + /// found by the identifier they share. + private func cellElement(row: Int, column: Int, in grid: XCUIElement) -> XCUIElement { + grid.descendants(matching: .any) + .matching(identifier: "DataGridCellAccessibilityView") + .matching(NSPredicate(format: "label BEGINSWITH %@", "Row \(row), column \(column): ")) + .firstMatch + } + + private func value(row: Int, column: Int, in grid: XCUIElement) -> String? { + let cell = cellElement(row: row, column: column, in: grid) + guard cell.exists else { return nil } + return cell.value as? String + } + + private func label(row: Int, in grid: XCUIElement) -> String? { + value(row: row, column: labelColumnPosition, in: grid) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index b0dc96c748..1fa5f16c5f 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -146,6 +146,8 @@ Some engines cannot skip rows and return a fixed maximum from one query. On thos On a table tab, sorting, filtering, and changing page or page size start at the first row. The horizontal scroll position stays. +Saving edits, importing rows, **Insert Document…**, a structure change and **Refresh Materialized View…** reload every tab showing the table they changed, in every window of the connection. The tab in front reloads at once and keeps its place. A tab behind it reloads when you switch to it. A tab with a cell open or unsaved edits is not interrupted. It reloads as soon as the cell is closed and its edits are saved, undone or discarded. Tabs on other tables stay as they are. A SQL file import reaches every table tab of the connection the same way. A statement run from the SQL editor reloads nothing, so press `Cmd+R` on the tabs it touched. + ## Copying Click a cell to select it, drag or `Shift`-click for a range, and click a row number for a whole row. The row-number gutter stays at the left edge on a table wider than the window, so whole rows are still selectable when the columns have scrolled past it. `Shift+Space` widens whatever is selected to every row it touches. Copy acts on the whole selection. diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 563d328d4a..966742435f 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -267,4 +267,4 @@ MongoDB structure is read-only, and inferred from the collection's first 200 doc ## Refreshing -**Database > Refresh** (`Cmd+R`) or the toolbar refresh button reloads structure from the server; changes made through TablePro refresh on their own. A refresh reads the tab's own database and schema, not the sidebar's current selection. +**Database > Refresh** (`Cmd+R`) or the toolbar refresh button reloads structure from the server; changes made through TablePro refresh on their own. A refresh reads the tab's own database and schema, not the sidebar's current selection. A tab with queued changes keeps them when another tab or window changes the table, and refreshes once they are saved, undone or discarded. diff --git a/project.yml b/project.yml index 75ae854669..839766a83d 100644 --- a/project.yml +++ b/project.yml @@ -582,6 +582,9 @@ targets: - Plugins/SQLiteDriverPlugin/SQLiteForeignKeyParents.swift - Plugins/SQLiteDriverPlugin/SQLiteMaintenance.swift - Plugins/SQLiteDriverPlugin/SQLiteAgentProtocol.swift + # The rest of TableProSQLiteCore needs `sqlite3_load_extension`, which macOS's SQLite, the one + # this bundle links, does not export. + - Packages/TableProCore/Sources/TableProSQLiteCore/SQLiteResultColumns.swift - Plugins/LibSQLDriverPlugin/LibSQLDefaultValue.swift - Plugins/LibSQLDriverPlugin/HranaHttpClient.swift - Plugins/CloudflareD1DriverPlugin/CloudflareD1DefaultValue.swift @@ -751,7 +754,7 @@ targets: dependencies: - target: TablePro - package: TableProCore - products: [TableProConnectionLibrary, TableProDocumentPath, TableProGeometry, TableProLogRedaction, TableProMSSQLCore, TableProNumberFormatting, TableProSQLGrammar, TableProSSHTransport, TableProTabular, TableProTabularIO, TableProWeaviateCore] + products: [CSQLite, TableProConnectionLibrary, TableProDocumentPath, TableProGeometry, TableProLogRedaction, TableProMSSQLCore, TableProNumberFormatting, TableProSQLGrammar, TableProSSHTransport, TableProTabular, TableProTabularIO, TableProWeaviateCore] # The Kafka integration suite drives the real driver, so the test target links what # the plugin target links: zstd for decompression and NIO for the transport. - package: zstd