Skip to content

Commit f471c26

Browse files
authored
fix(clickhouse): refuse grid edits and deletes that reach other rows (#3794)
1 parent 41f46bc commit f471c26

5 files changed

Lines changed: 185 additions & 7 deletions

File tree

‎TablePro/Core/DataWrite/RowChangeStatementFactory.swift‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ struct RowChangeStatementFactory {
124124
) {
125125
return .driverWritten(driverStatements)
126126
}
127+
try refuseMatchesReachingOtherRows(in: changes, deletedRowIDs: deletedRowIDs)
127128
return .counted(
128129
try hostStatements(
129130
for: changes,
@@ -134,6 +135,44 @@ struct RowChangeStatementFactory {
134135
)
135136
}
136137

138+
// These engines write their own rows, yet their saves reach the host's UPDATE and DELETE, keyed on a key the
139+
// engine does not keep unique, with no row count to hold them to (#3776). Dameng enforces its primary key.
140+
private static let enginesWithoutUniqueRowMatch: Set<DatabaseType> = [.clickhouse, .snowflake, .trino]
141+
142+
private func refuseMatchesReachingOtherRows(in changes: [RowChange], deletedRowIDs: Set<RowID>) throws {
143+
let reason: String
144+
if Self.enginesWithoutUniqueRowMatch.contains(databaseType) {
145+
reason = String(
146+
format: String(
147+
localized: "This version of TablePro cannot write %@ rows from the grid. Use a SQL statement instead."
148+
),
149+
databaseType.displayName
150+
)
151+
} else if databaseType == .dameng, primaryKeyColumns.isEmpty {
152+
reason = String(
153+
format: String(
154+
localized: """
155+
This version of TablePro cannot write rows of a %@ table without a primary key from the grid. \
156+
Use a SQL statement instead.
157+
"""
158+
),
159+
databaseType.displayName
160+
)
161+
} else {
162+
return
163+
}
164+
for change in changes {
165+
switch change.type {
166+
case .update:
167+
throw DataWriteError.changeRefused(table: tableName, kind: .update, reason: reason)
168+
case .delete where deletedRowIDs.contains(change.rowID):
169+
throw DataWriteError.changeRefused(table: tableName, kind: .delete, reason: reason)
170+
case .insert, .delete:
171+
continue
172+
}
173+
}
174+
}
175+
137176
/// A value staged for a column the server owns can never be written, however it got staged: the grid offers no
138177
/// editor for one, but the set of such columns is empty until the table's schema arrives, and an edit made in
139178
/// that window survives its arrival. Sending it fails on the server (SQL Server Msg 8102 for an `IDENTITY`), and a

‎TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22
// DataChangeManagerClickHouseTests.swift
33
// TableProTests
44
//
5-
// Tests for ClickHouse-specific UPDATE statement validation in DataChangeManager.
6-
// ClickHouse uses ALTER TABLE ... UPDATE syntax instead of standard UPDATE.
7-
//
85

96
import Foundation
107
import TableProPluginKit
@@ -13,8 +10,8 @@ import Testing
1310

1411
@MainActor
1512
struct DataChangeManagerClickHouseTests {
16-
@Test("ClickHouse ALTER TABLE UPDATE passes validation without throwing")
17-
func alterTableUpdatePassesValidation() async {
13+
@Test("A ClickHouse edit is refused and stays pending instead of keyed on the sorting key")
14+
func clickHouseEditIsRefusedAndStaysPending() async {
1815
let manager = DataChangeManager()
1916
manager.configureForTable(
2017
tableName: "events",
@@ -33,10 +30,10 @@ struct DataChangeManagerClickHouseTests {
3330
originalRow: ["42", "pending"]
3431
)
3532

36-
// Should not throw — ALTER TABLE UPDATE must be recognized as valid
37-
#expect(throws: Never.self) {
33+
#expect(throws: DataWriteError.self) {
3834
_ = try manager.generateSQL()
3935
}
36+
#expect(manager.hasChanges)
4037
}
4138

4239
@Test("Standard UPDATE prefix is still detected for non-ClickHouse databases")
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//
2+
// RowChangeStatementFactoryUniqueMatchTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
@testable import TablePro
8+
import TableProPluginKit
9+
import Testing
10+
11+
@MainActor
12+
struct RowChangeStatementFactoryUniqueMatchTests {
13+
private let columns = ["d", "id", "v"]
14+
private let row: [PluginCellValue] = ["2026-10-22", "1", "a"]
15+
16+
private func factory(_ databaseType: DatabaseType, primaryKeyColumns: [String] = ["d"]) -> RowChangeStatementFactory {
17+
RowChangeStatementFactory(
18+
tableName: "t",
19+
schemaName: nil,
20+
columns: columns,
21+
primaryKeyColumns: primaryKeyColumns,
22+
databaseType: databaseType,
23+
pluginDriver: nil
24+
)
25+
}
26+
27+
private var edit: RowChange {
28+
RowChange(
29+
rowID: .existing(0),
30+
type: .update,
31+
cellChanges: [CellChange(columnIndex: 2, columnName: "v", oldValue: "a", newValue: "b")],
32+
originalRow: row
33+
)
34+
}
35+
36+
private var delete: RowChange {
37+
RowChange(rowID: .existing(0), type: .delete, originalRow: row)
38+
}
39+
40+
private func refusal(_ write: () throws -> [ParameterizedStatement]) -> (kind: RowWriteKind?, reason: String)? {
41+
do {
42+
let statements = try write()
43+
Issue.record("Sent \(statements.map(\.sql))")
44+
return nil
45+
} catch let DataWriteError.changeRefused(table, kind, reason) {
46+
#expect(table == "t")
47+
return (kind, reason)
48+
} catch {
49+
Issue.record("Unexpected error: \(error)")
50+
return nil
51+
}
52+
}
53+
54+
@Test(arguments: [DatabaseType.clickhouse, .snowflake, .trino])
55+
func aDeleteIsRefusedInsteadOfKeyedOnAKeyTheEngineDoesNotKeepUnique(databaseType: DatabaseType) {
56+
let refused = refusal {
57+
try factory(databaseType).statements(for: [delete], deletedRowIDs: [.existing(0)])
58+
}
59+
#expect(refused?.kind == .delete)
60+
#expect(refused?.reason.contains(databaseType.displayName) == true)
61+
#expect(refused?.reason.contains("SQL statement") == true)
62+
}
63+
64+
@Test(arguments: [DatabaseType.clickhouse, .snowflake, .trino])
65+
func anEditIsRefusedInsteadOfKeyedOnAKeyTheEngineDoesNotKeepUnique(databaseType: DatabaseType) {
66+
let refused = refusal { try factory(databaseType).statements(for: [edit]) }
67+
#expect(refused?.kind == .update)
68+
}
69+
70+
@Test
71+
func aNewClickHouseRowIsStillInserted() throws {
72+
let inserted = RowID.inserted(UUID())
73+
let statements = try factory(.clickhouse).statements(
74+
for: [RowChange(rowID: inserted, type: .insert)],
75+
insertedRowData: [inserted: row],
76+
insertedRowIDs: [inserted]
77+
)
78+
#expect(statements.count == 1)
79+
#expect(statements.first?.sql.hasPrefix("INSERT") == true)
80+
}
81+
82+
@Test
83+
func aDamengTableWithAPrimaryKeyIsStillWritten() throws {
84+
let deleted = RowChange(rowID: .existing(1), type: .delete, originalRow: ["2026-10-22", "2", "a"])
85+
let statements = try factory(.dameng, primaryKeyColumns: ["id"]).statements(
86+
for: [edit, deleted],
87+
deletedRowIDs: [.existing(1)]
88+
)
89+
#expect(Set(statements.map { String($0.sql.prefix(6)) }) == ["UPDATE", "DELETE"])
90+
}
91+
92+
@Test
93+
func aDamengTableWithoutAPrimaryKeyRefusesADelete() {
94+
let refused = refusal {
95+
try factory(.dameng, primaryKeyColumns: []).statements(for: [delete], deletedRowIDs: [.existing(0)])
96+
}
97+
#expect(refused?.kind == .delete)
98+
#expect(refused?.reason.contains("primary key") == true)
99+
}
100+
101+
@Test
102+
func rewindingAClickHouseInsertIsRefused() {
103+
let target = DataWriteTarget(database: "default", schema: nil, table: "t")
104+
let record = RewindRecord(
105+
id: UUID(),
106+
historyId: nil,
107+
connectionId: UUID(),
108+
databaseType: .clickhouse,
109+
target: target,
110+
capturedAt: Date(timeIntervalSince1970: 0),
111+
generatedColumns: [],
112+
identityColumns: [],
113+
operations: [
114+
RowWriteOperation(
115+
kind: .insert,
116+
target: target,
117+
columns: columns,
118+
primaryKeyColumns: ["d"],
119+
preImage: nil,
120+
postImage: row,
121+
writtenColumns: columns,
122+
refusal: nil
123+
),
124+
]
125+
)
126+
let planner = RewindPlanner(
127+
record: record,
128+
factory: factory(.clickhouse),
129+
queryBuilder: TableQueryBuilder(
130+
databaseType: .clickhouse,
131+
dialect: PluginManager.shared.sqlDialect(for: .clickhouse),
132+
pagination: .offset
133+
)
134+
)
135+
136+
#expect(throws: DataWriteError.self) {
137+
try planner.plan(currentRows: [RewindCurrentRow(values: row)])
138+
}
139+
}
140+
}

‎changelog.d/3794.changed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The grid no longer edits or deletes ClickHouse, Snowflake, Trino or keyless Dameng rows; use a SQL statement instead.

‎changelog.d/3794.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A grid edit or delete on ClickHouse, Snowflake, Trino or a keyless Dameng table could change other rows too.

0 commit comments

Comments
 (0)