Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
113 changes: 113 additions & 0 deletions TablePro/Core/Database/ForeignKeyLookupQuery.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
36 changes: 36 additions & 0 deletions TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
120 changes: 120 additions & 0 deletions TablePro/Core/Services/Query/ForeignKeyLookupService.swift
Original file line number Diff line number Diff line change
@@ -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))"
}
}
39 changes: 39 additions & 0 deletions TablePro/Core/Storage/ForeignKeyLabelColumnStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions TablePro/Core/Storage/Preferences/PreferenceKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
DefaultsKey("com.TablePro.foreignKey.labelColumn." + scope.storageComponent)
}
}
27 changes: 27 additions & 0 deletions TablePro/Models/Schema/ForeignKeyConstraintSpan.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading