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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Privacy manifest for the iOS app.
- Oracle `DBMS_OUTPUT` lines shown with the result of the statement that printed them, and in a new **Output** result view.
- Oracle transactions opened with `SET TRANSACTION`, `SAVEPOINT` or `LOCK TABLE`, held until `COMMIT` or `ROLLBACK`.
- Several label columns beside the key in the foreign key picker, for a parent row only told apart by a combination. (#2996)

### Changed

Expand Down Expand Up @@ -51,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Icon-only buttons announced as nothing by VoiceOver across the data grid, row inspector, editor find bar, filter bar, structure, dashboard and settings.
- Status icons that carried a result only as a symbol and a colour, silent to VoiceOver, in the AWS and app import steps and the plugin lists.
- Foreign key picker rows that could only be chosen with a mouse.
- The referenced key column offered as a label in the foreign key picker, where choosing it showed no label at all.
- Foreign key picker reporting no matching rows for a term none of its columns could be searched for.
- No spoken sort direction on Query Plan columns.
- No columns, indexes or foreign keys listed for a MySQL server that answers `information_schema` with nothing or an error.
- Composite foreign key columns listed out of order on MariaDB.
Expand Down
34 changes: 21 additions & 13 deletions TablePro/Core/Database/ForeignKeyLookupQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,20 @@ 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.
/// term that matches no row. There is no query to send, and the caller has to say which of the
/// two happened: reporting it as an empty result made a picker on a numeric key with a numeric
/// label answer "No matching rows" to every word while still answering a number, so the search
/// read as intermittently broken rather than as having nothing to match against.
static func rows(
quotedTable: String,
key: ForeignKeyLookupColumn,
label: ForeignKeyLookupColumn?,
labels: [ForeignKeyLookupColumn],
searchTerm: String,
dialect: SQLDialectDescriptor,
stringLiteralPrefix: String,
quoteIdentifier: @escaping (String) -> String
) -> String? {
let selected = selectedColumns(key: key, label: label)
let selected = selectedColumns(key: key, labels: labels)
let selectList = selected.map { quoteIdentifier($0.name) }.joined(separator: ", ")
let generator = FilterSQLGenerator(
dialect: dialect,
Expand All @@ -49,7 +51,7 @@ enum ForeignKeyLookupQuery {

let term = searchTerm.trimmingCharacters(in: .whitespacesAndNewlines)
if !term.isEmpty {
let filters = searchFilters(key: key, label: label, term: term)
let filters = searchFilters(key: key, labels: labels, term: term)
guard !filters.isEmpty else { return nil }
let search = generator.generateConditions(from: filters, logicMode: .or)
guard !search.isEmpty else { return nil }
Expand All @@ -63,12 +65,15 @@ enum ForeignKeyLookupQuery {
return sql + " " + orderAndLimitClause(quotedKey: quoteIdentifier(key.name), dialect: dialect)
}

/// The key leads, then every label once. A name is selected at most once: naming the key again
/// would put two columns of the same value in front of the reader, and a duplicate label would
/// read as a repeated one.
static func selectedColumns(
key: ForeignKeyLookupColumn,
label: ForeignKeyLookupColumn?
labels: [ForeignKeyLookupColumn]
) -> [ForeignKeyLookupColumn] {
guard let label, label.name != key.name else { return [key] }
return [key, label]
var seen: Set<String> = [key.name]
return [key] + labels.filter { seen.insert($0.name).inserted }
}

/// The key column carries the order, so the filler `offsetFetchOrderBy` a dialect supplies for
Expand All @@ -83,15 +88,18 @@ enum ForeignKeyLookupQuery {
}
}

/// One predicate per label the engine can pattern-match, plus the key's own. A chosen column
/// that takes no `LIKE` is shown but carries no predicate, which costs that column a search
/// rather than costing the whole query an error; the columns beside it still search.
private static func searchFilters(
key: ForeignKeyLookupColumn,
label: ForeignKeyLookupColumn?,
labels: [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))
}
var filters = selectedColumns(key: key, labels: labels)
.dropFirst()
.filter(\.supportsPatternMatch)
.map { TableFilter(columnName: $0.name, filterOperator: .contains, value: term) }
if let keyFilter = keyFilter(key: key, term: term) {
filters.append(keyFilter)
}
Expand Down
47 changes: 38 additions & 9 deletions TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

/// Which column of the referenced table reads as a row's name beside its key.
/// Which columns of the referenced table read as a row's name beside its key.
enum ForeignKeyLabelColumn {
static let preferredNames = ["name", "title", "label", "username", "email", "code", "description"]

Expand All @@ -15,29 +15,58 @@ enum ForeignKeyLabelColumn {
/// honoured unconditionally and never checked against the table, because it names no identifier
/// and so nothing about it can go stale.
///
/// The key column is never a label, whatever is stored. It is already the first thing every row
/// shows, and every layer below drops it: the select list refuses to name it twice, so choosing
/// it used to be accepted, persisted, inherited by every other column pointing at the same
/// table, and then silently rendered nothing.
///
/// A choice that names only the key column is honoured as no label at all rather than handed
/// back to the heuristic, because the old menu listed the key and choosing it was how a reader
/// ended up with a key-only list. Falling through would give them a label they never asked for
/// on the first launch after this. The fall-through is for a choice whose names the table no
/// longer carries, which is a stale answer rather than an answer.
///
/// Chosen columns come back in the referenced table's own column order rather than the order
/// they were stored in, so the list reads the way the chooser does and needs no reordering
/// control. A parent whose natural key is declared out of sequence therefore reads in
/// declaration order, which is the cost of having no order to maintain.
///
/// 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.
/// names for themselves is still taken on their word, and the search simply carries the ones
/// that can hold a predicate.
static func resolve(
columns: [ForeignKeyLookupColumn],
keyColumn: String,
choice: ForeignKeyLabelChoice
) -> ForeignKeyLookupColumn? {
) -> [ForeignKeyLookupColumn] {
switch choice {
case .noLabel:
return nil
case .column(let preferred):
if let stored = columns.first(where: { $0.name == preferred }) { return stored }
return []
case .columns(let preferred):
let chosen = Set(preferred)
let stored = selectable(columns, keyColumn: keyColumn).filter { chosen.contains($0.name) }
if !stored.isEmpty { return stored }
if columns.contains(where: { chosen.contains($0.name) }) { return [] }
case .unset:
break
}
let candidates = columns.filter { $0.name != keyColumn && $0.supportsPatternMatch }
let candidates = selectable(columns, keyColumn: keyColumn).filter(\.supportsPatternMatch)
for name in preferredNames {
if let match = candidates.first(where: { $0.name.lowercased() == name }) {
return match
return [match]
}
}
return candidates.first
return candidates.first.map { [$0] } ?? []
}

/// The columns a reader may choose between, which is every column of the referenced table but
/// the one the key already shows.
static func selectable(
_ columns: [ForeignKeyLookupColumn],
keyColumn: String
) -> [ForeignKeyLookupColumn] {
columns.filter { $0.name != keyColumn }
}
}
21 changes: 21 additions & 0 deletions TablePro/Core/Services/Query/ForeignKeyLabelText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// ForeignKeyLabelText.swift
// TablePro
//

import Foundation

/// The one line a picker row shows beside its key, built from the chosen label columns.
enum ForeignKeyLabelText {
static let separator = ", "

/// A NULL or empty column is dropped rather than rendered as a gap, so a row missing the
/// middle value of three reads `integrale, caputo` instead of `integrale, , caputo`. A row
/// whose every chosen column is NULL carries no label at all, which is what the key-only
/// list already looks like.
static func joined(_ values: [String?]) -> String? {
let present = values.compactMap { $0 }.filter { !$0.isEmpty }
guard !present.isEmpty else { return nil }
return present.joined(separator: separator)
}
}
40 changes: 26 additions & 14 deletions TablePro/Core/Services/Query/ForeignKeyLookupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ enum ForeignKeyLookupService {
struct Row: Identifiable, Hashable, Sendable {
let id: Int
let key: String
let label: String?
let labels: [String?]
}

/// A search that could not be expressed is not a search that found nothing, and the picker
/// says something different about each. Collapsing the two reported "No matching rows" for a
/// term no column here can hold.
enum Outcome: Sendable {
case rows([Row])
case termNotSearchable
}

enum LookupFailure: Error {
Expand Down Expand Up @@ -42,11 +50,12 @@ enum ForeignKeyLookupService {
}
}

/// Rows whose key or label matches `term`, capped at `ForeignKeyLookupQuery.rowLimit`.
/// Rows whose key or one of whose labels 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.
/// `.termNotSearchable` when the term cannot be expressed as a predicate against any selected
/// 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
Expand All @@ -56,9 +65,9 @@ enum ForeignKeyLookupService {
databaseType: DatabaseType,
reference: ForeignKeyInfo,
key: ForeignKeyLookupColumn,
label: ForeignKeyLookupColumn?,
labels: [ForeignKeyLookupColumn],
term: String
) async throws -> [Row] {
) async throws -> Outcome {
guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else {
throw LookupFailure.noDialect
}
Expand All @@ -74,29 +83,32 @@ enum ForeignKeyLookupService {
guard let query = ForeignKeyLookupQuery.rows(
quotedTable: quotedTable(table: table, schema: schema, driver: driver),
key: key,
label: label,
labels: labels,
searchTerm: term,
dialect: dialect,
stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType),
quoteIdentifier: driver.quoteIdentifier
) else {
return []
return .termNotSearchable
}
let result = try await driver.execute(query: query)
return rows(from: result, key: key, label: label)
return .rows(rows(from: result, key: key, labels: labels))
}
}

/// The labels sit at every select position after the key, however many there are. A row keeps
/// a NULL as a NULL rather than dropping it here, because how a missing value reads beside the
/// ones around it is a rendering question that `ForeignKeyLabelText` answers.
nonisolated private static func rows(
from result: QueryResult,
key: ForeignKeyLookupColumn,
label: ForeignKeyLookupColumn?
labels: [ForeignKeyLookupColumn]
) -> [Row] {
let labelIndex = ForeignKeyLookupQuery.selectedColumns(key: key, label: label).count > 1 ? 1 : nil
let labelIndices = ForeignKeyLookupQuery.selectedColumns(key: key, labels: labels).indices.dropFirst()
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)
let labelValues = labelIndices.map { values.indices.contains($0) ? values[$0].asText : nil }
return Row(id: index, key: keyValue, labels: labelValues)
}
}

Expand Down
66 changes: 52 additions & 14 deletions TablePro/Core/Storage/ForeignKeyLabelChoice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,87 @@

import Foundation

/// What the reader has said about a referenced table's label column.
/// What the reader has said about a referenced table's label columns.
///
/// Three states, because "I have not chosen" and "I chose to show no label" are different answers
/// and only one of them should let the heuristic run. Encoding the second as an absent key made it
/// identical to the first, so **None** was forgotten the moment the picker was reopened.
///
/// The stored form is the column name's UTF-8, which is what is already on disk, plus a one-byte
/// sentinel for `noLabel`. `0xFF` is the sentinel because no Unicode scalar's UTF-8 contains it,
/// checked over all 1,114,112 of them, so it can never be a column name. An empty value would not
/// do: SQLite accepts `create table t("" integer)`, so `""` is a name a reader can really pick,
/// while PostgreSQL and MariaDB refuse it. Keep the sentinel, and do not "simplify" this to an
/// empty-`Data` check.
/// A choice carries a list rather than one name, because a parent row's identity often spans
/// several columns: a table whose `UNIQUE` constraint is `(descrizione, marchio)` reads as six
/// identical rows under either column alone.
///
/// The stored form is a one-byte sentinel plus a payload. `0xFE` and `0xFF` are the sentinels
/// because no Unicode scalar's UTF-8 contains either byte, checked over all 1,114,112 of them, so
/// neither can begin a column name. An empty value would not do: SQLite accepts
/// `create table t("" integer)`, so `""` is a name a reader can really pick, while PostgreSQL and
/// MariaDB refuse it. Keep the sentinels, and do not "simplify" this to an empty-`Data` check.
///
/// Bare UTF-8 with no sentinel is the single-name form every existing choice is written in, and it
/// stays readable forever. It is a read path only: a choice written from here always carries the
/// `0xFE` sentinel, whatever its count, so one meaning has one spelling on the way out.
internal enum ForeignKeyLabelChoice: Equatable, Sendable {
case unset
case noLabel
case column(String)
case columns([String])

private static let noLabelSentinel = Data([0xFF])
private static let noLabelSentinel: UInt8 = 0xFF
private static let listSentinel: UInt8 = 0xFE

/// Choosing nothing is choosing **None**, so an emptied chooser is remembered rather than
/// handed back to the heuristic. Duplicates collapse to the first mention, because the same
/// column twice would select it twice and read as a repeated value.
internal init(columnNames: [String]) {
var seen = Set<String>()
let unique = columnNames.filter { seen.insert($0).inserted }
self = unique.isEmpty ? .noLabel : .columns(unique)
}

internal init(storedData: Data?) {
guard let storedData else {
self = .unset
return
}
if storedData == Self.noLabelSentinel {
guard let first = storedData.first else {
self = .columns([""])
return
}
if first == Self.noLabelSentinel, storedData.count == 1 {
self = .noLabel
return
}
if first == Self.listSentinel {
let payload = Data(storedData.dropFirst())
guard let names = try? JSONDecoder().decode([String].self, from: payload) else {
self = .unset
return
}
self = ForeignKeyLabelChoice(columnNames: names)
return
}
guard let name = String(bytes: storedData, encoding: .utf8) else {
self = .unset
return
}
self = .column(name)
self = .columns([name])
}

internal var storedData: Data? {
switch self {
case .unset:
return nil
case .noLabel:
return Self.noLabelSentinel
case .column(let name):
return Data(name.utf8)
return Data([Self.noLabelSentinel])
case .columns(let names):
guard !names.isEmpty, let payload = try? JSONEncoder().encode(names) else {
return Data([Self.noLabelSentinel])
}
return Data([Self.listSentinel]) + payload
}
}

internal var columnNames: [String] {
guard case .columns(let names) = self else { return [] }
return names
}
}
8 changes: 8 additions & 0 deletions TablePro/Models/Schema/ForeignKeyLookupColumn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ struct ForeignKeyLookupColumn: Equatable, Sendable, Identifiable {
return Self.characterTypeNames.contains(base)
}

/// The declared type as the reader sees it beside the column's name, or nothing when the
/// engine declares none: SQLite accepts `create table t(a, b)`, and an empty string reads as a
/// missing word rather than as a column with no type.
var displayTypeName: String? {
guard let rawType = type.rawType, !rawType.isEmpty else { return nil }
return rawType
}

/// 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 }
Expand Down
Loading
Loading