diff --git a/CHANGELOG.md b/CHANGELOG.md index d98bbb8581..957b25a38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ 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. +- No label and no working search in the foreign key picker on a SQLite table whose columns are declared without a type. - SQLite foreign keys written `REFERENCES parent` with no column list pointing at a column the parent does not have, in the object browser, the ER diagram and the JSON inspector. - Preview Referenced Row on iPhone and iPad building its filter by hand, so a value holding a backslash could reach the server as SQL. (#2996) - The referenced key column offered as a label in the foreign key picker, where choosing it showed no label at all. diff --git a/TablePro/Models/Schema/ForeignKeyLookupColumn.swift b/TablePro/Models/Schema/ForeignKeyLookupColumn.swift index e092be6896..fe37b6b27a 100644 --- a/TablePro/Models/Schema/ForeignKeyLookupColumn.swift +++ b/TablePro/Models/Schema/ForeignKeyLookupColumn.swift @@ -21,11 +21,31 @@ struct ForeignKeyLookupColumn: Equatable, Sendable, Identifiable { /// 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. + /// + /// A column that declares no type at all is the one open answer, and it is not a gap in the + /// list. Only a dynamically typed engine reports one: `create table t(a, b)` is legal SQLite + /// and `PRAGMA table_xinfo` gives back a zero-length type for it, measured on 3.54.0, while + /// every strict engine always names a type. `LIKE` is defined on every column there, measured + /// on the same build, so an undeclared column takes a predicate and can be a label. Reading it + /// as unknown instead left a hand-written SQLite database with no label anywhere and no way to + /// search one. var supportsPatternMatch: Bool { - guard case .text = type, let base = Self.baseTypeName(of: type.rawType) else { return false } + guard case .text = type else { return false } + guard let base = Self.baseTypeName(of: type.rawType) else { return declaresNoType } return Self.characterTypeNames.contains(base) } + /// True when the engine answered with a type and that type was empty, which is how SQLite + /// reports a column declared without one. + /// + /// A missing `rawType` is deliberately not this. That is the app having no type information at + /// all, which several of its own conversions produce, and reading it as "the engine declared + /// nothing" would hand a pattern predicate to a column nobody has typed. + var declaresNoType: Bool { + guard let rawType = type.rawType else { return false } + return rawType.trimmingCharacters(in: .whitespaces).isEmpty + } + /// 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. diff --git a/TableProTests/Models/Schema/ForeignKeyLookupColumnTests.swift b/TableProTests/Models/Schema/ForeignKeyLookupColumnTests.swift new file mode 100644 index 0000000000..41e88b1968 --- /dev/null +++ b/TableProTests/Models/Schema/ForeignKeyLookupColumnTests.swift @@ -0,0 +1,82 @@ +// +// ForeignKeyLookupColumnTests.swift +// TablePro +// + +import Foundation +import Testing + +@testable import TablePro + +/// `supportsPatternMatch` decides both which columns the foreign key picker offers as a label on +/// its own and which ones carry the search. It answers a closed list of character type names, +/// because `ColumnTypeClassifier` files everything it does not recognise under `.text` and a `LIKE` +/// against a `uuid`, an enum or an array is an error on PostgreSQL rather than an empty result. +@Suite("ForeignKeyLookupColumn") +struct ForeignKeyLookupColumnTests { + private func column(_ rawType: String?) -> ForeignKeyLookupColumn { + ForeignKeyLookupColumn(name: "c", type: .text(rawType: rawType)) + } + + @Test("A character type carries a pattern predicate") + func characterTypesMatch() { + for rawType in ["TEXT", "VARCHAR(64)", "nvarchar(10)", "CITEXT", "LONGTEXT"] { + #expect(column(rawType).supportsPatternMatch, "\(rawType) should pattern match") + } + } + + @Test("A type the classifier only guessed at carries none") + func guessedTypesDoNotMatch() { + for rawType in ["uuid", "inet", "money", "tsvector"] { + #expect(!column(rawType).supportsPatternMatch, "\(rawType) should not pattern match") + } + } + + @Test("A type that is not text carries none") + func nonTextTypesDoNotMatch() { + #expect(!ForeignKeyLookupColumn(name: "c", type: .integer(rawType: "INTEGER")).supportsPatternMatch) + #expect(!ForeignKeyLookupColumn(name: "c", type: .date(rawType: "DATE")).supportsPatternMatch) + #expect(!ForeignKeyLookupColumn(name: "c", type: .decimal(rawType: "NUMERIC")).supportsPatternMatch) + } + + /// `create table t(a, b)` is legal SQLite and common in hand-written databases. Measured on + /// 3.54.0: `PRAGMA table_xinfo` answers a zero-length type for such a column, and `LIKE` + /// against it works. Reading the empty type as unknown left every column of that table + /// unlabelled and unsearchable, with the picker listing bare keys and no way to say why. + @Test("A column the engine declared with no type carries a predicate") + func undeclaredTypeMatches() { + #expect(column("").supportsPatternMatch) + #expect(column(" ").supportsPatternMatch) + #expect(column("").declaresNoType) + #expect(column(" ").declaresNoType) + } + + /// Several of the app's own conversions build a column with no type information at all. That + /// is not an engine saying "this column has no declared type", and it must not become a + /// predicate on a strict engine. + @Test("A column with no type information at all is left alone") + func missingTypeIsNotAnUndeclaredType() { + #expect(!column(nil).supportsPatternMatch) + #expect(!column(nil).declaresNoType) + } + + @Test("A declared type is still reported for display, and an empty one is not") + func displayTypeNameFollowsTheDeclaration() { + #expect(column("TEXT").displayTypeName == "TEXT") + #expect(column("").displayTypeName == nil) + #expect(column(nil).displayTypeName == nil) + } + + /// The reporter's shape: a parent whose every column is untyped had no label to offer. + @Test("An untyped table offers its first non-key column as a label") + func untypedTableStillGetsALabel() { + let columns = [ + ForeignKeyLookupColumn(name: "marchio", type: .text(rawType: "")), + ForeignKeyLookupColumn(name: "nome", type: .text(rawType: "")), + ] + let resolved = ForeignKeyLabelColumn.resolve( + columns: columns, keyColumn: "marchio", choice: .unset + ) + #expect(resolved.map(\.name) == ["nome"]) + } +}