diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8577acb..533ba8869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Value picker on a foreign key cell, listing rows from the referenced table with a label beside the key. (#2511) - Breakdown of a query's time into server, first row and transfer, behind the toolbar's duration readout. (#2503) - Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516) diff --git a/TablePro/Core/Database/ForeignKeyLookupQuery.swift b/TablePro/Core/Database/ForeignKeyLookupQuery.swift new file mode 100644 index 000000000..895e12594 --- /dev/null +++ b/TablePro/Core/Database/ForeignKeyLookupQuery.swift @@ -0,0 +1,113 @@ +// +// ForeignKeyLookupQuery.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// The list behind the foreign key value picker: keys from the referenced table, with a label +/// column beside them, narrowed by what the user typed. +/// +/// Pulled out of the popover because the predicate it builds is the part that has to be right on +/// every engine. `FilterSQLGenerator` owns the dialect, the case folding and the escaping; what is +/// decided here is which columns may carry a predicate at all, and that is a type question the +/// generator does not answer. A `LIKE` against a date column and an `=` against an integer column +/// with a word on the other side are both rejected outright by PostgreSQL, and either one turns the +/// whole picker into an error banner. +enum ForeignKeyLookupQuery { + static let rowLimit = 50 + + /// Nil when the term names nothing this table can be searched on, which is not the same as a + /// term that matches no row: there is no query to send, so the caller reports an empty list + /// rather than an engine error. + static func rows( + quotedTable: String, + key: ForeignKeyLookupColumn, + label: ForeignKeyLookupColumn?, + searchTerm: String, + dialect: SQLDialectDescriptor, + quoteIdentifier: @escaping (String) -> String + ) -> String? { + let selected = selectedColumns(key: key, label: label) + let selectList = selected.map { quoteIdentifier($0.name) }.joined(separator: ", ") + let generator = FilterSQLGenerator( + dialect: dialect, + columns: selected.map(\.name), + columnTypes: selected.map(\.type), + quoteIdentifier: quoteIdentifier + ) + + /// A referenced column may be a nullable `UNIQUE` one, and ascending order puts its NULLs + /// first. Without this the page can be fifty rows the picker then discards, because a NULL + /// key references nothing, and the list reads as empty while valid keys sit behind it. + var conditions = [generator.generateConditions( + from: [TableFilter(columnName: key.name, filterOperator: .isNotNull)] + )].filter { !$0.isEmpty } + + let term = searchTerm.trimmingCharacters(in: .whitespacesAndNewlines) + if !term.isEmpty { + let filters = searchFilters(key: key, label: label, term: term) + guard !filters.isEmpty else { return nil } + let search = generator.generateConditions(from: filters, logicMode: .or) + guard !search.isEmpty else { return nil } + conditions.append(filters.count > 1 ? "(\(search))" : search) + } + + var sql = "SELECT \(selectList) FROM \(quotedTable)" + if !conditions.isEmpty { + sql += " WHERE \(conditions.joined(separator: " AND "))" + } + return sql + " " + orderAndLimitClause(quotedKey: quoteIdentifier(key.name), dialect: dialect) + } + + static func selectedColumns( + key: ForeignKeyLookupColumn, + label: ForeignKeyLookupColumn? + ) -> [ForeignKeyLookupColumn] { + guard let label, label.name != key.name else { return [key] } + return [key, label] + } + + /// The key column carries the order, so the filler `offsetFetchOrderBy` a dialect supplies for + /// an unordered query is not needed here. T-SQL and Oracle put OFFSET/FETCH inside ORDER BY, so + /// the two travel together either way. + static func orderAndLimitClause(quotedKey: String, dialect: SQLDialectDescriptor) -> String { + switch dialect.paginationStyle { + case .offsetFetch: + return "ORDER BY \(quotedKey) OFFSET 0 ROWS FETCH NEXT \(rowLimit) ROWS ONLY" + case .limit: + return "ORDER BY \(quotedKey) LIMIT \(rowLimit)" + } + } + + private static func searchFilters( + key: ForeignKeyLookupColumn, + label: ForeignKeyLookupColumn?, + term: String + ) -> [TableFilter] { + var filters: [TableFilter] = [] + if let label, label.name != key.name, label.supportsPatternMatch { + filters.append(TableFilter(columnName: label.name, filterOperator: .contains, value: term)) + } + if let keyFilter = keyFilter(key: key, term: term) { + filters.append(keyFilter) + } + return filters + } + + /// A character key takes a substring match. Every other key takes equality, and only when the + /// term is a literal the engine can read as that type: `FilterSQLGenerator` quotes anything + /// else, and `id = 'abc'` on an integer, or a malformed literal on a `uuid`, is an error rather + /// than a query that returns nothing. A key that answers to neither is left out of the search. + private static func keyFilter(key: ForeignKeyLookupColumn, term: String) -> TableFilter? { + if key.supportsPatternMatch { + return TableFilter(columnName: key.name, filterOperator: .contains, value: term) + } + if ColumnTypeSQLQuoting.isNumericLiteral(term, for: key.type) { + return TableFilter(columnName: key.name, filterOperator: .equal, value: term) + } + guard key.isUuid, UUID(uuidString: term) != nil else { return nil } + return TableFilter(columnName: key.name, filterOperator: .equal, value: term) + } +} diff --git a/TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift b/TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift new file mode 100644 index 000000000..5ee1989e9 --- /dev/null +++ b/TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift @@ -0,0 +1,36 @@ +// +// ForeignKeyLabelColumn.swift +// TablePro +// + +import Foundation + +/// Which column of the referenced table reads as a row's name beside its key. +enum ForeignKeyLabelColumn { + static let preferredNames = ["name", "title", "label", "username", "email", "code", "description"] + + /// `preferred` is the user's stored choice, and it is honoured only when the table still carries + /// that column. The name reaches the query as a quoted identifier, so a preference left behind + /// by a dropped column, or one written into defaults by hand, must never become one. + /// + /// Only a column the search can actually pattern-match is offered automatically. A `LIKE` + /// against a date, an integer, a `uuid`, an enum or an array is a type error on a strict + /// engine, so a column the search cannot use is no use as a label either. A column the user + /// names for themselves is still taken on their word. + static func resolve( + columns: [ForeignKeyLookupColumn], + keyColumn: String, + preferred: String? + ) -> ForeignKeyLookupColumn? { + if let preferred, let stored = columns.first(where: { $0.name == preferred }) { + return stored + } + let candidates = columns.filter { $0.name != keyColumn && $0.supportsPatternMatch } + for name in preferredNames { + if let match = candidates.first(where: { $0.name.lowercased() == name }) { + return match + } + } + return candidates.first + } +} diff --git a/TablePro/Core/Services/Query/ForeignKeyLookupService.swift b/TablePro/Core/Services/Query/ForeignKeyLookupService.swift new file mode 100644 index 000000000..97407f49c --- /dev/null +++ b/TablePro/Core/Services/Query/ForeignKeyLookupService.swift @@ -0,0 +1,120 @@ +// +// ForeignKeyLookupService.swift +// TablePro +// +// The two reads behind the foreign key value picker: the referenced table's columns, and the +// rows matching what the user typed. +// + +import Foundation +import TableProPluginKit + +@MainActor +enum ForeignKeyLookupService { + struct Row: Identifiable, Hashable, Sendable { + let id: Int + let key: String + let label: String? + } + + enum LookupFailure: Error { + case noDialect + } + + private static let classifier = ColumnTypeClassifier() + + /// The referenced table's columns, for the label picker and for typing the search predicate. + /// + /// A metadata read, so it goes through `withMetadataDriver` like every other one. + static func referencedColumns( + in origin: DatabaseScope, + reference: ForeignKeyInfo + ) async throws -> [ForeignKeyLookupColumn] { + let scope = targetScope(from: origin, reference: reference) + let table = reference.referencedTable + let schema = reference.referencedSchema + let columns = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.fetchColumns(table: table, schema: schema) + } + return columns.map { + ForeignKeyLookupColumn(name: $0.name, type: classifier.classify(rawTypeName: $0.dataType)) + } + } + + /// Rows whose key or label matches `term`, capped at `ForeignKeyLookupQuery.rowLimit`. + /// + /// Empty when the term cannot be expressed as a predicate against either column, which is what + /// a word typed into a picker on an integer key with no text label comes to. No query is sent + /// in that case. + /// + /// Routed through `withMetadataDriver` rather than the session driver, which the single-row + /// preview uses: a search runs on every keystroke, and the session driver is the one carrying + /// the user's own query. + static func search( + in origin: DatabaseScope, + databaseType: DatabaseType, + reference: ForeignKeyInfo, + key: ForeignKeyLookupColumn, + label: ForeignKeyLookupColumn?, + term: String + ) async throws -> [Row] { + guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else { + throw LookupFailure.noDialect + } + let scope = targetScope(from: origin, reference: reference) + let table = reference.referencedTable + let schema = reference.referencedSchema + + return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + guard let query = ForeignKeyLookupQuery.rows( + quotedTable: quotedTable(table: table, schema: schema, driver: driver), + key: key, + label: label, + searchTerm: term, + dialect: dialect, + quoteIdentifier: driver.quoteIdentifier + ) else { + return [] + } + let result = try await driver.execute(query: query) + return rows(from: result, key: key, label: label) + } + } + + nonisolated private static func rows( + from result: QueryResult, + key: ForeignKeyLookupColumn, + label: ForeignKeyLookupColumn? + ) -> [Row] { + let labelIndex = ForeignKeyLookupQuery.selectedColumns(key: key, label: label).count > 1 ? 1 : nil + return result.rows.enumerated().compactMap { index, values in + guard let keyValue = values.first?.asText else { return nil } + let labelValue = labelIndex.flatMap { values.indices.contains($0) ? values[$0].asText : nil } + return Row(id: index, key: keyValue, label: labelValue) + } + } + + /// The scope of the table being picked from, taken from the grid's own scope rather than from + /// ambient browse state: a tab stays on the database it opened, while the sidebar and other + /// windows move, and resolving the database from session state is how a tab's read lands on + /// another database. + nonisolated static func targetScope(from origin: DatabaseScope, reference: ForeignKeyInfo) -> DatabaseScope { + guard let schema = reference.referencedSchema, !schema.isEmpty else { return origin } + return DatabaseScope(connectionId: origin.connectionId, database: origin.database, schema: schema) + } + + nonisolated static func tableScope(from origin: DatabaseScope, reference: ForeignKeyInfo) -> TableScope { + let scope = targetScope(from: origin, reference: reference) + return TableScope( + connectionId: scope.connectionId, + database: scope.database, + schema: scope.schema, + table: reference.referencedTable + ) + } + + nonisolated private static func quotedTable(table: String, schema: String?, driver: DatabaseDriver) -> String { + guard let schema, !schema.isEmpty else { return driver.quoteIdentifier(table) } + return "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(table))" + } +} diff --git a/TablePro/Core/Storage/ForeignKeyLabelColumnStore.swift b/TablePro/Core/Storage/ForeignKeyLabelColumnStore.swift new file mode 100644 index 000000000..90034ea96 --- /dev/null +++ b/TablePro/Core/Storage/ForeignKeyLabelColumnStore.swift @@ -0,0 +1,39 @@ +// +// ForeignKeyLabelColumnStore.swift +// TablePro +// + +import Foundation + +/// The label column a foreign key picker shows beside the key, remembered per referenced table. +/// +/// Keyed by the table being picked from rather than by the column pointing at it, because a name is +/// a property of the target: `orders.user_id` and `comments.user_id` both want `users.name`, and +/// setting it once for `users` is what a user means by remembering it. Device-local, so this needs +/// no CloudKit record type. +@MainActor +internal final class ForeignKeyLabelColumnStore { + static let shared = ForeignKeyLabelColumnStore() + + private let store: KeyValueStore + + init(defaults: KeyValueStore = AppStorageEnvironment.shared.defaults) { + store = defaults + } + + func labelColumn(for scope: TableScope) -> String? { + guard let data = store.dataValue(forKey: PreferenceKeys.foreignKeyLabelColumn(scope).name) else { + return nil + } + guard let name = String(bytes: data, encoding: .utf8), !name.isEmpty else { return nil } + return name + } + + func setLabelColumn(_ name: String?, for scope: TableScope) { + guard let name, !name.isEmpty else { + store.setDataValue(nil, forKey: PreferenceKeys.foreignKeyLabelColumn(scope).name) + return + } + store.setDataValue(Data(name.utf8), forKey: PreferenceKeys.foreignKeyLabelColumn(scope).name) + } +} diff --git a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift index b62baecab..7c46fdd75 100644 --- a/TablePro/Core/Storage/Preferences/PreferenceKeys.swift +++ b/TablePro/Core/Storage/Preferences/PreferenceKeys.swift @@ -31,4 +31,8 @@ enum PreferenceKeys { static func recentTables(connectionId: UUID) -> DefaultsKey<[RecentTableEntry]> { DefaultsKey("com.TablePro.recentTables." + connectionId.uuidString) } + + static func foreignKeyLabelColumn(_ scope: TableScope) -> DefaultsKey { + DefaultsKey("com.TablePro.foreignKey.labelColumn." + scope.storageComponent) + } } diff --git a/TablePro/Models/Schema/ForeignKeyConstraintSpan.swift b/TablePro/Models/Schema/ForeignKeyConstraintSpan.swift new file mode 100644 index 000000000..e7ad44c4d --- /dev/null +++ b/TablePro/Models/Schema/ForeignKeyConstraintSpan.swift @@ -0,0 +1,27 @@ +// +// ForeignKeyConstraintSpan.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Whether a foreign key column is one of several the same constraint spans. +/// +/// The value picker writes one column, so on a composite key it would offer a list of keys of which +/// only some pair with the values the row already holds in the constraint's other columns, and the +/// save is rejected on a reference the picker presented as valid. Such a column keeps the plain text +/// editor, which is what it had before the picker existed. +enum ForeignKeyConstraintSpan { + /// Columns of one constraint share its name and its referenced table. A driver that reports no + /// name cannot be asked, and an unnamed constraint is read as single-column: the answer there + /// costs a picker that would probably have worked, never a write the server refuses. + static func isMultiColumn(_ reference: ForeignKeyInfo, among all: [String: ForeignKeyInfo]) -> Bool { + guard !reference.name.isEmpty else { return false } + return all.values.contains { + $0.column != reference.column + && $0.name == reference.name + && $0.referencedTable == reference.referencedTable + } + } +} diff --git a/TablePro/Models/Schema/ForeignKeyLookupColumn.swift b/TablePro/Models/Schema/ForeignKeyLookupColumn.swift new file mode 100644 index 000000000..11278223e --- /dev/null +++ b/TablePro/Models/Schema/ForeignKeyLookupColumn.swift @@ -0,0 +1,59 @@ +// +// ForeignKeyLookupColumn.swift +// TablePro +// + +import Foundation + +/// A column of the table a foreign key points at, as the value picker needs it: the name to quote +/// and the type that decides which predicates can be built against it. +struct ForeignKeyLookupColumn: Equatable, Sendable, Identifiable { + let name: String + let type: ColumnType + + var id: String { name } + + /// Whether `LIKE` is defined for this column on a strict engine. + /// + /// `ColumnType` cannot answer it. `ColumnTypeClassifier` files `UUID`, `UNIQUEIDENTIFIER` and + /// `SQL_VARIANT` under `.text` by name, and every type it does not recognise under `.text` by + /// fallback, while PostgreSQL has no `~~` for `uuid`, for an enum or for an array. So the + /// question is asked of the raw type name and answered closed: a name that is not a known + /// character type carries no pattern predicate, which costs a search rather than an error on + /// every search. + var supportsPatternMatch: Bool { + guard case .text = type, let base = Self.baseTypeName(of: type.rawType) else { return false } + return Self.characterTypeNames.contains(base) + } + + /// A UUID takes no `LIKE`, but it does take equality against a literal the engine can parse. + var isUuid: Bool { + guard let base = Self.baseTypeName(of: type.rawType) else { return false } + return Self.uuidTypeNames.contains(base) + } + + private static let characterTypeNames: Set = [ + "TEXT", "VARCHAR", "CHAR", "NVARCHAR", "NCHAR", "NTEXT", + "VARCHAR2", "NVARCHAR2", "CLOB", "NCLOB", + "STRING", "FIXEDSTRING", "CHARACTER", "CHARACTER VARYING", + "BPCHAR", "CITEXT", + "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", + ] + + private static let uuidTypeNames: Set = ["UUID", "UNIQUEIDENTIFIER"] + + /// The same shape `ColumnTypeClassifier` reads: the wrappers off, the parameters off, uppercased. + static func baseTypeName(of rawType: String?) -> String? { + guard let rawType else { return nil } + var value = rawType.trimmingCharacters(in: .whitespaces) + for prefix in ["Nullable(", "LowCardinality("] where value.hasPrefix(prefix) && value.hasSuffix(")") { + value = String(value.dropFirst(prefix.count).dropLast()) + return baseTypeName(of: value) + } + if let paren = value.firstIndex(of: "(") { + value = String(value[value.startIndex ..< paren]) + } + let base = value.trimmingCharacters(in: .whitespaces).uppercased() + return base.isEmpty ? nil : base + } +} diff --git a/TablePro/Views/Results/CellInteractionResolver.swift b/TablePro/Views/Results/CellInteractionResolver.swift index 91198b116..41084fe5b 100644 --- a/TablePro/Views/Results/CellInteractionResolver.swift +++ b/TablePro/Views/Results/CellInteractionResolver.swift @@ -12,6 +12,7 @@ internal struct CellContext: Equatable { let isRowDeleted: Bool let isImmutableColumn: Bool let isBinaryValue: Bool + let isForeignKey: Bool let displayFormatOverride: ValueDisplayFormat? init( @@ -21,6 +22,7 @@ internal struct CellContext: Equatable { isRowDeleted: Bool, isImmutableColumn: Bool, isBinaryValue: Bool = false, + isForeignKey: Bool = false, displayFormatOverride: ValueDisplayFormat? = nil ) { self.columnType = columnType @@ -29,6 +31,7 @@ internal struct CellContext: Equatable { self.isRowDeleted = isRowDeleted self.isImmutableColumn = isImmutableColumn self.isBinaryValue = isBinaryValue + self.isForeignKey = isForeignKey self.displayFormatOverride = displayFormatOverride } } @@ -41,6 +44,7 @@ internal enum CellInteractionMode: Equatable { case editInline(value: String) case editOverlay(value: String) + case editForeignKey case editJson case editBlob @@ -67,10 +71,15 @@ internal struct CellInteractionResolver { } } + /// A writable foreign key column picks from the rows it points at rather than taking a typed + /// key on trust. Resolved here rather than ahead of the blob and structured-format branches, so + /// a foreign key that is also a blob, JSON or PHP-serialized value keeps the editor its content + /// needs, and a read-only cell keeps every viewer it has. private func plainText(for context: CellContext, isReadOnly: Bool) -> CellInteractionMode { if isReadOnly { return .viewInline(value: context.value ?? "NULL") } + if context.isForeignKey { return .editForeignKey } let value = context.value ?? "" if value.containsLineBreak { return .editOverlay(value: value) } return .editInline(value: value) diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index f1f85b08b..18f3c3634 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -294,12 +294,30 @@ class DataGridRowView: NSTableRowView { private func addForeignKeyMenuItems(to menu: NSMenu, dataColumnIndex: Int, tableRows: TableRows) { guard let coordinator, dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count else { return } let columnName = tableRows.columns[dataColumnIndex] - guard let fkInfo = tableRows.columnForeignKeys[columnName], - let cellValue = coordinator.cellValue(at: rowIndex, column: dataColumnIndex), - !cellValue.isEmpty else { return } + guard let fkInfo = tableRows.columnForeignKeys[columnName] else { return } + + /// Choosing a value is offered on an empty cell too, which is where it is needed most, + /// while previewing and following a key still need one to resolve. + let hasValue = coordinator.cellValue(at: rowIndex, column: dataColumnIndex)?.isEmpty == false + let canChoose = coordinator.canStartInlineEdit(row: rowIndex, columnIndex: dataColumnIndex) + && !ForeignKeyConstraintSpan.isMultiColumn(fkInfo, among: tableRows.columnForeignKeys) + guard hasValue || canChoose else { return } menu.addItem(NSMenuItem.separator()) + if canChoose { + let chooseItem = NSMenuItem( + title: String(format: String(localized: "Choose %@ Row…"), fkInfo.referencedTable), + action: #selector(chooseForeignKeyValue(_:)), + keyEquivalent: "" + ) + chooseItem.representedObject = dataColumnIndex + chooseItem.target = self + menu.addItem(chooseItem) + } + + guard hasValue else { return } + let previewItem = NSMenuItem( title: String(localized: "Preview Referenced Row"), action: #selector(previewForeignKey(_:)), @@ -739,6 +757,15 @@ class DataGridRowView: NSTableRowView { coordinator?.delegate?.dataGridShowRowAsJSON() } + @objc private func chooseForeignKeyValue(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int, + let coordinator, let tableView = coordinator.tableView, + let column = coordinator.tableColumnIndex(for: columnIndex) else { return } + coordinator.showForeignKeyPicker( + tableView: tableView, row: rowIndex, column: column, columnIndex: columnIndex + ) + } + @objc private func previewForeignKey(_ sender: NSMenuItem) { guard let columnIndex = sender.representedObject as? Int, let coordinator, let tableView = coordinator.tableView, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index d15514a4b..137315dec 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -40,6 +40,8 @@ extension TableViewCoordinator { beginCellEdit(row: row, tableColumnIndex: tableColumn) case .editOverlay(let value): showOverlayEditor(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex, value: value) + case .editForeignKey: + showForeignKeyPicker(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) case .editJson: showJSONEditorPopover(tableView: tableView, row: row, column: tableColumn, columnIndex: columnIndex) case .editBlob: @@ -65,6 +67,7 @@ extension TableViewCoordinator { isRowDeleted: changeManager.isRowDeleted(row), isImmutableColumn: immutable.contains(columnName), isBinaryValue: cellTypedValue(at: row, column: columnIndex).asBytes != nil, + isForeignKey: tableRows.columnForeignKeys[columnName] != nil, displayFormatOverride: override ) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 31b53eb13..9bb120e0b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -244,6 +244,62 @@ extension TableViewCoordinator { } } + /// The value picker a writable foreign key cell opens in place of the plain text editor. + /// + /// Falls back to that editor whenever the picker cannot be built, the way the array editor falls + /// back on a literal it cannot parse: an engine with no SQL dialect has nothing to search the + /// referenced table with, a column of a composite key cannot be picked on its own, and a cell + /// that opens nothing at all reads as a broken grid. + /// + /// `canStartInlineEdit` is asked again here because `CellInteractionResolver` knows only the + /// columns the plugin declares immutable. A generated column carrying foreign key metadata, + /// which SQLite allows, would otherwise open the picker and have its commit dropped by + /// `recordCellEdit`, closing the popover over a cell that never changed. + func showForeignKeyPicker(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { + guard presentsCell(row: row, tableColumnIndex: column) else { return } + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[columnIndex] + + guard let connectionId, + let databaseType, + let fkInfo = tableRows.columnForeignKeys[columnName], + canStartInlineEdit(row: row, columnIndex: columnIndex), + PluginManager.shared.sqlDialect(for: databaseType) != nil, + !ForeignKeyConstraintSpan.isMultiColumn(fkInfo, among: tableRows.columnForeignKeys) + else { + beginCellEdit(row: row, tableColumnIndex: column) + return + } + + let scope = DatabaseScope( + connectionId: connectionId, + database: databaseName ?? DatabaseManager.shared.browseScope(for: connectionId)?.database ?? "", + schema: schemaName + ) + + let currentValue = cellValue(at: row, column: columnIndex) + let isNullable = tableRows.columnNullable[columnName] ?? true + let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) + dismissActiveCellEditorPopover() + activeCellEditorPopover = PopoverPresenter.show( + relativeTo: cellRect, + of: tableView + ) { [weak self] dismiss in + ForeignKeyPickerView( + scope: scope, + databaseType: databaseType, + fkInfo: fkInfo, + currentValue: currentValue, + isNullable: isNullable, + onCommit: { newValue in + self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) + }, + onDismiss: dismiss + ) + } + } + func showSetPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard presentsCell(row: row, tableColumnIndex: column) else { return } let tableRows = tableRowsProvider() diff --git a/TablePro/Views/Results/ForeignKeyPickerEntry.swift b/TablePro/Views/Results/ForeignKeyPickerEntry.swift new file mode 100644 index 000000000..e001454cd --- /dev/null +++ b/TablePro/Views/Results/ForeignKeyPickerEntry.swift @@ -0,0 +1,102 @@ +// +// ForeignKeyPickerEntry.swift +// TablePro +// + +import Foundation + +/// One line of the foreign key picker's list: the term as typed, or a row from the referenced table. +internal enum ForeignKeyPickerEntry: Identifiable, Hashable { + case literal(String) + case row(ForeignKeyLookupService.Row) + + /// A row is identified by its position, never by its key. A foreign key may reference one + /// column of a composite unique key, which is not unique on its own, and two entries sharing an + /// id is undefined behaviour in the `List` that renders them. + var id: String { + switch self { + case .literal(let text): + return "literal\u{1}\(text)" + case .row(let row): + return "row\u{1}\(row.id)" + } + } + + /// The term leads the list only when it could be a key, so `Return` on a search that narrowed + /// the list picks the row it narrowed to. + /// + /// A word typed into a picker on a numeric key is a search for a label, never a key: offering + /// `Use "Big"` at the top of a list of one matching album, selected, made the obvious keystroke + /// write `Big` into an integer column and left the one row the user was looking at unpicked. + static func acceptsTypedKey(_ term: String, keyType: ColumnType?) -> Bool { + guard let keyType else { return true } + switch keyType { + case .integer, .decimal: + return ColumnTypeSQLQuoting.isNumericLiteral(term, for: keyType) + default: + return true + } + } + + static func build( + rows: [ForeignKeyLookupService.Row], + term: String, + keyType: ColumnType? + ) -> [ForeignKeyPickerEntry] { + var entries: [ForeignKeyPickerEntry] = [] + let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, + !rows.contains(where: { $0.key == trimmed }), + acceptsTypedKey(trimmed, keyType: keyType) { + entries.append(.literal(trimmed)) + } + entries.append(contentsOf: rows.map { .row($0) }) + return entries + } + + /// The typed term first, then the row that already carries it, then the head of a narrowed list. + /// + /// The exact-key case is what stops a key being passed over: a search for `42` also matches + /// every label containing 42, and those sort ahead of it whenever the key is longer. + /// + /// With nothing typed the cell's own value is selected instead, and nothing at all when the + /// list does not carry it. Selecting the head of an unfiltered list there put the first row of + /// the referenced table under `Return`, so opening the picker and pressing it wrote a key the + /// user never chose over the one already in the cell. + static func defaultSelection( + in entries: [ForeignKeyPickerEntry], + term: String, + currentValue: String? + ) -> ForeignKeyPickerEntry.ID? { + if let literal = entries.first(where: { $0.isLiteral }) { + return literal.id + } + let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return currentValue.flatMap { value in entries.first { $0.matchesKey(value) }?.id } + } + if let exact = entries.first(where: { $0.matchesKey(trimmed) }) { + return exact.id + } + return entries.first?.id + } + + private var isLiteral: Bool { + if case .literal = self { return true } + return false + } + + private func matchesKey(_ value: String) -> Bool { + if case .row(let row) = self { return row.key == value } + return false + } + + var committedValue: String { + switch self { + case .literal(let text): + return text + case .row(let row): + return row.key + } + } +} diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift new file mode 100644 index 000000000..dab9ef63f --- /dev/null +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -0,0 +1,351 @@ +// +// ForeignKeyPickerView.swift +// TablePro +// +// The value picker a foreign key cell opens instead of the plain text editor. +// + +import os +import SwiftUI +import TableProPluginKit + +struct ForeignKeyPickerView: View { + let scope: DatabaseScope + let databaseType: DatabaseType + let fkInfo: ForeignKeyInfo + let currentValue: String? + let isNullable: Bool + let onCommit: (String?) -> Void + let onDismiss: () -> Void + + @State private var searchText = "" + @State private var columns: [ForeignKeyLookupColumn] = [] + @State private var labelColumnName: String? + @State private var rows: [ForeignKeyLookupService.Row] = [] + @State private var isLoading = true + @State private var hasLoadedColumns = false + @State private var hasSearched = false + @State private var errorMessage: String? + @State private var selection: ForeignKeyPickerEntry.ID? + + private static let logger = Logger(subsystem: "com.TablePro", category: "ForeignKeyPicker") + private static let searchDebounce = Duration.milliseconds(200) + + var body: some View { + VStack(spacing: 0) { + header + Divider() + searchField + Divider() + content + Divider() + footer + } + .frame(width: 360) + .task { + await loadColumns() + } + .task(id: SearchKey(term: searchText, label: labelColumnName, isReady: hasLoadedColumns)) { + await runSearch() + } + } + + // MARK: - Header + + private var referencedTableDisplay: String { + guard let schema = fkInfo.referencedSchema, !schema.isEmpty else { return fkInfo.referencedTable } + return "\(schema).\(fkInfo.referencedTable)" + } + + private var header: some View { + HStack(spacing: 6) { + Text("\(fkInfo.column) → \(referencedTableDisplay).\(fkInfo.referencedColumn)") + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + if isLoading { + ProgressView() + .controlSize(.small) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + // MARK: - Search + + private var searchField: some View { + NativeSearchField( + text: $searchText, + placeholder: String(format: String(localized: "Search %@"), fkInfo.referencedTable), + onMoveUp: { moveSelection(by: -1) }, + onMoveDown: { moveSelection(by: 1) }, + onSubmit: commitSelection, + focusOnAppear: true, + accessibilityIdentifier: "fk-picker-search" + ) + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .frame(height: 220) + } else if entries.isEmpty { + emptyState + } else { + entryList + } + } + + @ViewBuilder + private var emptyState: some View { + Group { + if hasSearched { + Text("No matching rows") + } else { + Text("Loading rows…") + } + } + .foregroundStyle(.secondary) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 220) + } + + private var entryList: some View { + ScrollViewReader { proxy in + List(entries, selection: $selection) { entry in + row(for: entry) + .contentShape(Rectangle()) + .onTapGesture { commit(entry) } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .frame(height: 220) + .onChange(of: selection) { _, newValue in + guard let newValue else { return } + proxy.scrollTo(newValue) + } + } + } + + @ViewBuilder + private func row(for entry: ForeignKeyPickerEntry) -> some View { + switch entry { + case .literal(let text): + HStack(spacing: 8) { + Image(systemName: "square.and.pencil") + .foregroundStyle(.secondary) + Text(String(format: String(localized: "Use “%@”"), text)) + .lineLimit(1) + .truncationMode(.middle) + } + case .row(let row): + HStack(spacing: 8) { + Image(systemName: "checkmark") + .foregroundStyle(.secondary) + .opacity(row.key == currentValue ? 1 : 0) + Text(row.key) + .font(ThemeEngine.shared.valueFontSwiftUI) + .lineLimit(1) + if let label = row.label, !label.isEmpty { + Text(label) + .font(ThemeEngine.shared.valueFontSwiftUI) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + } + } + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 8) { + Picker(selection: labelBinding) { + Text("None").tag(String?.none) + ForEach(columns) { column in + Text(column.name).tag(String?.some(column.name)) + } + } label: { + Text("Label") + } + .pickerStyle(.menu) + .controlSize(.small) + .disabled(columns.isEmpty) + + Spacer(minLength: 4) + + if isCapped { + Text(String(format: String(localized: "First %d"), ForeignKeyLookupQuery.rowLimit)) + .font(.caption) + .foregroundStyle(.secondary) + } + + if isNullable { + Button { + onCommit(nil) + onDismiss() + } label: { + Text("Set NULL") + } + .controlSize(.small) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + private var isCapped: Bool { + rows.count >= ForeignKeyLookupQuery.rowLimit + } + + private var labelBinding: Binding { + Binding( + get: { labelColumnName }, + set: { newValue in + labelColumnName = newValue + ForeignKeyLabelColumnStore.shared.setLabelColumn( + newValue, + for: ForeignKeyLookupService.tableScope(from: scope, reference: fkInfo) + ) + } + ) + } + + // MARK: - Entries + + private var keyColumn: ForeignKeyLookupColumn? { + columns.first { $0.name == fkInfo.referencedColumn } + } + + private var entries: [ForeignKeyPickerEntry] { + ForeignKeyPickerEntry.build(rows: rows, term: searchText, keyType: keyColumn?.type) + } + + /// Return commits whatever the list has selected, which `defaultSelection` puts on the typed + /// term when the term could be a key and on the matching row when it could not. A selection + /// belongs to the results it was computed from, so typing drops it before the debounce even + /// starts: `Return` during an in-flight search must never commit the row the last one found. + /// + /// The fallback covers a term that matched nothing at all, and applies the same rule: a word on + /// a numeric key is a search that failed, not a value to write. + private func commitSelection() { + if let selection, let entry = entries.first(where: { $0.id == selection }) { + commit(entry) + return + } + let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty, + ForeignKeyPickerEntry.acceptsTypedKey(term, keyType: keyColumn?.type) else { return } + onCommit(term) + onDismiss() + } + + private func commit(_ entry: ForeignKeyPickerEntry) { + onCommit(entry.committedValue) + onDismiss() + } + + private func moveSelection(by offset: Int) { + let available = entries + guard !available.isEmpty else { return } + guard let selection, let index = available.firstIndex(where: { $0.id == selection }) else { + self.selection = offset > 0 ? available.first?.id : available.last?.id + return + } + let target = index + offset + guard available.indices.contains(target) else { return } + self.selection = available[target].id + } + + // MARK: - Loading + + private func loadColumns() async { + do { + let fetched = try await ForeignKeyLookupService.referencedColumns(in: scope, reference: fkInfo) + guard !Task.isCancelled else { return } + let stored = ForeignKeyLabelColumnStore.shared.labelColumn( + for: ForeignKeyLookupService.tableScope(from: scope, reference: fkInfo) + ) + labelColumnName = ForeignKeyLabelColumn.resolve( + columns: fetched, + keyColumn: fkInfo.referencedColumn, + preferred: stored + )?.name + columns = fetched + hasLoadedColumns = true + } catch { + Self.logger.error("Referenced column read failed: \(error.localizedDescription)") + isLoading = false + errorMessage = String(localized: "Could not read the referenced table") + } + } + + private func runSearch() async { + guard hasLoadedColumns else { return } + guard let key = keyColumn else { + isLoading = false + hasSearched = true + errorMessage = String( + format: String(localized: "%@ has no column named %@"), + referencedTableDisplay, + fkInfo.referencedColumn + ) + return + } + + selection = nil + + if hasSearched { + try? await Task.sleep(for: Self.searchDebounce) + guard !Task.isCancelled else { return } + } + + isLoading = true + errorMessage = nil + do { + let found = try await ForeignKeyLookupService.search( + in: scope, + databaseType: databaseType, + reference: fkInfo, + key: key, + label: columns.first { $0.name == labelColumnName }, + term: searchText + ) + guard !Task.isCancelled else { return } + rows = found + } catch { + guard !Task.isCancelled else { return } + Self.logger.error("Foreign key row search failed: \(error.localizedDescription)") + rows = [] + errorMessage = String(localized: "Could not search the referenced table") + } + isLoading = false + hasSearched = true + selection = ForeignKeyPickerEntry.defaultSelection( + in: entries, + term: searchText, + currentValue: currentValue + ) + } +} + +private struct SearchKey: Equatable { + let term: String + let label: String? + let isReady: Bool +} diff --git a/TableProTests/Core/Database/ForeignKeyConstraintSpanTests.swift b/TableProTests/Core/Database/ForeignKeyConstraintSpanTests.swift new file mode 100644 index 000000000..263cd3a44 --- /dev/null +++ b/TableProTests/Core/Database/ForeignKeyConstraintSpanTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("ForeignKeyConstraintSpan") +struct ForeignKeyConstraintSpanTests { + private func info( + name: String, + column: String, + referencedTable: String = "orders", + referencedColumn: String? = nil + ) -> ForeignKeyInfo { + ForeignKeyInfo( + name: name, + column: column, + referencedTable: referencedTable, + referencedColumn: referencedColumn ?? column + ) + } + + private func byColumn(_ infos: [ForeignKeyInfo]) -> [String: ForeignKeyInfo] { + Dictionary(infos.map { ($0.column, $0) }, uniquingKeysWith: { first, _ in first }) + } + + @Test("A single-column constraint spans one column") + func singleColumnConstraint() { + let reference = info(name: "fk_order", column: "order_id") + #expect(!ForeignKeyConstraintSpan.isMultiColumn(reference, among: byColumn([reference]))) + } + + @Test("Two columns of one constraint span it together") + func compositeConstraint() { + let first = info(name: "fk_line", column: "order_id") + let second = info(name: "fk_line", column: "line_no") + let all = byColumn([first, second]) + #expect(ForeignKeyConstraintSpan.isMultiColumn(first, among: all)) + #expect(ForeignKeyConstraintSpan.isMultiColumn(second, among: all)) + } + + @Test("Two constraints on one table stay separate") + func separateConstraintsOnOneTable() { + let first = info(name: "fk_billing", column: "billing_order_id") + let second = info(name: "fk_shipping", column: "shipping_order_id") + #expect(!ForeignKeyConstraintSpan.isMultiColumn(first, among: byColumn([first, second]))) + } + + /// A name a driver reuses across tables is not evidence of one constraint, so the referenced + /// table has to agree too. + @Test("The same name against a different table is a different constraint") + func sameNameDifferentTable() { + let first = info(name: "fk", column: "order_id", referencedTable: "orders") + let second = info(name: "fk", column: "user_id", referencedTable: "users") + #expect(!ForeignKeyConstraintSpan.isMultiColumn(first, among: byColumn([first, second]))) + } + + /// A driver that reports no name cannot be asked, and the answer costs a picker rather than a + /// write the server refuses. + @Test("An unnamed constraint is read as single-column") + func unnamedConstraint() { + let first = info(name: "", column: "order_id") + let second = info(name: "", column: "line_no") + #expect(!ForeignKeyConstraintSpan.isMultiColumn(first, among: byColumn([first, second]))) + } +} diff --git a/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift b/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift new file mode 100644 index 000000000..94c3f87cf --- /dev/null +++ b/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift @@ -0,0 +1,209 @@ +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("ForeignKeyLookupQuery") +struct ForeignKeyLookupQueryTests { + private let key = ForeignKeyLookupColumn(name: "ArtistId", type: .integer(rawType: "INTEGER")) + private let label = ForeignKeyLookupColumn(name: "Name", type: .text(rawType: "NVARCHAR(120)")) + + private func dialect( + paginationStyle: SQLDialectDescriptor.PaginationStyle = .limit, + caseSensitivityStyle: SQLDialectDescriptor.CaseSensitivityStyle = .collationDefined, + likeEscapeStyle: SQLDialectDescriptor.LikeEscapeStyle = .explicit + ) -> SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [], + functions: [], + dataTypes: [], + likeEscapeStyle: likeEscapeStyle, + paginationStyle: paginationStyle, + caseSensitivityStyle: caseSensitivityStyle + ) + } + + private func quote(_ name: String) -> String { + "\"\(name)\"" + } + + private func rows( + key: ForeignKeyLookupColumn? = nil, + label: ForeignKeyLookupColumn?, + term: String, + dialect: SQLDialectDescriptor? = nil + ) -> String? { + ForeignKeyLookupQuery.rows( + quotedTable: "\"Artist\"", + key: key ?? self.key, + label: label, + searchTerm: term, + dialect: dialect ?? self.dialect(), + quoteIdentifier: quote + ) + } + + @Test("An empty term lists the first rows in key order") + func emptyTermListsTheFirstRows() { + #expect( + rows(label: label, term: "") == + "SELECT \"ArtistId\", \"Name\" FROM \"Artist\" WHERE \"ArtistId\" IS NOT NULL " + + "ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + @Test("Whitespace is not a search term") + func whitespaceIsNotATerm() { + #expect(rows(label: label, term: " ") == rows(label: label, term: "")) + } + + /// T-SQL and Oracle parse OFFSET/FETCH as part of ORDER BY, and the key column supplies the + /// order here, so the dialect's filler ORDER BY is neither needed nor emitted. + @Test("An OFFSET/FETCH dialect orders by the key rather than by the dialect's filler") + func offsetFetchOrdersByTheKey() { + let sql = rows(label: label, term: "", dialect: dialect(paginationStyle: .offsetFetch)) + #expect( + sql == "SELECT \"ArtistId\", \"Name\" FROM \"Artist\" WHERE \"ArtistId\" IS NOT NULL " + + "ORDER BY \"ArtistId\" OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY" + ) + } + + @Test("A table with no label column selects the key alone") + func noLabelSelectsTheKeyAlone() { + #expect( + rows(label: nil, term: "") == + "SELECT \"ArtistId\" FROM \"Artist\" WHERE \"ArtistId\" IS NOT NULL " + + "ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + @Test("A label that is the key is not selected twice") + func labelEqualToTheKeyIsSelectedOnce() { + #expect( + rows(label: key, term: "") == + "SELECT \"ArtistId\" FROM \"Artist\" WHERE \"ArtistId\" IS NOT NULL " + + "ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + @Test("A numeric term matches the key exactly and the label loosely") + func numericTermMatchesBothColumns() { + #expect( + rows(label: label, term: "42") == + "SELECT \"ArtistId\", \"Name\" FROM \"Artist\" " + + "WHERE \"ArtistId\" IS NOT NULL " + + "AND (\"Name\" LIKE '%42%' ESCAPE '!' OR \"ArtistId\" = 42) " + + "ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + /// `FilterSQLGenerator` quotes a term it cannot read as a number, and `ArtistId = 'rock'` is + /// `operator does not exist: integer = text` on PostgreSQL rather than a query returning + /// nothing. A word therefore reaches the label column alone. + @Test("A word never reaches a numeric key column") + func wordSkipsTheNumericKey() { + #expect( + rows(label: label, term: "rock") == + "SELECT \"ArtistId\", \"Name\" FROM \"Artist\" " + + "WHERE \"ArtistId\" IS NOT NULL AND \"Name\" LIKE '%rock%' ESCAPE '!' " + + "ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + @Test("A text key takes a substring match of its own") + func textKeyTakesASubstringMatch() { + let textKey = ForeignKeyLookupColumn(name: "Code", type: .text(rawType: "VARCHAR(8)")) + #expect( + rows(key: textKey, label: label, term: "rock") == + "SELECT \"Code\", \"Name\" FROM \"Artist\" " + + "WHERE \"Code\" IS NOT NULL " + + "AND (\"Name\" LIKE '%rock%' ESCAPE '!' OR \"Code\" LIKE '%rock%' ESCAPE '!') " + + "ORDER BY \"Code\" LIMIT 50" + ) + } + + /// A `LIKE` against a date column is a type error on a strict engine, so a label the user chose + /// that is not text is shown beside the key and never searched. + @Test("A label column that is not text carries no predicate") + func nonTextLabelIsNotSearched() { + let dateLabel = ForeignKeyLookupColumn(name: "ReleasedOn", type: .date(rawType: "DATE")) + #expect( + rows(label: dateLabel, term: "42") == + "SELECT \"ArtistId\", \"ReleasedOn\" FROM \"Artist\" " + + "WHERE \"ArtistId\" IS NOT NULL AND \"ArtistId\" = 42 " + + "ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + /// Nil rather than a query with no predicate: an unsearchable term means no matches, and + /// listing the whole table instead would answer a question the user did not ask. + @Test("A term no column can carry produces no query at all") + func unsearchableTermProducesNoQuery() { + let dateLabel = ForeignKeyLookupColumn(name: "ReleasedOn", type: .date(rawType: "DATE")) + #expect(rows(label: dateLabel, term: "rock") == nil) + #expect(rows(label: nil, term: "rock") == nil) + } + + /// `ColumnTypeClassifier` files `UUID` under `.text`, and PostgreSQL has no `~~` for `uuid`, so + /// a pattern predicate there turned every search on a UUID key into an error. + @Test("A UUID key takes equality, never LIKE") + func uuidKeyTakesEquality() { + let uuidKey = ForeignKeyLookupColumn(name: "id", type: .text(rawType: "uuid")) + let value = "2f9d0e6c-1a4b-4c3d-9e8f-0a1b2c3d4e5f" + let sql = rows(key: uuidKey, label: nil, term: value) + #expect(sql?.contains("\"id\" = '\(value)'") == true) + #expect(sql?.contains("LIKE") == false) + } + + @Test("A term that is not a UUID never reaches a UUID key") + func malformedUuidSkipsTheKey() { + let uuidKey = ForeignKeyLookupColumn(name: "id", type: .text(rawType: "uuid")) + #expect(rows(key: uuidKey, label: nil, term: "2f9d") == nil) + let withLabel = rows(key: uuidKey, label: label, term: "2f9d") + #expect(withLabel?.contains("\"Name\" LIKE '%2f9d%'") == true) + #expect(withLabel?.contains("\"id\" =") == false) + #expect(withLabel?.contains("\"id\" LIKE") == false) + } + + /// The classifier files every type it does not recognise under `.text`, so the question is + /// asked of the raw type name and answered closed. + @Test("A type the classifier only guessed at carries no pattern predicate") + func unknownTextTypeIsNotPatternMatched() { + let inetKey = ForeignKeyLookupColumn(name: "addr", type: .text(rawType: "inet")) + #expect(rows(key: inetKey, label: nil, term: "10.0") == nil) + } + + @Test("An enum or an array label is shown but never searched") + func enumLabelIsNotSearched() { + let enumLabel = ForeignKeyLookupColumn(name: "status", type: .enumType(rawType: "status_t", values: nil)) + #expect(rows(label: enumLabel, term: "rock") == nil) + #expect(rows(label: enumLabel, term: "42")?.contains("\"status\" LIKE") == false) + } + + @Test("A PostgreSQL dialect searches with ILIKE") + func ilikeDialectUsesILike() { + let sql = rows(label: label, term: "rock", dialect: dialect(caseSensitivityStyle: .ilikeOperator)) + #expect(sql?.contains("\"Name\" ILIKE '%rock%' ESCAPE '!'") == true) + } + + @Test("A quote in the term is escaped rather than closing the literal") + func quoteInTermIsEscaped() { + let sql = rows(label: label, term: "O'Brien") + #expect(sql?.contains("LIKE '%O''Brien%'") == true) + } + + @Test("A wildcard in the term is matched literally") + func wildcardInTermIsEscaped() { + let sql = rows(label: label, term: "50%") + #expect(sql?.contains("LIKE '%50!%%' ESCAPE '!'") == true) + } + + @Test("MySQL escapes a wildcard with its own backslash convention") + func mysqlWildcardEscaping() { + let sql = rows(label: label, term: "50%", dialect: dialect(likeEscapeStyle: .implicit)) + #expect(sql?.contains("LIKE '%50\\\\%%'") == true) + #expect(sql?.contains("ESCAPE") == false) + } +} diff --git a/TableProTests/Core/Services/ForeignKeyLabelColumnTests.swift b/TableProTests/Core/Services/ForeignKeyLabelColumnTests.swift new file mode 100644 index 000000000..527560d4c --- /dev/null +++ b/TableProTests/Core/Services/ForeignKeyLabelColumnTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("ForeignKeyLabelColumn") +struct ForeignKeyLabelColumnTests { + private let key = ForeignKeyLookupColumn(name: "id", type: .integer(rawType: "INTEGER")) + + private func text(_ name: String) -> ForeignKeyLookupColumn { + ForeignKeyLookupColumn(name: name, type: .text(rawType: "VARCHAR(64)")) + } + + private func resolve(_ columns: [ForeignKeyLookupColumn], preferred: String? = nil) -> String? { + ForeignKeyLabelColumn.resolve(columns: columns, keyColumn: "id", preferred: preferred)?.name + } + + @Test("A preferred name wins over the column order") + func preferredNameWins() { + #expect(resolve([key, text("slug"), text("name")]) == "name") + } + + @Test("The preferred names are tried in their own order, not the table's") + func preferredNamesKeepTheirOwnOrder() { + #expect(resolve([key, text("description"), text("title")]) == "title") + } + + @Test("A preferred name matches whatever case the column is declared in") + func preferredNameIgnoresCase() { + #expect(resolve([key, text("Slug"), text("Name")]) == "Name") + } + + @Test("Without a preferred name the first text column is taken") + func firstTextColumnIsTheFallback() { + #expect(resolve([key, text("slug"), text("bio")]) == "slug") + } + + @Test("A table of nothing but the key has no label") + func keyOnlyTableHasNoLabel() { + #expect(resolve([key]) == nil) + } + + /// A `LIKE` against a date or an integer is a type error on a strict engine, so a column the + /// search cannot use is no use as a label either. + @Test("A column that is not text is never picked automatically") + func nonTextColumnsAreNotPicked() { + let columns = [ + key, + ForeignKeyLookupColumn(name: "created_at", type: .timestamp(rawType: "TIMESTAMP")), + ForeignKeyLookupColumn(name: "score", type: .decimal(rawType: "NUMERIC")), + ] + #expect(resolve(columns) == nil) + } + + @Test("A stored choice wins over every heuristic") + func storedChoiceWins() { + #expect(resolve([key, text("name"), text("email")], preferred: "email") == "email") + } + + /// The stored name reaches the query as a quoted identifier. A preference left behind by a + /// dropped column, or written into defaults by hand, must never become one. + @Test("A stored choice the table no longer has falls back to the heuristic") + func storedChoiceMustExist() { + #expect(resolve([key, text("name")], preferred: "dropped_column") == "name") + #expect(resolve([key, text("name")], preferred: "\" OR 1=1 --") == "name") + } + + /// PostgreSQL refuses `LIKE` on an enum or an array, so neither can carry the picker's search + /// and neither is offered as a label on its own. + @Test("An enum or an array column is not picked automatically") + func enumAndArrayColumnsAreNotPicked() { + let columns = [ + key, + ForeignKeyLookupColumn(name: "status", type: .enumType(rawType: "status_t", values: nil)), + ForeignKeyLookupColumn(name: "tags", type: .array(rawType: "text[]", element: .text(rawType: "text"))), + ] + #expect(resolve(columns) == nil) + #expect(resolve(columns + [text("name")]) == "name") + } + + @Test("A stored choice may be a column the heuristic would have skipped") + func storedChoiceMayBeNonText() { + let columns = [key, text("name"), ForeignKeyLookupColumn(name: "score", type: .decimal(rawType: "NUMERIC"))] + #expect(resolve(columns, preferred: "score") == "score") + } +} diff --git a/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift b/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift new file mode 100644 index 000000000..7d4fedbd6 --- /dev/null +++ b/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("ForeignKeyLabelColumnStore") +@MainActor +struct ForeignKeyLabelColumnStoreTests { + private func makeStore() throws -> ForeignKeyLabelColumnStore { + let defaults = try #require(UserDefaults(suiteName: "ForeignKeyLabelColumnTests.\(UUID().uuidString)")) + return ForeignKeyLabelColumnStore(defaults: defaults) + } + + private func scope( + connectionId: UUID, + database: String? = "chinook", + schema: String? = nil, + table: String = "Artist" + ) -> TableScope { + TableScope(connectionId: connectionId, database: database, schema: schema, table: table) + } + + @Test("A table with no stored choice answers nil") + func unsetScopeAnswersNil() throws { + let store = try makeStore() + #expect(store.labelColumn(for: scope(connectionId: UUID())) == nil) + } + + @Test("A stored choice comes back") + func storedChoiceRoundTrips() throws { + let store = try makeStore() + let target = scope(connectionId: UUID()) + store.setLabelColumn("Name", for: target) + #expect(store.labelColumn(for: target) == "Name") + } + + @Test("Nil clears the stored choice") + func nilClearsTheChoice() throws { + let store = try makeStore() + let target = scope(connectionId: UUID()) + store.setLabelColumn("Name", for: target) + store.setLabelColumn(nil, for: target) + #expect(store.labelColumn(for: target) == nil) + } + + @Test("An empty name clears rather than storing a blank") + func emptyNameClears() throws { + let store = try makeStore() + let target = scope(connectionId: UUID()) + store.setLabelColumn("Name", for: target) + store.setLabelColumn("", for: target) + #expect(store.labelColumn(for: target) == nil) + } + + /// The choice belongs to the table being picked from, so two tables of the same name in + /// different connections, databases or schemas keep their own. + @Test("Each table keeps its own choice") + func choiceIsScopedToTheTable() throws { + let store = try makeStore() + let connection = UUID() + store.setLabelColumn("Name", for: scope(connectionId: connection)) + store.setLabelColumn("Title", for: scope(connectionId: connection, table: "Album")) + store.setLabelColumn("Email", for: scope(connectionId: connection, database: "other")) + store.setLabelColumn("Code", for: scope(connectionId: UUID())) + + #expect(store.labelColumn(for: scope(connectionId: connection)) == "Name") + #expect(store.labelColumn(for: scope(connectionId: connection, table: "Album")) == "Title") + #expect(store.labelColumn(for: scope(connectionId: connection, database: "other")) == "Email") + } + + @Test("A name with a dot or a quote survives the key encoding") + func awkwardNamesSurvive() throws { + let store = try makeStore() + let target = scope(connectionId: UUID(), schema: "public.v2", table: "user\"s") + store.setLabelColumn("full name", for: target) + #expect(store.labelColumn(for: target) == "full name") + #expect(store.labelColumn(for: scope(connectionId: target.connectionId)) == nil) + } +} diff --git a/TableProTests/Views/Results/CellInteractionResolverTests.swift b/TableProTests/Views/Results/CellInteractionResolverTests.swift index 80a37057e..c06fa0591 100644 --- a/TableProTests/Views/Results/CellInteractionResolverTests.swift +++ b/TableProTests/Views/Results/CellInteractionResolverTests.swift @@ -227,6 +227,79 @@ struct CellInteractionResolverBinaryTests { } } +@Suite("CellInteractionResolver - foreign key columns") +struct CellInteractionResolverForeignKeyTests { + private let resolver = CellInteractionResolver() + + @Test("a writable foreign key cell opens the value picker") + func writableForeignKeyOpensThePicker() { + let context = ContextFactory.make( + value: "42", columnType: .integer(rawType: "INTEGER"), isTableEditable: true, isForeignKey: true + ) + #expect(resolver.resolve(context) == .editForeignKey) + } + + @Test("an empty foreign key cell opens the picker too") + func emptyForeignKeyOpensThePicker() { + let context = ContextFactory.make( + value: nil, columnType: .integer(rawType: "INTEGER"), isTableEditable: true, isForeignKey: true + ) + #expect(resolver.resolve(context) == .editForeignKey) + } + + @Test("a read-only foreign key cell keeps its viewer") + func readOnlyForeignKeyKeepsTheViewer() { + let context = ContextFactory.make( + value: "42", columnType: .integer(rawType: "INTEGER"), isForeignKey: true + ) + #expect(resolver.resolve(context) == .viewInline(value: "42")) + } + + @Test("an immutable foreign key column keeps its viewer") + func immutableForeignKeyKeepsTheViewer() { + let context = ContextFactory.make( + value: "42", columnType: .integer(rawType: "INTEGER"), + isTableEditable: true, isImmutableColumn: true, isForeignKey: true + ) + #expect(resolver.resolve(context) == .viewInline(value: "42")) + } + + @Test("a deleted row stays blocked on a foreign key column") + func deletedForeignKeyRowBlocked() { + let context = ContextFactory.make( + value: "42", isTableEditable: true, isRowDeleted: true, isForeignKey: true + ) + #expect(resolver.resolve(context) == .blocked) + } + + /// The picker lists keys, so a cell whose content needs a structured editor keeps that editor: + /// the reference is still followed from the arrow and the context menu. + @Test("a foreign key column holding a blob keeps the blob editor") + func blobForeignKeyKeepsTheBlobEditor() { + let context = ContextFactory.make( + value: nil, columnType: .blob(rawType: "BLOB"), isTableEditable: true, isForeignKey: true + ) + #expect(resolver.resolve(context) == .editBlob) + } + + @Test("a foreign key column shown as JSON keeps the JSON editor") + func jsonDisplayForeignKeyKeepsTheJsonEditor() { + let context = ContextFactory.make( + value: "{}", columnType: .text(rawType: "TEXT"), isTableEditable: true, + isForeignKey: true, displayFormatOverride: .json + ) + #expect(resolver.resolve(context) == .editJson) + } + + @Test("a column with no foreign key still edits inline") + func plainColumnStillEditsInline() { + let context = ContextFactory.make( + value: "42", columnType: .integer(rawType: "INTEGER"), isTableEditable: true + ) + #expect(resolver.resolve(context) == .editInline(value: "42")) + } +} + private enum ContextFactory { static func make( value: String?, @@ -235,6 +308,7 @@ private enum ContextFactory { isRowDeleted: Bool = false, isImmutableColumn: Bool = false, isBinaryValue: Bool = false, + isForeignKey: Bool = false, displayFormatOverride: ValueDisplayFormat? = nil ) -> CellContext { CellContext( @@ -244,6 +318,7 @@ private enum ContextFactory { isRowDeleted: isRowDeleted, isImmutableColumn: isImmutableColumn, isBinaryValue: isBinaryValue, + isForeignKey: isForeignKey, displayFormatOverride: displayFormatOverride ) } diff --git a/TableProTests/Views/Results/ForeignKeyPickerEntryTests.swift b/TableProTests/Views/Results/ForeignKeyPickerEntryTests.swift new file mode 100644 index 000000000..b36ec276e --- /dev/null +++ b/TableProTests/Views/Results/ForeignKeyPickerEntryTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("ForeignKeyPickerEntry") +struct ForeignKeyPickerEntryTests { + private let integerKey = ColumnType.integer(rawType: "INTEGER") + private let textKey = ColumnType.text(rawType: "VARCHAR(8)") + + private func rows(_ pairs: [(String, String?)]) -> [ForeignKeyLookupService.Row] { + pairs.enumerated().map { ForeignKeyLookupService.Row(id: $0.offset, key: $0.element.0, label: $0.element.1) } + } + + private func entry(_ index: Int, _ key: String, _ label: String?) -> ForeignKeyPickerEntry { + .row(ForeignKeyLookupService.Row(id: index, key: key, label: label)) + } + + private func build( + _ pairs: [(String, String?)], + term: String, + keyType: ColumnType? + ) -> [ForeignKeyPickerEntry] { + ForeignKeyPickerEntry.build(rows: rows(pairs), term: term, keyType: keyType) + } + + @Test("An empty term lists the rows alone") + func emptyTermListsRowsAlone() { + let entries = build([("1", "AC/DC"), ("2", "Accept")], term: "", keyType: integerKey) + #expect(entries == [entry(0, "1", "AC/DC"), entry(1, "2", "Accept")]) + } + + @Test("A numeric term on a numeric key leads the list") + func numericTermLeadsOnNumericKey() { + let entries = build([("42", "Big Ones")], term: "7", keyType: integerKey) + #expect(entries.first == .literal("7")) + } + + /// A word typed against a numeric key is a search for a label. Offering it as a value, selected, + /// made the obvious keystroke write the search term into an integer column. + @Test("A word on a numeric key is never offered as a value") + func wordIsNotOfferedOnNumericKey() { + let entries = build([("5", "Big Ones")], term: "Big", keyType: integerKey) + #expect(entries == [entry(0, "5", "Big Ones")]) + } + + @Test("A word on a text key is offered, because it could be the key") + func wordIsOfferedOnTextKey() { + let entries = build([("BIG", "Big Ones")], term: "Big", keyType: textKey) + #expect(entries.first == .literal("Big")) + } + + @Test("An unknown key type takes the term on trust") + func unknownKeyTypeAcceptsAnything() { + #expect(ForeignKeyPickerEntry.acceptsTypedKey("Big", keyType: nil)) + } + + @Test("A term that already names a listed key is not repeated") + func exactKeyIsNotRepeated() { + let entries = build([("7", "Seven")], term: "7", keyType: integerKey) + #expect(entries == [entry(0, "7", "Seven")]) + } + + @Test("Whitespace around the term is trimmed before it becomes a value") + func termIsTrimmed() { + let entries = build([], term: " 7 ", keyType: integerKey) + #expect(entries == [.literal("7")]) + } + + // MARK: - Default selection + + private func selection( + _ pairs: [(String, String?)], + term: String, + keyType: ColumnType?, + currentValue: String? = nil + ) -> ForeignKeyPickerEntry.ID? { + ForeignKeyPickerEntry.defaultSelection( + in: build(pairs, term: term, keyType: keyType), + term: term, + currentValue: currentValue + ) + } + + @Test("The typed term is selected when it is offered") + func typedTermIsSelected() { + #expect( + selection([("1", "AC/DC")], term: "7", keyType: integerKey) + == ForeignKeyPickerEntry.literal("7").id + ) + } + + /// A search for `42` also matches every label containing 42, and those sort ahead of the key + /// itself whenever the key is longer, so the row the user asked for has to be named. + @Test("An exact key wins over the head of the list") + func exactKeyWinsOverTheFirstRow() { + let rows = [("7", "Track 42" as String?), ("42", "Big Ones" as String?)] + #expect( + selection(rows, term: "42", keyType: integerKey) + == entry(1, "42", "Big Ones").id + ) + } + + @Test("A search that narrows to one row selects that row") + func narrowedSearchSelectsTheRow() { + #expect( + selection([("5", "Big Ones")], term: "Big", keyType: integerKey) + == entry(0, "5", "Big Ones").id + ) + } + + @Test("An empty list selects nothing") + func emptyListSelectsNothing() { + #expect(ForeignKeyPickerEntry.defaultSelection(in: [], term: "Big", currentValue: nil) == nil) + } + + /// Opening the picker and pressing Return used to write the first row of the referenced table + /// over the key already in the cell, because an unfiltered list preselected its head. + @Test("With nothing typed the cell's own value is selected") + func currentValueIsSelectedOnOpen() { + let rows = [("1", "AC/DC" as String?), ("5", "Big Ones" as String?)] + #expect( + selection(rows, term: "", keyType: integerKey, currentValue: "5") + == entry(1, "5", "Big Ones").id + ) + } + + @Test("With nothing typed and no current value nothing is selected") + func nothingIsSelectedWithoutACurrentValue() { + #expect(selection([("1", "AC/DC")], term: "", keyType: integerKey) == nil) + } + + @Test("A current value the first page does not reach selects nothing") + func unreachedCurrentValueSelectsNothing() { + #expect(selection([("1", "AC/DC")], term: "", keyType: integerKey, currentValue: "900") == nil) + } + + /// A foreign key may reference one column of a composite unique key, which is not unique on its + /// own, so two rows can carry the same key and the list has to keep them apart. + @Test("Two rows with the same key keep separate identities") + func duplicateKeysStayDistinct() { + let entries = build([("5", "Big Ones"), ("5", "Bigger Ones")], term: "", keyType: integerKey) + #expect(entries.count == 2) + #expect(Set(entries.map(\.id)).count == 2) + } + + @Test("The committed value of a row is its key, never its label") + func committedValueIsTheKey() { + #expect(entry(0, "5", "Big Ones").committedValue == "5") + #expect(ForeignKeyPickerEntry.literal("7").committedValue == "7") + } +} diff --git a/TableProUITests/ForeignKeyPickerUITests.swift b/TableProUITests/ForeignKeyPickerUITests.swift new file mode 100644 index 000000000..7e9c68018 --- /dev/null +++ b/TableProUITests/ForeignKeyPickerUITests.swift @@ -0,0 +1,101 @@ +// +// ForeignKeyPickerUITests.swift +// TableProUITests +// +// Editing a foreign key cell picks a row from the referenced table. Chinook's +// Album.ArtistId references Artist.ArtistId, whose Name column is what the picker labels with. +// + +import AppKit +import XCTest + +final class ForeignKeyPickerUITests: UITestCase { + func testEditingAForeignKeyCellListsTheReferencedRows() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try albumGrid(in: app, window: window) + + openPicker(in: app, grid: grid) + + XCTAssertTrue( + searchField(in: window).waitToExist(timeout: 20), + "Editing a foreign key cell must open the value picker" + ) + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.staticTexts["AC/DC"].exists }, + "The picker must list Artist rows labelled with their Name column" + ) + } + + func testTypingNarrowsTheListToMatchingRows() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try albumGrid(in: app, window: window) + + openPicker(in: app, grid: grid) + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.staticTexts["AC/DC"].exists }, + "The picker must load its first rows before a search can narrow them" + ) + + app.typeText("Accept") + + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.staticTexts["Accept"].exists }, + "Searching Accept must reach the Artist row of that name" + ) + XCTAssertTrue( + waitForPredicate(timeout: 20) { !window.staticTexts["AC/DC"].exists }, + "A row the search term does not match must leave the list" + ) + } + + // MARK: - Helpers + + private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch + XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, + "The object browser must list the sample database's tables" + ) + return window + } + + private func albumGrid(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { + let row = window.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", "Table: Album")) + .firstMatch + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") + clickAtCenter(row) + + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") + XCTAssertTrue( + waitForPredicate(timeout: 30) { !grid.tableRows.allElementsBoundByIndex.isEmpty }, + "Album must load rows before a cell can be edited" + ) + return grid + } + + /// A point offset from the grid rather than a row or cell element, which XCUITest reads as + /// obscured by the columns published beside them, with `dy` clearing the 42pt header. + /// + /// The cell cursor then walks right until it stops, which lands on Album's last column whatever + /// the click hit and whatever the columns are sized to. That column is `ArtistId`, the reference + /// this drives, and Return opens the editor the cursor is on. + private func openPicker(in app: XCUIApplication, grid: XCUIElement) { + grid.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: 60, dy: 70)) + .click() + + for _ in 0 ..< 5 { + app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: []) + } + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + } + + private func searchField(in window: XCUIElement) -> XCUIElement { + window.searchFields.matching(identifier: "fk-picker-search").firstMatch + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 46ff1a803..858f7e170 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -50,6 +50,17 @@ Widths, order, and hidden columns are remembered per table, scoped to the connec A foreign key cell carries an arrow on its right edge. Click it to open the referenced table filtered to the matching row, or right-click for **Preview Referenced Row**, which shows that row in a popover. `Cmd`-click always opens a new tab; otherwise the reference takes over the current tab unless that tab holds a query or unsaved edits. +Double-click a foreign key cell, press `Return` on it, or open its right-click menu to pick the key rather than type it. The picker lists rows from the referenced table, each key with a label beside it, and narrows as you type. `Return` in the search field commits the text as typed, which covers a key the search has not turned up. **Set NULL** appears on a nullable column. + +**Label** at the foot of the picker chooses the column that reads as the row's name. The choice is remembered for the referenced table, so every column pointing at it shows the same one. A search fetches the first 50 matches, which keeps the list quick on a large table. + +A column of a foreign key that spans several columns keeps the text editor. The picker sets one column, and a key it offered might not pair with the values the row holds in the constraint's other columns. Type the key, or open the referenced table and read the pair off it. + + + Foreign key value picker over a data grid cell + Foreign key value picker over a data grid cell + + The inspector's **JSON** tab follows a key without leaving the row: see [Row as JSON](/features/json-viewer#row-as-json). Step back with the Back and Forward buttons at the leading edge of the toolbar, or **View > Back** (`Ctrl+Cmd+[`) and **View > Forward** (`Ctrl+Cmd+]`). Back restores the table you came from as you left it: same filters, sort, page, and selected row. Each tab keeps its own history, Back never closes a tab, and it is unavailable while a tab holds unsaved edits. diff --git a/docs/images/fk-value-picker-dark.png b/docs/images/fk-value-picker-dark.png new file mode 100644 index 000000000..d8749f861 Binary files /dev/null and b/docs/images/fk-value-picker-dark.png differ diff --git a/docs/images/fk-value-picker.png b/docs/images/fk-value-picker.png new file mode 100644 index 000000000..6a7158574 Binary files /dev/null and b/docs/images/fk-value-picker.png differ