Skip to content

Commit 584e73a

Browse files
committed
fix(datagrid): withhold the column drag on a rearranged list, and refuse to rebuild a virtual table
1 parent e73722b commit 584e73a

6 files changed

Lines changed: 134 additions & 6 deletions

File tree

Plugins/TableProPluginKit/SQLiteTableDDL.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,16 @@ public enum SQLiteTableDDL {
4949
]
5050

5151
/// Splits `CREATE TABLE x (…) WITHOUT ROWID` into its prefix, its top-level entries and its
52-
/// trailing options. Nil when the statement has no balanced parenthesised body, which is what a
53-
/// `CREATE TABLE … AS SELECT` looks like and what a rebuild must not touch.
52+
/// trailing options.
53+
///
54+
/// Nil for anything that is not an ordinary table, because a rebuild of one of those destroys
55+
/// it. `sqlite_master` stores an FTS5 table as `CREATE VIRTUAL TABLE docs USING fts5(title,
56+
/// body)`, whose parentheses parse exactly like a column list, so accepting it would recreate
57+
/// the table as a plain one and take the index and its shadow tables down with the `DROP`.
58+
/// `CREATE TABLE … AS SELECT` has no column list to reorder either.
5459
public static func parse(createTableSQL sql: String) -> Parsed? {
5560
guard let open = topLevelBodyStart(in: sql) else { return nil }
61+
guard isOrdinaryTable(prefix: sql[sql.startIndex..<open]) else { return nil }
5662
guard let close = matchingCloseParen(in: sql, from: open) else { return nil }
5763

5864
let body = String(sql[sql.index(after: open)..<close])
@@ -106,6 +112,23 @@ public enum SQLiteTableDDL {
106112
return trailing.isEmpty ? "" : " \(trailing)"
107113
}
108114

115+
/// Whether what stands before the column list is a plain `CREATE TABLE`. Only the keywords
116+
/// SQLite allows there are accepted, so an unrecognised form is refused rather than rebuilt.
117+
private static func isOrdinaryTable(prefix: Substring) -> Bool {
118+
let words = prefix
119+
.split(whereSeparator: { $0.isWhitespace })
120+
.map { $0.uppercased() }
121+
guard let tableIndex = words.firstIndex(of: "TABLE") else { return false }
122+
/// Only these may stand before TABLE. VIRTUAL does not, which is what rules out an FTS or
123+
/// R-tree table whose module arguments would otherwise read as a column list.
124+
guard words[..<tableIndex].allSatisfy({ ["CREATE", "TEMP", "TEMPORARY"].contains($0) }),
125+
words.first == "CREATE" else { return false }
126+
/// What follows is `IF NOT EXISTS` and a name, which may itself be several whitespace
127+
/// separated tokens when it is quoted. A bare AS among them is `CREATE TABLE … AS SELECT`,
128+
/// which has no column list to reorder.
129+
return !words[tableIndex...].contains("AS")
130+
}
131+
109132
/// The opening parenthesis of the column list, skipping any that a quoted table name contains.
110133
private static func topLevelBodyStart(in sql: String) -> String.Index? {
111134
var scanner = Scanner(sql)

TablePro/Models/Schema/ColumnReorderSupport.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ enum ColumnReorderPolicy {
5959
engineName: String,
6060
isColumnsTab: Bool,
6161
canEditSchema: Bool,
62-
hasStagedChanges: Bool
62+
hasStagedChanges: Bool,
63+
isRearranged: Bool
6364
) -> ColumnReorderAvailability {
6465
guard isColumnsTab else { return .notApplicable }
6566
guard canEditSchema else {
@@ -81,6 +82,15 @@ enum ColumnReorderPolicy {
8182
reason: String(localized: "Save or discard the pending structure changes before reordering columns.")
8283
)
8384
}
85+
/// A drop reports the row's position in what is on screen, and a filtered or sorted
86+
/// list is not the table's order, so "third from the top" names a different column in
87+
/// each. There is nothing to map it back to either: the wanted order is a statement
88+
/// about every column, and a filtered list is not showing every column.
89+
guard !isRearranged else {
90+
return .unavailable(
91+
reason: String(localized: "Clear the filter and the sort to reorder columns.")
92+
)
93+
}
8494
return .available(support)
8595
}
8696
}

TablePro/Views/Structure/TableStructureView+ColumnReorder.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ extension TableStructureView {
2121
engineName: connection.type.displayName,
2222
isColumnsTab: selectedTab == .columns,
2323
canEditSchema: connection.type.supportsSchemaEditing,
24-
hasStagedChanges: structureChangeManager.hasChanges
24+
hasStagedChanges: structureChangeManager.hasChanges,
25+
isRearranged: !searchText.isEmpty || structureSortDescriptor != nil
2526
)
2627
}
2728

TableProTests/Models/Schema/ColumnReorderPlannerTests.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,56 @@ struct ColumnReorderPlannerTests {
9797
func nonPermutationProducesNoCycle() {
9898
#expect(PluginColumnReorderPlanner.appendCycle(from: current, to: ["a", "b"]).isEmpty)
9999
}
100+
101+
// MARK: - Exhaustive
102+
103+
/// Both planners are asked for every permutation of five columns. A tie in the common
104+
/// subsequence can pick a different set to leave alone without changing how many columns move,
105+
/// so the guarantee worth pinning is the result, not the choice.
106+
@Test("Every permutation of five columns is reached, by both mechanisms, in the minimum moves")
107+
func everyPermutationIsReachable() {
108+
let start = ["a", "b", "c", "d", "e"]
109+
for desired in permutations(of: start) {
110+
let moves = PluginColumnReorderPlanner.moves(from: start, to: desired)
111+
var byMove = start
112+
for move in moves {
113+
byMove.removeAll { $0 == move.column }
114+
if let after = move.afterColumn, let index = byMove.firstIndex(of: after) {
115+
byMove.insert(move.column, at: byMove.index(after: index))
116+
} else {
117+
byMove.insert(move.column, at: 0)
118+
}
119+
}
120+
#expect(byMove == desired, "moves did not reach \(desired)")
121+
#expect(moves.count == start.count - longestCommonSubsequenceLength(start, desired))
122+
123+
var byCycle = start
124+
for column in PluginColumnReorderPlanner.appendCycle(from: start, to: desired) {
125+
byCycle.removeAll { $0 == column }
126+
byCycle.append(column)
127+
}
128+
#expect(byCycle == desired, "cycling did not reach \(desired)")
129+
}
130+
}
131+
132+
private func permutations(of values: [String]) -> [[String]] {
133+
guard values.count > 1 else { return [values] }
134+
return values.indices.flatMap { index -> [[String]] in
135+
var rest = values
136+
let picked = rest.remove(at: index)
137+
return permutations(of: rest).map { [picked] + $0 }
138+
}
139+
}
140+
141+
private func longestCommonSubsequenceLength(_ lhs: [String], _ rhs: [String]) -> Int {
142+
var lengths = Array(repeating: Array(repeating: 0, count: rhs.count + 1), count: lhs.count + 1)
143+
for i in stride(from: lhs.count - 1, through: 0, by: -1) {
144+
for j in stride(from: rhs.count - 1, through: 0, by: -1) {
145+
lengths[i][j] = lhs[i] == rhs[j]
146+
? lengths[i + 1][j + 1] + 1
147+
: max(lengths[i + 1][j], lengths[i][j + 1])
148+
}
149+
}
150+
return lengths[0][0]
151+
}
100152
}

TableProTests/Models/Schema/ColumnReorderPolicyTests.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ struct ColumnReorderPolicyTests {
1313
support: ColumnReorderSupport = .alter,
1414
isColumnsTab: Bool = true,
1515
canEditSchema: Bool = true,
16-
hasStagedChanges: Bool = false
16+
hasStagedChanges: Bool = false,
17+
isRearranged: Bool = false
1718
) -> ColumnReorderAvailability {
1819
ColumnReorderPolicy.resolve(
1920
support: support,
2021
engineName: "PostgreSQL",
2122
isColumnsTab: isColumnsTab,
2223
canEditSchema: canEditSchema,
23-
hasStagedChanges: hasStagedChanges
24+
hasStagedChanges: hasStagedChanges,
25+
isRearranged: isRearranged
2426
)
2527
}
2628

@@ -66,4 +68,20 @@ struct ColumnReorderPolicyTests {
6668
let availability = resolve(support: .unsupported, hasStagedChanges: true)
6769
#expect(availability.unavailableReason?.contains("PostgreSQL") == true)
6870
}
71+
72+
/// A drop reports a position in what is on screen. Filtered or sorted, that is not the table's
73+
/// order, and the delegate hands the position over without mapping it back, so the drag is
74+
/// withheld rather than acted on against the wrong column.
75+
@Test("A filtered or sorted column list withholds the drag")
76+
func rearrangedListWithholdsTheDrag() {
77+
let availability = resolve(isRearranged: true)
78+
#expect(!availability.isAvailable)
79+
#expect(availability.unavailableReason != nil)
80+
}
81+
82+
@Test("Staged edits outrank a rearranged list, because saving is the first thing to do")
83+
func stagedChangesOutrankRearrangement() {
84+
let staged = resolve(hasStagedChanges: true, isRearranged: true)
85+
#expect(staged.unavailableReason == resolve(hasStagedChanges: true).unavailableReason)
86+
}
6987
}

TableProTests/Models/Schema/SQLiteTableDDLTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,30 @@ struct SQLiteTableDDLTests {
4242
@Test("A statement with no column list is refused rather than half parsed")
4343
func parseRefusesCreateTableAsSelect() {
4444
#expect(SQLiteTableDDL.parse(createTableSQL: "CREATE TABLE t AS SELECT 1") == nil)
45+
#expect(SQLiteTableDDL.parse(createTableSQL: "CREATE TABLE t AS SELECT (1)") == nil)
46+
}
47+
48+
/// `sqlite_master` stores an FTS5 table as a `CREATE VIRTUAL TABLE` whose parentheses parse
49+
/// exactly like a column list. Rebuilding one recreates it as a plain table and drops the index
50+
/// and its shadow tables with the original, so it has to be refused before that.
51+
@Test("A virtual table is refused, whatever its module", arguments: [
52+
"CREATE VIRTUAL TABLE docs USING fts5(title, body)",
53+
"CREATE VIRTUAL TABLE t USING rtree(id, minX, maxX)",
54+
"CREATE VIRTUAL TABLE IF NOT EXISTS v USING fts4(a, b)"
55+
])
56+
func parseRefusesVirtualTables(sql: String) {
57+
#expect(SQLiteTableDDL.parse(createTableSQL: sql) == nil)
58+
}
59+
60+
@Test("The ordinary forms are still accepted", arguments: [
61+
"CREATE TABLE t(a INT)",
62+
"CREATE TEMP TABLE t(a INT)",
63+
"CREATE TEMPORARY TABLE t(a INT)",
64+
"CREATE TABLE IF NOT EXISTS t(a INT)",
65+
"CREATE TABLE \"my (odd) name\"(a INT)"
66+
])
67+
func parseAcceptsOrdinaryTables(sql: String) {
68+
#expect(SQLiteTableDDL.parse(createTableSQL: sql) != nil)
4569
}
4670

4771
@Test("Reordering moves the column definitions verbatim and leaves the constraints in place")

0 commit comments

Comments
 (0)